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
Create a Chainlink VRF Subscription
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:
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.
Click on the "
Create Subscription
" button and fill in the required details. Once you have entered the necessary information, click on "Create Subscription" again.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:
COORDINATOR
- This is the contract address that we will use to request randomness.subscriptionId
- This is the subscription ID that this contract uses for funding requests.vrfCoordinator
- This is the address of the coordinator on the Sepolia testnet.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.callbackGasLimit
- This is the limit for how much gas to use for the callback request to your contract's function.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.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.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:
LotteryData
struct - This structure keeps a record of thelotteryOperator
, 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, andtickets
, which stores all the tickets for the lottery.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 therandomNumber
.lottery
mapping - This mapping mapslotteryCount
to theLotteryData
struct.requests
mapping - This mapping maps toLotteryStatus
.
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:
LotteryCreated - This event is emitted when a new lottery is created. It includes information about the
lotteryOperator
,ticketPrice
,maxTickets
,operatorCommissionPercentage
, andexpiration
.LogTicketCommission - This event is emitted when lottery commission is sent to the operator. It includes information about the
lotteryId
,lotteryOperator
, andamount
.TicketsBought - This event is emitted when a user buys tickets. It includes information about the
buyer
,lotteryId
, andticketsBought
.LotteryWinnerRequestSent - This event is emitted when a random number request is sent to Chainlink VRF. It includes information about the
lotteryId
,requestId
, andnumWords
.RequestFulfilled - This event is emitted when a random number request is fulfilled by Chainlink VRF. It includes information about the
requestId
andrandomWords
.LotteryWinnerDrawn - This event is emitted when a lottery winner is drawn. It includes information about the
lotteryId
andlotteryWinner
.LotteryClaimed - This event is emitted when a lottery winner claims their prize. It includes information about the
lotteryId
,lotteryWinner
, andamount
.
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:
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.canClaimLottery - This modifier restricts access to functions that can only be called by the
lotteryWinner
orlotteryOperator
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:
Open Remix and create a new Solidity file.
Copy and paste the Lottery contract code into the file.
Compile the contract by selecting the appropriate compiler version and clicking on the "
Compile
" button.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.In the "Deploy" section, select the appropriate account to deploy the contract from and select the Lottery contract from the dropdown menu.
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.
Click on the "Transact" button to deploy the contract.
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!