Building a lottery smart contract using chainlink VRF.

Building a lottery smart contract using chainlink VRF.

In this article, we'll be learning how to build and deploy a lottery smart contract using Chainlink's verifiable random function (VRF) to select winners randomly. We'll also build a Dapp for our smart contract using Ethers.js and Next.js.

Alright, let's get our hands dirty, we'll enjoy the ride...

Prerequisites

For us to have a smooth ride, let's ensure we meet the following requirements;

  • Have Node installed on our PC.

  • Have a basic understanding of Javascript/React.js.

  • Ensure we have an Ethereum wallet installed, preferably Metamask.

  • Basic understanding of the Remix IDE.

  • A basic understanding of solidity will give us an edge but we don't have to worry about that.

Quoting Chainlink's docs, Chainlink VRF (Verifiable Random Function) is a provably fair and verifiable random number generator (RNG) that enables smart contracts to access random values without compromising security or usability. As we learned in our introduction, we'll be using the random number returned to us by the VRF to select the winner of our lottery.

The question now is, why are we using Chainlink for this? Why can't we just generate random numbers as we do in Javascript? The answer is that getting a random number is a big challenge in Solidity as there's no native random number generator in solidity. Also, there's no randomness in blockchain as it is deterministic. This means that we can predict any future state if we know the past or current state of the system. We don't want this vulnerability in our smart contract, that's why we are using a decentralized oracle network, Chainlink to get a truly verifiable random number. We can read more about oracle networks here.

Building the lottery contract

Before we start writing our smart contract, we need to know that our contract will be USDC-based. That is, we'll be using USDC to be able to partake in the lottery. Now, let's dive into writing our contract.

Writing smart contracts using solidity.

We'll be writing two separate contracts for our project. The first is the contract to generate the random number while the second is our main lottery contract. We'll also dwell less on the random number generator contract as we have a more comprehensive resource on how to use it here.

Random number generator contract

Let's open our remix IDE, and create a new file named _RandomNumber.sol. Then we copy the code below and paste it into the empty file we just created.

// SPDX-License-Identifier: MIT
// An example of a consumer contract that relies on a subscription for funding.
pragma solidity ^0.8.7;

import "@chainlink/contracts/src/v0.8/interfaces/VRFCoordinatorV2Interface.sol";
import "@chainlink/contracts/src/v0.8/VRFConsumerBaseV2.sol";
import "@chainlink/contracts/src/v0.8/ConfirmedOwner.sol";

/**
 * Request testnet LINK and ETH here: https://faucets.chain.link/
 * Find information on LINK Token Contracts and get the latest ETH and LINK faucets here: https://docs.chain.link/docs/link-token-contracts/
 */

/**
 * THIS IS AN EXAMPLE CONTRACT THAT USES HARDCODED VALUES FOR CLARITY.
 * THIS IS AN EXAMPLE CONTRACT THAT USES UN-AUDITED CODE.
 * DO NOT USE THIS CODE IN PRODUCTION.
 */

