Minting Random NFTs with Chainlink VRF, IPFS, and Pinata
A blog post covering randomized NFTs and how to develop a smart contract that can mint provably random NFTs using Chainlink VRF, IPFS, and Pinata.

Hey, fren! gm. βοΈ
In this blog post, we're going to develop & deploy a smart contract that mints provably random NFTs using Chainlink VRF, IPFS, and Pinata.
β¨A Gentle Introduction to ERC Standards: ERC20 vs ERC721 vs ERC1155β¨
So, without further ado, let's get started.
Table of Content
- What are we building?
- Installation Requirements
- Setting up the Project
- Writing the Smart Contract
πΉ Working with Chainlink VRF
πΉ Working with IPFS and Pinata - Deploying Smart Contract
- Interacting with Smart Contract
- Conclusion
- Recommended Resources
What are we building?
We're going to develop and deploy an ERC721 smart contract that allows people to mint a random NFT from our NinjaCards NFT collection.




The NinjaCards collection has 4 different NFTs consisting of different Ninjas each with its own rarity:
- Genin: Common (50% chance of minting),
- Chunin: Rare (25% chance of minting),
- Jonin: Super Rare (15% chance),
- Hokage: Ultra Rare (10% chance).
When a user mints an NFT, they'll randomly get one of these 4 NFTs, depending on their luck. We're going to use Chainlink VRF to do random number generation.

