Build a Lottery Smart Contract with Chainlink VRF Random Numbers: A Step-by-Step Guide

Build a Lottery Smart Contract with Chainlink VRF Random Numbers: A Step-by-Step Guide

Overview

In this article, we will be exploring how to create a lottery smart contract using Chainlink VRF (Verifiable Random Function) random numbers. Chainlink VRF is a secure and reliable way to generate random numbers on the blockchain, making it ideal for use in lotteries and other games of chance.

By using Chainlink VRF, we can ensure that our lottery is fair and tamper-proof, giving players the confidence to participate and the assurance that the results are unbiased. We will walk through the steps of building a lottery smart contract using Chainlink VRF, and by the end of the tutorial, you will have a fully functional lottery smart contract that you can use as a template for your own projects.

You can find the entire code for the contract in the GitHub Repository

Prerequisites

Before you begin with this tutorial make sure you are comfortable with the following:

  • Basic knowledge of Solidity and the Ethereum blockchain

  • Experience with compiling and deploying contracts using Remix IDE

  • Passion for building cool things

Before starting to build the actual Lottery Contract let's take a look at Chainlink VRF and set up the needed stuff to use it in our Contract

Chainlink VRF is a powerful tool for generating random numbers on the blockchain, and it offers two methods for requesting randomness: subscription and direct funding. In this article, we'll focus on the subscription method, which allows you to fund requests for multiple consumer contracts from a single subscription account.

To create a Chainlink VRF subscription, you'll need to follow a few simple steps.

Step 1 - Creating a Subscription

To create a subscription for Chainlink VRF, follow these steps:

  1. Navigate to the subscription manager page at https://vrf.chain.link and connect your Metamask wallet. Ensure that your wallet is set to the Sepolia testnet.

  2. Click on the "Create Subscription" button and fill in the required details. Once you have entered the necessary information, click on "Create Subscription" again.

  3. You will be prompted to approve the transaction on Metamask. Approve the transaction and wait until it is confirmed on the chain.

Step 2 - Fund your Subscription

To fund your subscription and obtain random numbers, you will need to pay LINK tokens. To obtain testnet LINK tokens, visit https://faucets.chain.link/sepolia.

After obtaining LINK tokens, return to your subscription dashboard and click on the "Fund Subscription" button located under the "Actions" dropdown in the top right corner of the page. Enter the number of LINK tokens you wish to fund in the input box and click on "Confirm". You will then be prompted with a Metamask popup. Click on "Approve" to complete the transaction.

That's It. We have successfully created a subscription to get Random Numbers, now we can move to the smart contract.

Create Lottery Contract

Initializing Contract

To begin creating the lottery smart contract, we need to specify the license of the contract, and the pragma version, and import the necessary modules.

We will be using the Strings module from @openzeppelin/contracts to provide custom error messages, and the VRFConsumerBaseV2 and VRFCoordinatorV2Interface modules from @chainlink/contracts to implement random number functionality.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.14;

import "@openzeppelin/contracts/utils/Strings.sol";
import "@chainlink/contracts/src/v0.8/interfaces/VRFCoordinatorV2Interface.sol";
import "@chainlink/contracts/src/v0.8/VRFConsumerBaseV2.sol";

Next, we will create a contract called Lottery that will inherit from VRFConsumerBaseV2.

contract Lottery is VRFConsumerBaseV2 {
    // Code...
}

State Variables

To pass on the configuration to the VRFConsumerBaseV2 contract, we need to declare some state variables.

VRFCoordinatorV2Interface COORDINATOR;
uint64 subscriptionId;
address vrfCoordinator = 0x8103B0A8A00be2DDC778e6e7eaa21791Cd364625;
bytes32 keyHash =
    0x474e34a077df58807dbe9c96d3c009b23b3c6d0cce433e59bbf5b34f823bc56c;
uint32 callbackGasLimit = 100000;
uint16 requestConfirmations = 3;
uint32 numWords = 1;

uint256 public lotteryCount = 0;

