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.

Minting Random NFTs with 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.

πŸ’‘
Be sure to follow this article to get an overview of the ERC721 token standard if you're unfamiliar with it:

✨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?

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.

BTW, I got this idea while watching The Philosopher's Stone

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, run node --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.

  1. Start by installing Hardhat.
πŸ’‘
If you're unfamiliar with Hardhat, here's a blog post that you can follow to get up & running:

✨ 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"
  }
}
Your 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
Not my real keys. πŸ˜€
πŸ’‘
For instructions on how to grab API and private keys, refer to this blog post.

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
    },
  },
}
Your 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 {}
contracts/NinjaCards.sol

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 the ERC721 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 our withdraw 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.

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.

πŸ’‘
If you're unfamiliar with the concept of Interfaces, check out this tutorial.

πŸ”Ή 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.

Chainlink VRF End-to-End Diagram.
End-to-End Diagram of the VRF Process. Image Credit: Chainlink Docs
🎩
A hat tip to Aditya Bhattad for helping me understand the technicalities behind these VRF contracts in his amazing explanation. πŸ™πŸ»

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.

Image 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.

Metadata 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;
    }
}
contracts/NinjaCards.sol

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.

Subscription ID

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,
}
helper-hardhat-config.js
πŸ’‘
You can find all the values for VRF Coordinator parameters for different networks here.

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,
}
verify.js

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)
  }
}
deploy/deploy-ninja-cards.js

Running the Deploy Script:

To run the deploy script, run the following command in terminal:

yarn hardhat deploy --network goerli
Smart Contract deployed & verified! πŸŽ‰

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.

Add your smart contract address as a consumer to start requesting random numbers.

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:

GitHub - cryptoshuriken/ninja-cards-article: Code files for following along with the Random NFTs article
Code files for following along with the Random NFTs article - GitHub - cryptoshuriken/ninja-cards-article: Code files for following along with the Random NFTs article
If you enjoyed this project, feel free to ⭐️ the GitHub repo. πŸ™ŒπŸ»

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.


ℹ️
This blog post is inspired by the NFT lesson in Patrick's course and acts as my personal notes on the Random NFT project I've done while following that course.

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 ✌🏻