Installation Requirements
In order to follow this tutorial, you must have the following installed on your machine:
- Node.js
v16.0
or above installed on our machine. To check the Node.js version, runnode --v
in the command line. To install Node.js, follow this quick guide. - Yarn or npm package manager. In this article, I'm going to use Yarn, but feel free to use npm if you're comfortable with it. To install yarn, run:
npm i -g yarn
Setting up the Project
Let's create a new directory and open VS Code.
- Start by installing Hardhat.
β¨ A Gentle Introduction to Hardhat β¨
To install Hardhat,
πΉ First, run: yarn add --dev hardhat
in the project's root directory.
πΉ Then, run: yarn hardhat
> Select Create an empty hardhat.config.js
2. Installing dependencies. We're gonna need a couple of libraries to make our smart contract work.
Open the package.json
file, delete everything in it, and paste the following:
{
"name": "ninjacards",
"devDependencies": {
"@chainlink/contracts": "^0.5.1",
"@ethersproject/bignumber": "^5.5.0",
"@nomiclabs/hardhat-ethers": "npm:hardhat-deploy-ethers",
"@nomiclabs/hardhat-etherscan": "^3.0.3",
"@nomiclabs/hardhat-waffle": "^2.0.3",
"@openzeppelin/contracts": "^4.8.0",
"@pinata/sdk": "^1.1.23",
"base64-sol": "^1.1.0",
"dotenv": "^16.0.0",
"ethereum-waffle": "^3.4.0",
"ethers": "^5.5.4",
"fs": "^0.0.1-security",
"hardhat": "^2.12.0",
"hardhat-deploy": "^0.10.5",
"hardhat-gas-reporter": "^1.0.7",
"path": "^0.12.7",
"solhint": "^3.3.7",
"solidity-coverage": "^0.7.18"
},
"scripts": {
"lint": "solhint 'contracts/*.sol'",
"lint:fix": "solhint 'contracts/**/*.sol' --fix"
}
}
package.json
should look like this. Then, to install all of these dependencies, run yarn
in the terminal. It's going to take some time to install, but once it's done, let's move to step 3.
3. Create a .env
file in the root directory, and paste your Goerli RPC URL, private key, and Etherscan API key like so:
GOERLI_RPC_URL=https://eth-goerli.g.alchemy.com/v2/ghT8CHssndeSss
PRIVATE_KEY=0b0d4ssjw672mzakwlsrxmd3md9q2P8Xx7sksade5Xe8sjds1
ETHERSCAN_API_KEY=BMWUTESLADQXUVFECGSPINBZ7IVQQS9
4. Setting up the Hardhat Config file. Now, we need to import the private keys from .env
and set up the network and compiler configurations.
To do so, open your hardhat.config.js
, delete everything in it and paste the following:
require('@nomiclabs/hardhat-etherscan')
require('@nomiclabs/hardhat-waffle')
require('hardhat-deploy')
require('solidity-coverage')
require('dotenv').config()
/**
* @type import('hardhat/config').HardhatUserConfig
*/
const GOERLI_RPC_URL = process.env.GOERLI_RPC_URL
const PRIVATE_KEY = process.env.PRIVATE_KEY
const ETHERSCAN_API_KEY = process.env.ETHERSCAN_API_KEY
module.exports = {
defaultNetwork: 'hardhat',
networks: {
hardhat: {
chainId: 31337,
},
goerli: {
url: GOERLI_RPC_URL,
accounts: [PRIVATE_KEY],
chainId: 5,
blockConfirmations: 5,
},
},
solidity: {
compilers: [
{
version: '0.8.8',
},
{
version: '0.6.6',
},
],
},
etherscan: {
apiKey: ETHERSCAN_API_KEY,
},
namedAccounts: {
deployer: {
default: 0, // by default the 0th account will be the deployer
},
},
}
hardhat.config.js
should look like this. βπ»Now that most of our project setup is done, let's move on to writing the smart contract.
Writing the Smart Contract
Create a contracts
folder in the root directory, and create a file NinjaCards.sol
in it.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.8;
contract NinjaCards {}
Since we don't want to reinvent the wheel while developing our NFT smart contract, we're going to use OpenZeppelin. OpenZeppelin, if you're not already familiar with it, is a library for secure smart contracts development on Ethereum.
Working with OpenZeppelin
OpenZeppelin provides implementations of common ERC token standards, containing lots of useful building blocks for smart contracts to build on. OpenZeppelin smart contracts are battle-tested and used by some of the most popular projects in the ecosystem.
To install OpenZeppelin, run:
yarn add --dev @openzeppelin/contracts
Then, we're going to import two of the following OpenZeppelin contracts in our NinjaCards.sol
:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.8;
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract NinjaCards is ERC721URIStorage, Ownable {}
- The
ERC721URIStorage
is an extension of theERC721
smart contract by OpenZeppelin. We'll use this to set individual token URIs while minting random NFTs. - The
Ownable.sol
provides us with a basic access control mechanism that we'll use for ourwithdraw
function allowing only the owner to withdraw funds from our smart contract. - Then, we make our
NinjaCards
contract inherit the functionality of both of these smart contracts.
Working with Chainlink VRF
We know that since blockchains are deterministic systems, they can't have randomness.
Chainlink VRFs provide a way to get provably random numbers, enabling smart contracts to guarantee fairness & randomness without compromising security or usability in applications that demand randomness.
The current model of Chainlink VRF, i.e. v2, offers two methods for requesting randomness:
- Subscription, where we can create a subscription account and fund it with LINK tokens. We can then authorize multiple consuming smart contracts to that subscription account by providing a
subscriptionId
and request for randomness. - Directly fund smart contracts with LINK tokens.
We're gonna choose the Subscription model since we can connect multiple smart contracts with it and it's much more gas efficient. Plus, I can just set up a subscription account and pretty much use it for playing with VRF in some of my other projects.
So, how this will work is when we'll mint an NFT, we'll trigger a Chainlink VRF call to get a provably random number, and then depending on that number, we'll get a random NFT minted.
To import Chainlink contracts, run:
yarn add --dev @chainlink/contracts
Once Chainlink contracts are imported, import them in the smart contract:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.8;
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@chainlink/contracts/src/v0.8/interfaces/VRFCoordinatorV2Interface.sol";
import "@chainlink/contracts/src/v0.8/VRFConsumerBaseV2.sol";
contract NinjaCards is VRFConsumerBaseV2, ERC721URIStorage, Ownable {}
Here, we import:
πΉ A VRFCoordinatorV2Interface.sol
contract which is an interface of the VRFCoordinator
. We'll use this interface to call a function named requestRandomWords()
from the VRFCoordinator. The VRFCoordinator is a contract designed to interact with the VRF service.
πΉ A VRFConsumerBaseV2.sol
contract. In order to use the VRFCoordinator, our NinjaCards.sol
contract must inherit VRFConsumerBaseV2
and implement a fulfillRandomWords()
function, which is a callback VRF function.
So, this entire process of generating a random number is a two-step process:
πΉ Step 1: We submit our VRF request by calling the requestRandomWords()
function on the VRF Coordinator and the VRF Coordinator will emit an event.
πΉ Step 2: The event is picked up by the Chainlink VRF node, the VRF node will wait for a number of specified block confirmations, and then respond back to the VRF coordinator with a random number and a proof of how it was generated.