Here is what each variable does:

  1. COORDINATOR - This is the contract address that we will use to request randomness.

  2. subscriptionId - This is the subscription ID that this contract uses for funding requests.

  3. vrfCoordinator - This is the address of the coordinator on the Sepolia testnet.

  4. keyHash - This is the gas lane key hash value, which functions as an ID of the off-chain VRF job that runs in response to requests.

  5. callbackGasLimit - This is the limit for how much gas to use for the callback request to your contract's function.

  6. requestConfirmations - This is how many confirmations the Chainlink node should wait before responding. The longer the node waits, the more secure the random value is.

  7. numWords - This is how many random values to request. If you can use several random values in a single callback, you can reduce the amount of gas that you spend per random value.

  8. lotteryCount - This is the variable that keeps track of the total lotteries.

To keep track of the data for the lottery, we need to create some structures and mappings.

struct LotteryData {
        address lotteryOperator;
        uint256 ticketPrice;
        uint256 maxTickets;
        uint256 operatorCommissionPercentage;
        uint256 expiration;
        address lotteryWinner;
        address[] tickets;
}

struct LotteryStatus {
    uint256 lotteryId;
    bool fulfilled;
    bool exists;
    uint256[] randomNumber;
}

mapping(uint256 => LotteryData) public lottery;
mapping(uint256 => LotteryStatus) public requests;

Here is what each structure and mapping does:

  1. LotteryData struct - This structure keeps a record of the lotteryOperator, which is the address that can start and end a lottery, ticketPrice, which is the price of each ticket, maxTickets, which is the maximum number of tickets, operatorCommissionPercentage, which is a percentage of the total pool that is sent to the operator, expiration, which is the time when the lottery ends, lotteryWinner, which is the address of the winner, and tickets, which stores all the tickets for the lottery.

  2. LotteryStatus struct - This structure keeps track of the request sent to the VRFCoordinator after the lottery is ended. It stores some states like the request fulfilled status and the randomNumber.

  3. lottery mapping - This mapping maps lotteryCount to the LotteryData struct.

  4. requests mapping - This mapping maps to LotteryStatus.

Constructor

The constructor function is used to initialize the Lottery contract. It takes in a subscriptionId as a parameter, which is used to fund requests for random numbers.

Inside the constructor function, the COORDINATOR variable is set to an instance of the VRFCoordinatorV2Interface contract using the vrfCoordinator address. The subscriptionId variable is set to the _subscriptionId parameter passed to the function.

constructor(uint64 _subscriptionId) VRFConsumerBaseV2(vrfCoordinator) {
    COORDINATOR = VRFCoordinatorV2Interface(vrfCoordinator);
    subscriptionId = _subscriptionId;
}

Events

The Lottery contract has several events that are emitted during different stages of the lottery process.

event LotteryCreated(
    address lotteryOperator,
    uint256 ticketPrice,
    uint256 maxTickets,
    uint256 operatorCommissionPercentage,
    uint256 expiration
);

event LogTicketCommission(
    uint256 lotteryId,
    address lotteryOperator,
    uint256 amount
);

event TicketsBought(
    address buyer,
    uint256 lotteryId,
    uint256 ticketsBought
);

event LotteryWinnerRequestSent(
    uint256 lotteryId,
    uint256 requestId,
    uint32 numWords
);

event RequestFulfilled(uint256 requestId, uint256[] randomWords);

event LotteryWinnerDrawn(uint256 lotteryId, address lotteryWinner);

event LotteryClaimed(
    uint256 lotteryId,
    address lotteryWinner,
    uint256 amount
);

Here is what each event does:

  1. LotteryCreated - This event is emitted when a new lottery is created. It includes information about the lotteryOperator, ticketPrice, maxTickets, operatorCommissionPercentage, and expiration.

  2. LogTicketCommission - This event is emitted when lottery commission is sent to the operator. It includes information about the lotteryId, lotteryOperator, and amount.

  3. TicketsBought - This event is emitted when a user buys tickets. It includes information about the buyer, lotteryId, and ticketsBought.

  4. LotteryWinnerRequestSent - This event is emitted when a random number request is sent to Chainlink VRF. It includes information about the lotteryId, requestId, and numWords.

  5. RequestFulfilled - This event is emitted when a random number request is fulfilled by Chainlink VRF. It includes information about the requestId and randomWords.

  6. LotteryWinnerDrawn - This event is emitted when a lottery winner is drawn. It includes information about the lotteryId and lotteryWinner.

  7. LotteryClaimed - This event is emitted when a lottery winner claims their prize. It includes information about the lotteryId, lotteryWinner, and amount.