contract VRFv2Consumer is VRFConsumerBaseV2, ConfirmedOwner {
    event RequestSent(uint256 requestId, uint32 numWords);
    event RequestFulfilled(uint256 requestId, uint256[] randomWords);

    struct RequestStatus {
        bool fulfilled; // whether the request has been successfully fulfilled
        bool exists; // whether a requestId exists
        uint256[] randomWords;
    }
    mapping(uint256 => RequestStatus)
        public s_requests; /* requestId --> requestStatus */
    VRFCoordinatorV2Interface COORDINATOR;

    // Your subscription ID.
    uint64 s_subscriptionId;

    // past requests Id.
    uint256[] public requestIds;
    uint256 public lastRequestId;

    // The gas lane to use, which specifies the maximum gas price to bump to.
    // For a list of available gas lanes on each network,
    // see https://docs.chain.link/docs/vrf/v2/subscription/supported-networks/#configurations
    bytes32 keyHash =
        0x79d3d8832d904592c0bf9818b621522c988bb8b0c05cdc3b15aea1b6e8db0c15;

    // Depends on the number of requested values that you want sent to the
    // fulfillRandomWords() function. Storing each word costs about 20,000 gas,
    // so 100,000 is a safe default for this example contract. Test and adjust
    // this limit based on the network that you select, the size of the request,
    // and the processing of the callback request in the fulfillRandomWords()
    // function.
    uint32 callbackGasLimit = 2500000;

    // The default is 3, but you can set this higher.
    uint16 requestConfirmations = 3;

    // For this example, retrieve 2 random values in one request.
    // Cannot exceed VRFCoordinatorV2.MAX_NUM_WORDS.
    uint32 numWords = 1;

    /**
     * HARDCODED FOR GOERLI
     * COORDINATOR: 0x2Ca8E0C643bDe4C2E08ab1fA0da3401AdAD7734D
     */
    constructor(
        uint64 subscriptionId
    )
        VRFConsumerBaseV2(0x2Ca8E0C643bDe4C2E08ab1fA0da3401AdAD7734D)
        ConfirmedOwner(msg.sender)
    {
        COORDINATOR = VRFCoordinatorV2Interface(
            0x2Ca8E0C643bDe4C2E08ab1fA0da3401AdAD7734D
        );
        s_subscriptionId = subscriptionId;
    }

    // Assumes the subscription is funded sufficiently.
    function requestRandomWords()
        external
        returns (uint256 requestId)
    {
        // Will revert if subscription is not set and funded.
        requestId = COORDINATOR.requestRandomWords(
            keyHash,
            s_subscriptionId,
            requestConfirmations,
            callbackGasLimit,
            numWords
        );
        s_requests[requestId] = RequestStatus({
            randomWords: new uint256[](0),
            exists: true,
            fulfilled: false
        });
        requestIds.push(requestId);
        lastRequestId = requestId;
        emit RequestSent(requestId, numWords);
        return requestId;
    }

    function fulfillRandomWords(
        uint256 _requestId,
        uint256[] memory _randomWords
    ) internal override {
        require(s_requests[_requestId].exists, "request not found");
        s_requests[_requestId].fulfilled = true;
        s_requests[_requestId].randomWords = _randomWords;
        emit RequestFulfilled(_requestId, _randomWords);
    }

    function getRequestStatus(
        uint256 _requestId
    ) external view returns (bool fulfilled, uint256[] memory randomWords) {
        require(s_requests[_requestId].exists, "request not found");
        RequestStatus memory request = s_requests[_requestId];
        return (request.fulfilled, request.randomWords);
    }
}

The code above is already hardcoded for the Goerli testnet which is the network we'll be deploying our contract on and we're using the subscription method for Chainlink VRF. Let's also confirm that we're using the correct VRF Coordinator for our chosen testnet here.

Now that we're done with the above, let's compile and deploy our random number generator contract with our subscription ID from Chainlink.

Now we copy the contract address of our deployed contract above and add it as a consumer of our Chainlink VRF subscription. That's all for the random number generator contract. We'll now use it in our main lottery contract.

Lottery smart contract

Still, on the Remix IDE, let's create another file named LotteryContract.sol. Then let's copy the code below and paste it into our newly created file.

// SPDX-License-Identifier: GPL-3.0

pragma solidity ^0.8.11;

import "./_RandomNumber.sol";

interface USDC {
    function balanceOf(address account) external view returns (uint256);

    function allowance(address owner, address spender)
        external
        view
        returns (uint256);

    function transfer(address recipient, uint256 amount)
        external
        returns (bool);

    function approve(address spender, uint256 amount) external returns (bool);

    function transferFrom(
        address sender,
        address recipient,
        uint256 amount
    ) external returns (bool);
}