Before moving on to implementing the VRF functions, let's work on our constructor for a bit. This will also make the compiler stop yelling at us with that red squiggly line. π
Since we're inheriting from VRFConsumerBaseV2
and ERC721URIStorage
, we have to provide their constructor as well.
contract NinjaCards is VRFConsumerBaseV2, ERC721URIStorage, Ownable {
// NFT variables
uint256 internal immutable i_mintFee;
// Chainlink VRF variables
VRFCoordinatorV2Interface private immutable i_vrfCoordinator;
constructor(uint256 mintFee, address vrfCoordinatorV2Address)
VRFConsumerBaseV2(vrfCoordinatorV2Address)
ERC721("Ninja Cards", "NINJA")
{
i_mintFee = mintFee;
i_vrfCoordinator = VRFCoordinatorV2Interface(vrfCoordinatorV2Address);
}
}
The VRFConsumerBaseV2
constructor needs an address to the VRFCoordinator. We can specify this address later in our deploy script, depending on the network we wish to deploy, and then set it to our private immutable variable i_vrfCoordinator
, and then use it to call requestRandomWords()
function later on.
We are also taking a mintFee
variable in the constructor which we'll specify during deployment, and set it to our state variable i_mintFee
.
Our Process:
We'll be mainly working with two functions: requestNFT()
and fulfillRandomWords()
.