Modifiers

The Lottery contract has two modifiers that are used to restrict access to certain functions.

modifier onlyOperator(uint256 _lotteryId) {
    require(
        (msg.sender == lottery[_lotteryId].lotteryOperator),
        "Error: Caller is not the lottery operator"
    );
    _;
}

modifier canClaimLottery(uint256 _lotteryId) {
    require(
        (lottery[_lotteryId].lotteryWinner != address(0x0)),
        "Error: Lottery Winner not yet drawn"
    );
    require(
        (msg.sender == lottery[_lotteryId].lotteryWinner ||
            msg.sender == lottery[_lotteryId].lotteryOperator),
        "Error: Caller is not the lottery winner"
    );
    _;
}

Here is what each modifier does:

  1. onlyOperator - This modifier restricts access to functions that can only be called by the lotteryOperator. It takes in a _lotteryId parameter to ensure that the caller is the operator of the specified lottery. If the caller is not the operator, an error message is returned.

  2. canClaimLottery - This modifier restricts access to functions that can only be called by the lotteryWinner or lotteryOperator after the winner has been drawn. It takes in a _lotteryId parameter to ensure that the caller is either the winner or the operator of the specified lottery. If the winner has not been drawn yet or the caller is not the winner or operator, an error message is returned.

Read Functions

The Lottery contract has a read function called getRemainingTickets that returns the number of remaining tickets for a specified lottery.

function getRemainingTickets(uint256 _lotteryId) public view returns (uint256) {
    return lottery[_lotteryId].maxTickets - lottery[_lotteryId].tickets.length;
}

Write Functions

The Lottery contract has a function called createLottery that is used to create a new lottery.

function createLottery(
    address _lotteryOperator,
    uint256 _ticketPrice,
    uint256 _maxTickets,
    uint256 _operatorCommissionPercentage,
    uint256 _expiration
) public {
    require(
        _lotteryOperator != address(0),
        "Error: Lottery operator cannot be 0x0"
    );
    require(
        (_operatorCommissionPercentage >= 0 &&
            _operatorCommissionPercentage % 5 == 0),
        "Error: Commission percentage should be greater than zero and multiple of 5"
    );
    require(
        _expiration > block.timestamp,
        "Error: Expiration must be greater than current block timestamp"
    );
    require(_maxTickets > 0, "Error: Max tickets must be greater than 0");
    require(_ticketPrice > 0, "Error: Ticket price must be greater than 0");
    address[] memory ticketsArray;
    lotteryCount++;
    lottery[lotteryCount] = LotteryData({
        lotteryOperator: _lotteryOperator,
        ticketPrice: _ticketPrice,
        maxTickets: _maxTickets,
        operatorCommissionPercentage: _operatorCommissionPercentage,
        expiration: _expiration,
        lotteryWinner: address(0),
        tickets: ticketsArray
    });
    emit LotteryCreated(
        _lotteryOperator,
        _ticketPrice,
        _maxTickets,
        _operatorCommissionPercentage,
        _expiration
    );
}

Here is what the function does:

This function takes in several parameters including the lotteryOperator, ticketPrice, maxTickets, operatorCommissionPercentage, and expiration. It first checks that the lotteryOperator is not the null address, the operatorCommissionPercentage is greater than or equal to zero and a multiple of 5, and the expiration is greater than the current block timestamp. It also checks that the maxTickets and ticketPrice are greater than zero.

If all of these conditions are met, a new LotteryData struct is created with the specified parameters and an empty tickets array. The lotteryCount is incremented and the new LotteryData struct is added to the lottery mapping with the lotteryCount as the key. Finally, the LotteryCreated event is emitted with information about the new lottery.

Next we have the BuyTickets Function that is used to buy tickets for a specified lottery.