contract Lottery {
    // instance of the usdc token for the contract
    USDC public usdc;

    // Contract Owner
    address payable internal owner;

    // Array that stores the players in the lottery
    address[] public players;

    // Lottery entry fee
    uint256 public entryFee = 1 * (10**6);

    // Variable for storing lottery IDs
    uint256 lotteryId;

    // Mapping for storing winners of each lottery
    mapping(uint256 => Winner) lotterWinners;

    // Mapping to store if a user have already entered the lottery
    mapping(address => bool) alreadyEntered;

    // Winner struct that stores the lottery ID and the winner of that particular lottery
    struct Winner {
        uint256 id;
        address winner;
    }
    // Random number initialization
    VRFv2Consumer public randomNumContract;

    constructor(address usdcContractAddress) {
        usdc = USDC(usdcContractAddress);
        owner = payable(msg.sender);
        lotteryId = 1;
        // USDC contract: 0x07865c6E87B9F70255377e024ace6630C1Eaa37F

        // Assigning random number contract(replace with the one you deployed)
        randomNumContract = VRFv2Consumer(
            0x6E2E6007f69014e59324Cf214000CCF4626DdF43
        );
    }

    // Random number function - requests random words from chainlink
    function requestRanNum() public {
        // Request random words first
        randomNumContract.requestRandomWords();
    }

    // RandomValues function to verify whether chainlink has returned random words.

    function RandomValues()
        public
        view
        returns (bool fulfilled, uint256[] memory randomWords)
    {
        uint256 requestID = randomNumContract.lastRequestId();
        (fulfilled, randomWords) = randomNumContract.getRequestStatus(
            requestID
        );
    }

    // This functions returns the random number given to us by chainlink
    function randomNumGenerator() public view returns (uint256) {
        //    uint256 requestID = getRequestId();
        uint256 requestID = randomNumContract.lastRequestId();
        // Get random words array
        (, uint256[] memory randomWords) = randomNumContract.getRequestStatus(
            requestID
        );

        // return first random word
        return randomWords[0];
    }

    // This function allows the user to enter the lottery after meeting the requirements
    function enterLottery() public {
        require(msg.sender != address(0), "Use a valid account");
        require(players.length <= 9, "No vacancy now");
        require(!alreadyEntered[msg.sender], "Already entered");
        usdc.transferFrom(msg.sender, address(this), entryFee);
        players.push(msg.sender);
        alreadyEntered[msg.sender] = true;
    }

    // This function picks a winner and transfers the reward to the winner
    function pickWinner() public onlyOwner {
        require(players.length == 10, "players not yet complete");
        uint256 withdrawAmount = (usdc.balanceOf(address(this)) * 9800) / 10000;
        uint256 ownerShare = (usdc.balanceOf(address(this)) * 200) / 10000;
        uint256 index = randomNumGenerator() % players.length;
        usdc.transfer(players[index], withdrawAmount);
        usdc.transfer(owner, ownerShare);
        lotterWinners[lotteryId] = Winner({
            id: lotteryId,
            winner: players[index]
        });

        // this resets the players entry status after a winner has been picked
        for (uint256 i = 0; i < players.length; i++) {
            alreadyEntered[players[i]] = false;
        }
        // The ID is incremented for the next lottery
        lotteryId += 1;
        // Reset the players array.
        players = new address[](0);
    }

    // This function returns an array of the players in the current lottery
    function seePlayers() public view returns (address[] memory) {
        return players;
    }

    // This funcrion returns an array of winners of different lotteries.
    function seeWinners() public view returns (Winner[] memory) {
        Winner[] memory arr = new Winner[](lotteryId - 1);
        for (uint256 i = 0; i < lotteryId - 1; i++) {
            arr[i] = lotterWinners[i + 1];
        }
        return arr;
    }

    // modifier that gives only the owner of the contract certain functionalities
    modifier onlyOwner() {
        require(msg.sender == owner, "not owner of contract");
        _;
    }
}

We have to ensure that the contract address we passed to the VRFv2Consumer in the above code is the one from our deployed _RandomNumber.sol contract earlier.