Implementing the requestNFT function:
Let's create a function called requestNFT
that will be called by a user to mint an NFT.
function requestNFT() public payable returns (uint256 requestId) {
// check if mintFee is paid
if (msg.value < i_mintFee) {
revert NinjaCards__NotEnoughMintFee();
}
requestId = i_vrfCoordinator.requestRandomWords(
i_keyHash,
i_subscriptionId,
REQUEST_CONFIRMATIONS,
i_callbackGasLimit,
NUM_WORDS
);
s_requestIdToSender[requestId] = msg.sender; // map the caller to their respective requestIDs.
// emit an event
emit NftRequested(requestId, msg.sender);
}
Here, we call requestRandomWords()
function on the VRF Coordinator to make a request for randomness.
The requestRandomWords()
function takes in the following parameters:
πΉ a keyHash
: specifies which gas lane to use, which is how much maximum gas price you're willing to pay (in Wei) for a randomness request.
πΉ a subscriptionId
: the subscription Id that you get after creating a subscription from the Subscription Manager at vrf.chain.link.
πΉ REQUEST_CONFIRMATIONS
: how many confirmations the VRF node should wait for before responding (the longer the node waits, the more secure the random value is).
πΉ callbackGasLimit
: gas limit for the callback request to our fullRandomWords()
function.
πΉ NUM_WORDS
: how many random numbers do we want to request? In our case, we only want 1.
We'll be keeping track of individual requests with requestId
.
While we're at it, we have also created a mapping s_requestIdToSender
that we will be using to map a caller to their respective requestId
. This will be particularly helpful in the fulfillRandomWords()
function to figure out which address requested an NFT. More on this later.
We'll also emit an event to log requestId
and caller address.
So, let's declare these variables at the top and update our constructor. We'll specify the i_keyHash
, i_subscriptionId
and i_callbackGasLimit
at the time of deployment, based on the network we'll be deploying to.
// NFT variables
uint256 internal immutable i_mintFee;
mapping(uint256 => address) public s_requestIdToSender; // a mapping from requestId to the address that made that request
// Chainlink VRF variables
VRFCoordinatorV2Interface private immutable i_vrfCoordinator;
uint64 private immutable i_subscriptionId; // get subscription ID from vrf.chain.link
bytes32 private immutable i_keyHash;
uint16 private constant REQUEST_CONFIRMATIONS = 3;
uint32 private immutable i_callbackGasLimit;
uint32 private constant NUM_WORDS = 1;
constructor(
uint256 mintFee,
address vrfCoordinatorV2Address,
uint64 subId,
bytes32 keyHash,
uint32 callbackGasLimit
)
VRFConsumerBaseV2(vrfCoordinatorV2Address)
ERC721("Ninja Cards", "NINJA")
{
i_mintFee = mintFee;
// VRF variables
i_vrfCoordinator = VRFCoordinatorV2Interface(vrfCoordinatorV2Address);
i_subscriptionId = subId;
i_keyHash = keyHash;
i_callbackGasLimit = callbackGasLimit;
}
Implementing the fulfillRandomWords function:
We already know that this function will not be called by any user, but by the VRF nodes. And with this function, the VRF node will supply us with two things:
1οΈβ£ a requestId
(the same requestId
we got when we made a request),
2οΈβ£ a randomWords
array: an array of random numbers generated by the VRF node.
function fulfillRandomWords(uint256 requestId, uint256[] memory randomWords) internal override {}
Before actually working on the fulfillRandomWords()
function, let's do some homework and prepare some helper functions that will help us implement the functionality of minting random NFTs.
First, let's create a getChanceArray()
function that will provide us an array of different chances each type of NinjaCard holds.
function getChanceArray() public pure returns (uint8[4] memory){
// index 0 -> 10-0: 10% chance: Hokage
// index 1: 25-10: 15% chance: Jonin
// index 2: 50-25: 25% chance: Chunin
// index 3: 100-50: 50% chance: Genin
return [10, 25, 50, 100];
}
Now, we'll use this getChanceArray()
function to obtain a random Ninja type. To do this, let's create a getNinjaRarity()
function:
// NFT variables
uint256 internal immutable i_mintFee;
mapping(uint256 => address) public s_requestIdToSender; // a mapping from requestId to the address that made that request
enum NinjaType {
HOKAGE, // 0th item
JONIN, // 1th item
CHUNIN, // 2th item
GENIN // 3th item
}
function getNinjaRarity(uint256 randomNumber) public pure returns (NinjaType)
{
uint256 cumulativeSum = 0;
uint8[4] memory chanceArray = getChanceArray();
// loop through chanceArray: [10,25, 50, 100]
for (uint256 i = 0; i < chanceArray.length; i++) {
if (
randomNumber >= cumulativeSum && randomNumber < chanceArray[i]
) {
// if randomNumber: 0-9 => Hokage
// 10-24 => Jonin
// 25-49 => Chunin
// 50-99 => Genin
return NinjaType(i);
}
cumulativeSum = chanceArray[i];
}
}
Here, what we're doing is, we're taking a randomNumber
(which will be between 0-99 once we call this getNinjaRarity()
function in fulfillRandomWords
) and a cumulativeSum
which will be 0 to start with.
And we will loop through the chanceArray
and using this equation, return a particular NinjaType, if:
πΉ randomNumber is between 0 - 9: Hokage
πΉ randomNumber is between 10-24: Jonin
πΉ randomNumber is between 25-49: Chunin
πΉ randomNumber is between 50-99: Genin
Now, using this NinjaType, we'll mint our random NinjaCard in the fulfillRandomWords()
function.
Before implementing the fulfillRandomWords()
function, we have to declare a couple of variables and events that we're going to use in fulfillRandomWords()
and update the constructor.
// Events
event NFTRequested(uint256 indexed requestId, address requester);
event NFTMinted(NinjaType ninjaType, address minter);
// NFT variables
uint256 internal immutable i_mintFee;
mapping(uint256 => address) public s_requestIdToSender;
uint256 private s_tokenCounter;
string[] internal s_ninjaTokenURIs;
// update the constructor with s_tokenCounter and s_ninjaTokenURIs
constructor(
uint256 mintFee,
address vrfCoordinatorV2Address,
uint64 subId,
bytes32 keyHash,
uint32 callbackGasLimit
)
VRFConsumerBaseV2(vrfCoordinatorV2Address)
ERC721("Ninja Cards", "NINJA")
{
i_mintFee = mintFee;
s_tokenCounter = 0;
// VRF variables
i_vrfCoordinator = VRFCoordinatorV2Interface(vrfCoordinatorV2Address);
i_subscriptionId = subId;
i_keyHash = keyHash;
i_callbackGasLimit = callbackGasLimit;
}
Now, finally on to the fulfillRandomWords()
function:
function fulfillRandomWords(uint256 requestId, uint256[] memory randomWords)
internal
override
{
// Step 1 - figure out nftOwner
address nftOwner = s_requestIdToSender[requestId]; // map the requestId to whoever sent the request for Randomness
// Step 2 - mint the NFT
uint256 tokenId = s_tokenCounter; // assign unique tokenId
// figure out which NFT to mint
// create a randomNumber by modding the value provided by VRF through randomWords[] array
uint256 randomNumber = randomWords[0] % 100; // get a random number between 0 - 99
NinjaType ninjaType = getNinjaRarity(randomNumber); // get which random NinjaCard to mint
_safeMint(nftOwner, tokenId); // finally, mint the NFT using _safeMint function of the ERC721 standard
// set Token URI of that particular NFT
_setTokenURI(tokenId, s_ninjaTokenURIs[uint256(ninjaType)]); // takes tokenID and tokenURI
s_tokenCounter += 1; // increment the token count
// emit event
emit NftMinted(ninjaType, nftOwner);
}
The fulfillRandomWords()
function gives a randomWords
array, and since we only requested 1 random number, we can access it at the 0th position in the randomWords[]
array.
Although the random number we get from VRF is a very large uint256
number, something like: 129239802301234010394176542919394725242227471
.
So, we mod it by 100 to get it down to between 0 - 99. And then, we pass it on to our getNinjaRarity()
function which does its math and gives us a random NinjaType.
Remember, we created a mapping s_requestIdToSender
and I said we'll get to it later on why we need it. So, the thing is in order to mint an NFT, we need to know who is minting it.
And here, in the fulfillRandomWords()
function, we can't do the normal msg.sender
wizardry that we're used to doing because the minter will not be the one calling this fulfillRandomWords()
function, it will be the VRF nodes that will be calling it. So, assigning msg.sender
as nftOwner
will just make the VRF node the nftOwner
. Hope you're getting what I'm trying to say. π€
So, this way we can map the requestId
to the address who made that request earlier in requestNFT()
function and then assign that address to nftOwner
. π€―
And now, we have the nftOwner
and a unique tokenId
, so let's just mint the NFT by calling _safeMint()
function from OpenZeppelin ERC721 contract.
At this point, we have minted an NFT. But we still have one more thing to do. We need to set a tokenURI
for that particular NFT.
Storing Token URIs with IPFS and Pinata:
IPFS is a peer-to-peer protocol for storing and sharing data in a distributed file system. We're going to use IPFS to store the image and token metadata for our NFTs.
Pinata is a cloud service that makes it easy to pin data to IPFS. We're going to upload our NFT images and metadata to Pinata and generate token URIs.
To do so:
πΉ We'll to Pinata and create a new account.
πΉ Once the account is created, we'll head over to Pinata dashboard and upload our NFT images and grab their individual CIDs.