function BuyTickets(uint256 _lotteryId, uint256 _tickets) public payable {
    uint256 amount = msg.value;
    require(
        _tickets > 0,
        "Error: Number of tickets must be greater than 0"
    );
    require(
        _tickets <= getRemainingTickets(_lotteryId),
        "Error: Number of tickets must be less than or equal to remaining tickets"
    );
    require(
        amount >= _tickets * lottery[_lotteryId].ticketPrice,
        "Error: Ether value must be equal to number of tickets times ticket price"
    );
    require(
        block.timestamp < lottery[_lotteryId].expiration,
        "Error: Lottery has expired"
    );

    LotteryData storage currentLottery = lottery[_lotteryId];

    for (uint i = 0; i < _tickets; i++) {
        currentLottery.tickets.push(msg.sender);
    }

    emit TicketsBought(msg.sender, _lotteryId, _tickets);
}

Here is what the function does:

This function takes in two parameters including the _lotteryId and _tickets to specify which lottery to buy tickets for and how many tickets to buy. It first checks that the _tickets parameter is greater than zero, that the number of tickets being purchased is less than or equal to the remaining tickets for the specified lottery, and that the amount of ether sent is equal to the number of tickets times the ticket price. It also checks that the specified lottery has not expired.

If all of these conditions are met, the function adds the buyer's address to the tickets array in the LotteryData struct for the specified lottery _tickets the number of times. Finally, the TicketsBought event is emitted with information about the buyer, the lottery, and the number of tickets purchased.

Next, we have the DrawLotteryWinner function is used to draw a winner for a specified lottery.

function DrawLotteryWinner(
    uint256 _lotteryId
) external onlyOperator(_lotteryId) returns (uint256 requestId) {
    require(
        block.timestamp > lottery[_lotteryId].expiration,
        "Error: Lottery has not yet expired"
    );
    require(
        lottery[_lotteryId].lotteryWinner == address(0),
        "Error: Lottery winner already drawn"
    );
    requestId = COORDINATOR.requestRandomWords(
        keyHash,
        subscriptionId,
        requestConfirmations,
        callbackGasLimit,
        numWords
    );
    requests[requestId] = LotteryStatus({
        lotteryId: _lotteryId,
        randomNumber: new uint256[](0),
        exists: true,
        fulfilled: false
    });
    emit LotteryWinnerRequestSent(_lotteryId, requestId, numWords);
    return requestId;
}

Here is what the function does:

This function takes in one parameter, _lotteryId, to specify which lottery to draw a winner for. This function uses onlyOperator modifier which ensures that only the Lottery Operator can Draw the Lottery Results. Then It checks that the specified lottery has expired and that a winner has not already been drawn.

If these conditions are met, the function calls the requestRandomWords function from the VRFConsumerBase contract to generate a random number. The function creates a new LotteryStatus struct with the specified lottery ID, an empty randomNumber array, and a flag indicating that the request exists but has not yet been fulfilled. The function then emits a LotteryWinnerRequestSent event with information about the lottery, the request ID, and the number of words requested. Finally, the function returns the request ID.

The last write function is called ClaimLottery that is used to claim the prize for a specified lottery.

function ClaimLottery(
    uint256 _lotteryId
) public canClaimLottery(_lotteryId) {
    LotteryData storage currentLottery = lottery[_lotteryId];
    uint256 vaultAmount = currentLottery.tickets.length *
        currentLottery.ticketPrice;

    uint256 operatorCommission = vaultAmount /
        (100 / currentLottery.operatorCommissionPercentage);

    (bool sentCommission, ) = payable(currentLottery.lotteryOperator).call{
        value: operatorCommission
    }("");
    require(sentCommission);
    emit LogTicketCommission(
        _lotteryId,
        currentLottery.lotteryOperator,
        operatorCommission
    );

    uint256 winnerAmount = vaultAmount - operatorCommission;

    (bool sentWinner, ) = payable(currentLottery.lotteryWinner).call{
        value: winnerAmount
    }("");
    require(sentWinner);
    emit LotteryClaimed(
        _lotteryId,
        currentLottery.lotteryWinner,
        winnerAmount
    );
}

Here is what the function does:

This function takes in one parameter, _lotteryId, to specify which lottery to claim the prize for. It has the canClaimLottery modifier which restricts the Lottery Winner and the Operator to only call the function. Then It checks that the specified lottery has a winner and that the caller is the winner.