Before we finally deploy our lottery smart contract, let's do a quick explanation of some of the variables and functions in our smart contract.

  • import "./_RandomNumber.sol"; This line of code imports our first deployed random number contract into our lottery smart contract.

  • We have the USDC interface which enables us to interact with functions in the USDC contract.

  • USDC public usdc; Here, we used the interface to declare the variable we'll store our USDC token.

  • Some other variables in our contract include; owner, entryFee and lotteryId which stores the address of the account that deployed our contract, the fee to be eligible to partake in our lottery and the id for all the lotteries on our contract respectively.

  • address[] public players is an array that stores the players participating in a particular lottery.

  • We also have the winner struct which makes sure every winner of any lottery has the lottery id and address of the winning account.

  • The two mappings in our contract help us store key-value pairs. The alreadyEntered mapping stores the accounts that have already the lottery. This helps us to restrict an account from entering the lottery twice. The second mapping lotterWinners keeps track of the winners of various lotteries and their ids.

  • The final variable is the randomNumContract which is an instance of the first random number contract we deployed. Note that the VFRv2Consumer used to declare this variable is from the file we imported. It is the name of the contract we used to generate our random number from Chainlink.

  • Our constructor takes the address of the USDC smart contract as an argument which it assigns to the usdc variable above. Also, in the constructor, we assign the very first lottery id and owner of the contract. Finally, we assign to the randomNumContract we initialized earlier the random number contract address we deployed.

  • We also have three functions in our lottery contract that interact with the random number contract we deployed earlier. They are requestRanNum(), RandomValues() and randomNumGenerator(). These functions send requests to Chainlink for a random number, verify if the random number has been returned by Chainlink and then return the random number respectively.

  • The enterLottery() function adds an account to the lottery if it meets all the requirements stipulated in the function.

  • Our pickWinner() function does a lot of things. It first ensures that the number of players for the lottery is met before using the random number returned to us by Chainlink to select a winner. It also rewards the winner and resets the players array and the entry status of accounts that participated in the lottery so they can join the next lottery.

  • The remaining functions seePlayers() and seeWinners() are getter functions that return the list of current players in a lottery and the list of lottery winners respectively.

  • Finally, the onlyOwner() modifier ensures that only the owner of the contract can interact with certain functions.

Now that we are well-informed of what we have in our smart contract, let's quickly compile, deploy and start interacting with it. We need to deploy it with the USDC smart contract address 0x07865c6E87B9F70255377e024ace6630C1Eaa37F.

Building the front end.

Now that we've deployed our contract, let's build a simple web application to interact with it. As our prerequisites earlier stated, we need basic knowledge of Javascript and React.js for us to have smooth sailing. We'll be using Next.js which is a React framework to build our application. We'll also not dwell much on this as we have an already-made application hosted on this Github repository. Let's clone it and install the dependencies by running the following commands.

npm install && npm run dev

The command above command just opened the project on our port 3000.

Now, let's replace the smart contract address in our index.js file with the one we deployed in our lottery contract above and the admin with our owner address.

Let's save our changes and begin to interact with our smart contract but before then, the application we cloned doesn't have the requestRanNum() in it. So let's call the function from our remix environment and request the random number from Chainlink. We will also be using just one random number for our project so, there's no need to call the function again.

Alright! Let's now interact with our lottery smart contract using the web application we just built.

Enter Lottery

Our application is designed in a way that once a user clicks the enter lottery button, they first approve the USDC in their wallet for our contract to use before finally entering the lottery if they meet the other requirements. All these can only work if the user's wallet is connected.

Pick winner

We easily implemented the pickWinner() function in our smart contract on the front end. The pick winner button can only be seen if the owner of the smart contract is the signer. From the previous images, we can see that the button was invisible because we used another account that's not the owner to sign in.

Conclusion

So far, we've learned how to build a lottery smart contract with Chainlink VRF. We also built a simple dapp to interact with it. We are excited to get to the very end of this simple project. There is a lot to learn about Solidity. This resource will be of great help to us.

Let's connect via Twitter, LinkedIn & Github.