πΉ Then, we'll create a metadata file for each of the 4 NinjaCards and add the image CIDs to the image
attribute by appending ipfs://
to it, like so:
{
"description": "Ninja Cards",
"external_url": "https://cryptoshuriken.com",
"image": "ipfs://QmfExSLtVQwsFJNcN6AaW8DZsrL9CYsbHmxVdeLWkRzuyj",
"name": "Lord Hokage",
"attributes": [
{
"trait_type": "Rank",
"value": "Hokage"
},
{
"trait_type": "Rarity",
"value": "Ultra Rare"
},
{
"trait_type": "Graphic",
"value": "Pixelated"
},
{
"trait_type": "Inspired by",
"value": "Naruto"
}
]
}
Once we have four metadata JSON files ready, we'll upload them to Pinata the same way we uploaded the images and gather their individual CIDs.

Now, we'll store these CIDs in our s_ninjaTokenURIs
variable like so:
string[] internal s_ninjaTokenURIs = [
"ipfs://QmZEYCAm6go1X2sRc9h2BHmSgKzBwsSR26SvFcm1JavCWE",
"ipfs://QmSFf5WNMpiUNB7bpCAK4KhyvvQZDaQNMcHgYFmewoNAuA",
"ipfs://QmTXSbmHnXzPqFpJxvhCmMDJ8vq4STzXFyipSxx6ZMA9he",
"ipfs://QmfXZEUQw191DUVxKwnggacA3HkGJaQTiYyfTPCYskMvs2"
]; // [Hokage, Jonin, Chunin, Genin]
And now, in the fulfillRandomWords()
function, we'll set the token URI of the minted NFT by getting which ninjaType
NFT was minted and putting its index into the s_ninjaTokenURIs
array:
// set Token URI of that particular NFT
_setTokenURI(tokenId, s_ninjaTokenURIs[uint256(ninjaType)]);
So, suppose the NinjaCard NFT minted was of Hokage, we pass that ninjaType
by converting the NinjaType
enum item to uint256. Since, HOKAGE
is the 0th item in the NinjaType
enum, we pass: s_ninjaTokenURIs[0]
and we already have the token URI of Hokage NFT at 0th place in the s_ninjaTokenURIs
.
Withdrawing Funds from Smart Contract
Now, we have one last thing to do in our Smart Contract, i.e., allowing the owner of the smart contract to withdraw funds collected from other NFT minters.
function withdraw() public onlyOwner {
uint256 amount = address(this).balance;
(bool success, ) = payable(msg.sender).call{value: amount}("");
if (!success) {
revert NinjaCards__WithdrawalFailed();
}
}
While we're at it, let's also create a couple of view functions to get information about some of the private variables.
function getMintFee() public view returns (uint256) {
return i_mintFee;
}
function getTokenURIs(uint256 index) public view returns (string memory) {
return s_ninjaTokenURIs[index];
}
function getTokenCounter() public view returns (uint256) {
return s_tokenCounter;
}
Now, let's compile our Smart Contract and get ready for deployment.
To compile, run: yarn hardhat compile