If these conditions are met, the function calculates the total amount of ether in the lottery vault by multiplying the number of tickets sold by the ticket price. It then calculates the operator commission by dividing the vault amount by the operator commission percentage and sends the commission to the lottery operator. The function then calculates the winner amount by subtracting the operator commission from the vault amount and sends the winner amount to the lottery winner. Finally, the function emits a LogTicketCommission event with information about the operator commission and a LotteryClaimed event with information about the lottery and the winner.

Internal Functions

The Lottery contract has an internal function called fulfillRandomWords which overrides the Chainlink implementation that is used to handle the fulfillment of a randomness request sent in the DrawLottery Funtion.

function fulfillRandomWords(
    uint256 _requestId,
    uint256[] memory _randomWords
) internal override {
    require(requests[_requestId].exists, "Error: Request not found");
    uint256 lotteryId = requests[_requestId].lotteryId;
    requests[_requestId].fulfilled = true;
    requests[_requestId].randomNumber = _randomWords;
    uint256 winnerIndex = _randomWords[0] %
        lottery[lotteryId].tickets.length;
    lottery[lotteryId].lotteryWinner = lottery[lotteryId].tickets[
        winnerIndex
    ];
    emit RequestFulfilled(_requestId, _randomWords);
}

Here is what the function does:

This function takes in two parameters, _requestId and _randomWords, to specify the ID of the request being fulfilled and the random words generated by the VRF coordinator. It first checks that the request exists in the requests mapping.

If the request exists, the function retrieves the lottery ID associated with the request and sets the fulfilled flag to true for the request. The function then stores the random words in the randomNumber array for the request and calculates the index of the winning ticket by taking the modulus of the first random number with the number of tickets sold for the lottery.

The function then sets the lotteryWinner address for the specified lottery to the address of the ticket holder at the calculated index. Finally, the function emits a RequestFulfilled event with information about the request and the random words.


Deploying Contract

To deploy the Lottery contract through Remix, you will need to follow these steps:

  1. Open Remix and create a new Solidity file.

  2. Copy and paste the Lottery contract code into the file.

  3. Compile the contract by selecting the appropriate compiler version and clicking on the "Compile" button.

  4. Once the contract is compiled, click on the "Run" tab and select the "Deploy & Run Transactions" option.Then choose Injected Provider - Metamask under the Environments Dropdown and make sure you are on Sepolia Testnet.

  5. In the "Deploy" section, select the appropriate account to deploy the contract from and select the Lottery contract from the dropdown menu.

  6. Enter the constructor arguments for the Lottery contract. One of the constructor arguments is the subscription ID that was generated when creating a VRF subscription. You will need to copy this subscription ID and paste it into the constructor arguments field.

  7. Click on the "Transact" button to deploy the contract.

  8. Once the contract is deployed, we need to whitelist the contract in our Subscription so it can request random numbers.


Add Consumer to Subscription Manager

To whitelist our contract in the subscription so that it can request random numbers, go to your subscription dashboard and click on "Add Consumer" button. then enter the deployed Lottery contract address in the input field and click "Add Consumer". You will be prompted with a Metamask popup to approve the transaction. That's all, we've added our Lottery Contract to the total and the user can now request random numbers from the VRF.


Conclusion

🎉 Congratulations on successfully deploying the Lottery contract!

In conclusion, the Lottery contract is a Solidity smart contract that allows for the creation and management of lotteries on the Ethereum blockchain. The contract uses Chainlink's VRF to generate random numbers for selecting lottery winners in a secure and decentralized manner. The contract also includes features such as ticket purchasing, operator commission, and prize claiming.

To make the Lottery contract even better, one possible improvement would be to add more advanced features such as multi-round lotteries, variable ticket prices, and automatic prize distribution. Additionally, the contract could be audited by a third-party security firm to ensure that it is free from vulnerabilities and exploits.

Overall, the Lottery contract is a great example of how smart contracts can be used to create decentralized and transparent lotteries on the blockchain.

👍 If you found this technical writing helpful and would like to see more content like this, please consider following the blog!

🔍 You can also connect with me on GitHub and Twitter for more updates and technical discussions. Thank you for reading!


Did you find this article valuable?

Support Vedant Chainani by becoming a sponsor. Any amount is appreciated!