Our smart contract has compiled with 0 errors and 1 warning. We'll ignore the warning.
Here's the entire code of our NinjaCards.sol
contract:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.8;
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@chainlink/contracts/src/v0.8/interfaces/VRFCoordinatorV2Interface.sol";
import "@chainlink/contracts/src/v0.8/VRFConsumerBaseV2.sol";
error NinjaCards__NotEnoughMintFee();
error NinjaCards__WithdrawalFailed();
contract NinjaCards is VRFConsumerBaseV2, ERC721URIStorage, Ownable {
// NFT variables
uint256 internal immutable i_mintFee;
mapping(uint256 => address) public s_requestIdToSender; // a mapping from requestId to the address that made that request
uint256 private s_tokenCounter;
string[] internal s_ninjaTokenURIs = [
"ipfs://QmZEYCAm6go1X2sRc9h2BHmSgKzBwsSR26SvFcm1JavCWE",
"ipfs://QmSFf5WNMpiUNB7bpCAK4KhyvvQZDaQNMcHgYFmewoNAuA",
"ipfs://QmTXSbmHnXzPqFpJxvhCmMDJ8vq4STzXFyipSxx6ZMA9he",
"ipfs://QmfXZEUQw191DUVxKwnggacA3HkGJaQTiYyfTPCYskMvs2"
]; // [Hokage, Jonin, Chunin, Genin]
enum NinjaType {
HOKAGE, // 0th item
JONIN, // 1st item
CHUNIN, // 2nd item
GENIN // 3rd item
}
// Chainlink VRF variables
VRFCoordinatorV2Interface private immutable i_vrfCoordinator;
uint64 private immutable i_subscriptionId; // get subscription ID from vrf.chain.link
bytes32 private immutable i_keyHash;
uint16 private constant REQUEST_CONFIRMATIONS = 3;
uint32 private immutable i_callbackGasLimit;
uint32 private constant NUM_WORDS = 1;
// Events
event NftRequested(uint256 indexed requestId, address requester);
event NftMinted(NinjaType ninjaType, address minter);
constructor(
uint256 mintFee,
address vrfCoordinatorV2Address,
uint64 subId,
bytes32 keyHash,
uint32 callbackGasLimit
)
VRFConsumerBaseV2(vrfCoordinatorV2Address)
ERC721("Ninja Cards", "NINJA")
{
i_mintFee = mintFee;
s_tokenCounter = 0;
// VRF variables
i_vrfCoordinator = VRFCoordinatorV2Interface(vrfCoordinatorV2Address);
i_subscriptionId = subId;
i_keyHash = keyHash;
i_callbackGasLimit = callbackGasLimit;
}
function requestNFT() public payable returns (uint256 requestId) {
// check if mintFee is paid
if (msg.value < i_mintFee) {
revert NinjaCards__NotEnoughMintFee();
}
requestId = i_vrfCoordinator.requestRandomWords(
i_keyHash, //
i_subscriptionId,
REQUEST_CONFIRMATIONS,
i_callbackGasLimit,
NUM_WORDS
);
s_requestIdToSender[requestId] = msg.sender; // map the caller to their respective requestIDs.
// emit an event
emit NftRequested(requestId, msg.sender);
}
function fulfillRandomWords(uint256 requestId, uint256[] memory randomWords)
internal
override
{
// Step 1 - figure out nftOwner
address nftOwner = s_requestIdToSender[requestId]; // map the requestId to whoever sent the request for Randomness
// Step 2 - mint the NFT
uint256 tokenId = s_tokenCounter; // assign unique tokenId
// figure out which NFT to mint
// create a randomNumber by modding the value provided by VRF through randomWords[] array
uint256 randomNumber = randomWords[0] % 100; // get a random number between 0 - 99
NinjaType ninjaType = getNinjaRarity(randomNumber); // get which random NinjaCard to mint
_safeMint(nftOwner, tokenId); // finally, mint the NFT using _safeMint function
// set Token URI of that particular NFT
_setTokenURI(tokenId, s_ninjaTokenURIs[uint256(ninjaType)]); // takes tokenID and tokenURI
s_tokenCounter += 1; // increment the token count
// emit event
emit NftMinted(ninjaType, nftOwner);
}
function getNinjaRarity(uint256 randomNumber)
public
pure
returns (NinjaType)
{
uint256 cumulativeSum = 0;
uint8[4] memory chanceArray = getChanceArray();
// loop through chanceArray: [10,25, 50, 100]
for (uint256 i = 0; i < chanceArray.length; i++) {
if (
randomNumber >= cumulativeSum && randomNumber < chanceArray[i]
) {
// if randomNumber: 0-9 => Hokage
// 10-24 => Jonin
// 25-49 => Chunin
// 50-99 => Genin
return NinjaType(i);
}
cumulativeSum = chanceArray[i];
}
}
function getChanceArray() public pure returns (uint8[4] memory) {
// index 0 -> 10-0: 10% chance: Hokage
// index 1: 25-10: 15% chance: Jonin
// index 2: 50-25: 25% chance: Chunin
// index 3: 100-50: 50% chance: Genin
return [10, 25, 50, 100];
}
function withdraw() public onlyOwner {
uint256 amount = address(this).balance;
(bool success, ) = payable(msg.sender).call{value: amount}("");
if (!success) {
revert NinjaCards__WithdrawalFailed();
}
}
// View Functions
function getMintFee() public view returns (uint256) {
return i_mintFee;
}
function getTokenURIs(uint256 index) public view returns (string memory) {
return s_ninjaTokenURIs[index];
}
function getTokenCounter() public view returns (uint256) {
return s_tokenCounter;
}
}
Deploying Smart Contract
First, let's create our VRF subscription and fund it with some LINK tokens.
To create a subscription, go to the Chainlink Subscription Manager and click on "Create Subscription". Once you create a subscription, add some LINK tokens to it by clicking on the "Add Funds" button. You can grab some LINK at faucets.chain.link.

Now, grab the Subscription ID of your VRF subscription.

Let's create a helper file to hold all our network and arguments related data that we can later import to our deploy script.
Create a helper-hardhat-config.js
file in the root directory, and put all the values for our constructor arguments.
const networkConfig = {
5: {
name: 'goerli',
vrfCoordinatorV2: '0x2Ca8E0C643bDe4C2E08ab1fA0da3401AdAD7734D',
keyHash:
'0x79d3d8832d904592c0bf9818b621522c988bb8b0c05cdc3b15aea1b6e8db0c15',
callbackGasLimit: '500000', // 500,000 gas
mintFee: '20000000000000000', // 0.02 ETH
subscriptionId: '6506', // add your subscription ID here!
},
}
const developmentChains = ['hardhat', 'localhost']
module.exports = {
networkConfig,
developmentChains,
}
Here we specified the mintFee to be 0.02 ETH as well as values for other constructor arguments.
Before deployment, let's also create a contract verification script to verify our smart contract on Goerli Etherscan.
To do so, Β we'll create a verify.js
script in the root directory.
const { run } = require('hardhat')
const verify = async (contractAddress, args) => {
console.log('Verifying contract...')
try {
await run('verify:verify', {
address: contractAddress,
constructorArguments: args,
})
} catch (e) {
if (e.message.toLowerCase().includes('already verified')) {
console.log('Already verified!')
} else {
console.log(e)
}
}
}
module.exports = {
verify,
}
And now, let's create a deploy
folder in our root directory and create a deploy-ninja-cards.js
script in it.
Import both the helper-hardhat-config.js
and verify.js
into the deploy script and declare constructor argument variables, and pass them into the args
array.
And deploy the contract using the deploy()
function in Hardhat.
const { network, ethers } = require('hardhat')
const { verify } = require('../verify')
const { developmentChains, networkConfig } = require('../helper-hardhat-config')
module.exports = async function({ getNamedAccounts, deployments }) {
const { deploy, log } = deployments
const { deployer } = await getNamedAccounts()
const chainId = network.config.chainId
let mintFee,
subscriptionId,
vrfCoordinatorV2Address,
keyHash,
callbackGasLimit
if (!developmentChains.includes(network.name)) {
mintFee = networkConfig[chainId].mintFee
subscriptionId = networkConfig[chainId].subscriptionId
vrfCoordinatorV2Address = networkConfig[chainId].vrfCoordinatorV2
keyHash = networkConfig[chainId].keyHash
callbackGasLimit = networkConfig[chainId].callbackGasLimit
}
const args = [
mintFee,
vrfCoordinatorV2Address,
subscriptionId,
keyHash,
callbackGasLimit,
]
log('Deploying...')
const ninjaCards = await deploy('NinjaCards', {
from: deployer,
args: args,
log: true,
waitConfirmations: network.config.blockConfirmations || 1,
})
// Verify contract
if (
!developmentChains.includes(network.name) &&
process.env.ETHERSCAN_API_KEY
) {
log('Verifying...')
await verify(ninjaCards.address, args)
}
}
Running the Deploy Script:
To run the deploy script, run the following command in terminal:
yarn hardhat deploy --network goerli

Interacting with the Smart Contract
Before we can start requesting random number from VRF, we have to add our deployed smart contract to the Subscription we created earlier in the Chainlink VRF Subscription Manager.
So, grab your smart contract deployment address and add it by clicking on the "Add Consumer" button.

Now, let's head over to Goerli Etherscan and interact with our already deployed & verified smart contract, and finally start minting cool NFTs that are provably random.
Let's call our requestNFT()
function, pay mint fee and mint an NFT.

Once, we click on the Write
button, Metamask will ask us to sign the transaction. Once we sign, it will make a randomness request to VRF nodes.
The request will show up at our Subscription Manager with useful information such as txn hash, request status, gas spent, etc.

The VRF nodes will take some time for block confirmation and then send back a random number. After that, our fulfillRandomWords()
function will execute minting a random NFT as result.
Then, we can head over to OpenSea testnet and put in our contract address and search for our NFT.
Our 100% provably random NFT should now show up at OpenSea with all of its metadata properties.

Conclusion
That's it for now, fren!
It's been a long journey so far. I hope you've made it to the end. In this article, we've learned about working with OpenZeppelin, Chainlink VRF, and storing files on a distributed file system with IPFS and Pinata.
Here's the GitHub repo where you can find all project files:
What's next?
While this blog post has become quite long, there are a few things that you can do to take this project to next level.
Some of them could include:
πΉ Writing tests with Mocha
πΉ Connecting a Front-end so that an average user can mint these cool NFTs through a nice user interface instead of interacting with functions on Etherscan.
Recommended Resources:
- Chainlink VRF v2 Developer Walkthrough - Chainlink YouTube
- Chainlink VRF Docs
- Patrick Collins' Blockchain & Solidity course - freeCodeCamp
If you enjoyed reading this article, please consider subscribing here so that you get a notification in your mail whenever I post new stuff. Subscribing is free, and it gives me a ton of motivation to keep going.
#WAGMI βπ»
