NFT Lottery Contract using ChainLink VRF
Introduction
There are many variations of Lottery or Raffle systems built on Ethereum. It is an exciting game of chance which can pay large sums of money to those who win, hence it is very prominent in the crypto space. When designing and implementing such systems, there are a lot of security and cost considerations. In this article, we will go over our own take on the classic lottery game
Design considerations
There are 3 main principles which we considered when designing this contract
Cost
The lottery has to be gas efficient in order to reduce the burden of gas costs on the end user. We have taken a naive approach to make the ticket purchasing and claiming process as efficient as possible for users.
True Randomness
In order to get verifiable, true randomness, we have leveraged ChainLink VRF in our contract to guarantee the authenticity of game results. ChainLink VRF is one of the most widely used random number oracles on Ethereum, hence it suits our purpose perfectly
Interoperability
We wanted our lottery system to be able to be integrated easily into existing DeFi ecosystems. Hence we have chosen the currency of the lottery to be an ERC20 token.
Why VRF?
An end user might find that a solution such as ChainLink VRF adds extra complexity and overhead to the contract. However, it is essential that VRF be used in order to ensure that users play a fair game.
Pseudo-random approach
In theory, it is possible to generate pseudo-random numbers on Ethereum. Why is it called pseudo-random? Because desipite seeming random, the number generated on-chain can be influenced by validators who process the block. Here’s an example of a pseudo-random number generator in solidity
function random() internal view returns (uint256) {
return uint256(keccak256(abi.encodePacked(
tx.origin,
blockhash(block.number - 1),
block.timestamp
)));
}
As you can see, this generator uses data that is known at the time of block validation. This may allow validators to sway the result of the generator by choosing to exclude/include your transaction in a specific block

Code breakdown
Boilerplate
To manage the lottery state, we intiailize a LotteryInstance and Ticket struct, and mappings to store them
struct LotteryInstance {
uint startTime;
uint prizePool;
uint claimedAmount;
uint seed;
uint8[(5)] winningNumbers;
LotteryStatus status;
}
struct Ticket {
uint lottoId;
uint8[(5)] numbers;
bool claimed;
}
enum LotteryStatus {
IN_PLAY,
VRF_REQUESTED,
SETTLED
}
mapping(uint => LotteryInstance) lottoIdToLotto;
mapping(uint => uint) requestIdToLottoId;
mapping(uint => Ticket) ticketIdToTicket;
mapping(uint => mapping(uint => mapping(uint => uint))) lottoIdToPositionToNumberToCounter;
uint public ticketPrice;
uint public currentLottoId;
uint public expiry;
uint currentTicketId;
IERC20 CALLISTO;
The Lottery Instance
Each lottery instance is defined by the LotteryInstance struct and managed by the lottoIdToLotto mapping. The current lottoId can be viewed from currentLottoId
struct LotteryInstance {
uint startTime;
uint prizePool;
uint claimedAmount;
uint seed;
uint8[(5)] winningNumbers;
LotteryStatus status;
}
- startTime — block.timestamp at the start of the lottery
- prizePool — Total prize pool
- claimedAmount — Total amount claimed
- seed — Return value of VRF
- winningNumbers — Array of winning numbers derived from the seed
- status — current lottery state
Tickets
Each ticket is an ERC721 token minted by the Lottery contract. It is defined by the Ticket struct. Tickets are managed by the ticketIdToTicket mapping
struct Ticket {
uint lottoId;
uint8[(5)] numbers;
bool claimed;
}
lottoId — Lottery which the ticket belongs to
numbers — Numbers chosen by the user
claimed — Whether the ticket has been claimed or not
Variables and constants
uint public ticketPrice;
uint public currentLottoId;
uint public expiry;
uint currentTicketId;
ticketPrice — Price per ticket
currentLottoId — Currently running lottery is
expiry — Duration of a single lottery instance
currentTicketId — TokenId of NFTs
Initialising VRF
We have chosen ChainLink’s VRF v2 subscription based model. This requires that a subscription be created in advance and funded by the deployer. You can read more about this at vrf.chain.link
To initialise VRF in the contract, we inherit from VRFV2ConsumerBase.sol
import "@chainlink/contracts/src/v0.8/interfaces/VRFCoordinatorV2Interface.sol";
import "@chainlink/contracts/src/v0.8/vrf/VRFConsumerBaseV2.sol";
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract CallistoLotto is VRFConsumerBaseV2, ERC721 {
...
Then it is initialised in the constructor with the following parameters
VRFCoordinatorV2Interface COORDINATOR;
uint64 s_subscriptionId;
bytes32 keyHash =
0x474e34a077df58807dbe9c96d3c009b23b3c6d0cce433e59bbf5b34f823bc56c;
uint32 callbackGasLimit = 100000;
uint16 requestConfirmations = 3;
uint32 numWords = 1;
/**
* HARDCODED FOR SEPOLIA
* COORDINATOR: 0x8103B0A8A00be2DDC778e6e7eaa21791Cd364625
**/
constructor(
uint64 subscriptionId,
address coordinator,
address callistoToken,
uint _expiry,
uint _ticketPrice
)
ERC721("Callisto Lottery Ticket", "CTKT")
VRFConsumerBaseV2(coordinator) // 0x8103B0A8A00be2DDC778e6e7eaa21791Cd364625
{
COORDINATOR = VRFCoordinatorV2Interface(
coordinator // 0x8103B0A8A00be2DDC778e6e7eaa21791Cd364625
);
s_subscriptionId = subscriptionId;
...
Lottery Lifecycle
Each lottery has 3 phases in its life cycle. It is defined by the following enum
enum LotteryStatus {
IN_PLAY,
VRF_REQUESTED,
SETTLED
}
IN_PLAY signifies that the lottery is currently on-going. Tickets may be purchased in this phase
VRF_REQUESTED indicates that a VRF request has been made to the ChainLink coordinator. Tickets may not be purchased or claimed during this phase
SETTLED is the final phase a game, which reveals the winning numbers based on the VRF output. Tickets man be claimed during this phase
Main Lottery Functions
The entire lottery cycle is operated by 3 main functions. These are startNextLotto, endLotto and drawLottoResult. We will go over how each of these works
function startNextLotto()
public
{
require(
newLottoStartable(),
"Lottery: Either lotto is in play or VRF has been requested"
);
currentLottoId++;
lottoIdToLotto[currentLottoId] = LotteryInstance(
block.timestamp,
0,
0,
0,
[0,0,0,0,0],
LotteryStatus.IN_PLAY
);
}
...
function newLottoStartable()
internal
view
returns(bool)
{
return(
block.timestamp >= lottoIdToLotto[currentLottoId].startTime + expiry
&&
lottoIdToLotto[currentLottoId].status == LotteryStatus.SETTLED
);
}
startNextLotto when called under the right conditions (as outlined in newLottoStartable) will initialise a new LotteryInstance.
function endLotto()
public
returns(uint requestId)
{
require(
currentLottoEndable(),
"Lottery: Either lotto is in play or VRF has been requested"
);
lottoIdToLotto[currentLottoId].status = LotteryStatus.VRF_REQUESTED;
uint _requestId = COORDINATOR.requestRandomWords(
keyHash,
s_subscriptionId,
requestConfirmations,
callbackGasLimit,
numWords
);
requestIdToLottoId[_requestId] = currentLottoId;
return(_requestId);
}
...
function currentLottoEndable()
internal
view
returns(bool)
{
return(
block.timestamp >= lottoIdToLotto[currentLottoId].startTime + expiry
&&
lottoIdToLotto[currentLottoId].status == LotteryStatus.IN_PLAY
);
}
endLotto ends the current Lottery instance when the conditions are met and makes a VRF request to the ChainLink coordinator
function drawLottoResult(uint seed)
internal
{
lottoIdToLotto[currentLottoId].seed = seed;
lottoIdToLotto[currentLottoId].status = LotteryStatus.SETTLED;
lottoIdToLotto[currentLottoId].winningNumbers = drawWinningNumbers(seed);
}
...
function fulfillRandomWords(
uint,
uint256[] memory _randomWords
) internal override {
drawLottoResult(_randomWords[0]);
}
...
function drawWinningNumbers(uint seed)
internal
pure
returns(uint8[(5)] memory nums)
{
for(uint i=0;i<5;i++) {
seed = uint(keccak256(abi.encode(seed)));
nums[i] = uint8(seed%10);
}
return(nums);
}
drawLottoResult is the callback function used by ChainLink VRF to settle the current Lottery Instance. It sets the status, seed and winning numbers as shown above. This is done via the fulfillRandomWords function required by the VRFConsumerBase contract. The winning numbers are drawn using the simple drawWinningNumbers function using the uint256 seed provided by ChainLink. The LotteryInstance for the respective Lottery Id is updated with the information
Ticket Purchasing Logic
function buyTicket(uint8[(5)] memory numbers)
public
{
require(
isValidNumbers(numbers),
"Lottery: Invalid set of numbers!"
);
require(
canBuyTickets(),
"Lottery: Either lotto has been settled, VRF has been requested or is awaiting closure"
);
CALLISTO.transferFrom(msg.sender, address(this), ticketPrice);
currentTicketId++;
_safeMint(msg.sender, currentTicketId);
ticketIdToTicket[currentTicketId] = Ticket(
currentLottoId,
numbers,
false
);
lottoIdToLotto[currentLottoId].prizePool += ticketPrice;
for(uint i=0; i<5; i++) {
incrementNumberPos(i, numbers[i]);
}
}
...
function canBuyTickets()
internal
view
returns(bool)
{
return(
block.timestamp <= lottoIdToLotto[currentLottoId].startTime + expiry
&&
lottoIdToLotto[currentLottoId].status != LotteryStatus.VRF_REQUESTED
&&
lottoIdToLotto[currentLottoId].status != LotteryStatus.SETTLED
);
}
...
function isValidNumbers(uint8[(5)] memory numbers)
internal
pure
returns(bool)
{
for(uint i=0; i<5; i++) {
if(numbers[i] > 9) {
return(false);
}
}
return(true);
}
...
function incrementNumberPos(uint pos, uint num)
internal
{
lottoIdToPositionToNumberToCounter[currentLottoId][pos][num]++;
}
The buyTicket function takes the users choice of numbers as an argument. The input must be an array of 5 numbers, each 0–9. The sanity of the user input is checked with the function isValidNumbers. Once a ticket is bought, the required ERC20 token amount (ticketPrice) is deducted from the user balance and a ticket NFT is minted containing information about the user’s chosen numbers and current lottery
Ticket Claiming Logic
Every lottery or raffle has a different prize distribution model. The PowerBall requires that you match five ‘white ball’ numbers between 1–69, and one ‘powerball’ number between 1–26. Our model differs such that in order to win a share of the pool, you have to match the exact number in the exact positions to win. However the wins do not need to be in the same order. Here is an example
Consider the tickets #1: 92748, #2: 01837 and #3: 17473. Consider the winning number to be 12749
In this case ticket 1 has three matches (2,7,4 in the exact positions as the winning number), ticket 2 has zero matches and ticket 3 has 1 match (first digit matches positionally)
The prize per match is determined by the share of the match (if there is no rake/house edge, this would be 20%) divided by the total number of people who have positionally matched the same digit as you. If you have multiple matches, your prize is the sum of the prizes won from each match
Here is how the claimTicket function works
function claimTicket(uint ticketId)
public
{
require(
ownerOf(ticketId) == msg.sender,
"Lottery: You don't own this ticket!"
);
require(
lottoIdToLotto[ticketIdToTicket[ticketId].lottoId].status == LotteryStatus.SETTLED,
"Lottery: The requested lottery instance is not settled"
);
require(
!ticketIdToTicket[ticketId].claimed,
"Lottery: Nice try, you've already claimed this ticket"
);
ticketIdToTicket[ticketId].claimed = true;
uint8[(5)] memory numbers = getTicketNumbers(ticketId);
uint prizePoolShare = getPrizePoolShare(ticketIdToTicket[ticketId].lottoId, numbers);
lottoIdToLotto[ticketIdToTicket[ticketId].lottoId].prizePool -= prizePoolShare;
CALLISTO.transfer(msg.sender, prizePoolShare);
}
...
function getPrizePoolShare(uint lottoId, uint8[(5)] memory numbers)
internal
view
returns(uint share)
{
for(uint i=0; i<5; i++) {
if(numbers[i] == lottoIdToLotto[lottoId].winningNumbers[i]) {
uint nc = getNumberCount(lottoId, i, numbers[i]);
share += (lottoIdToLotto[lottoId].prizePool) / (5 * nc);
}
}
return(share);
}
...
function getNumberCount(uint lottoId, uint pos, uint num)
internal
view
returns(uint)
{
return(lottoIdToPositionToNumberToCounter[lottoId][pos][num]);
}
function drawWinningNumbers(uint seed)
internal
pure
returns(uint8[(5)] memory nums)
{
for(uint i=0;i<5;i++) {
seed = uint(keccak256(abi.encode(seed)));
nums[i] = uint8(seed%10);
}
return(nums);
}
First, we check if the user owns the ticket in question, and if it’s been already claimed. Then we check if the lottery instance of which the ticket belongs to has been settled. If so, we get their prize using the method as outlined before using the function getPrizePoolShare
If you recall the boilerplate code at the start, you would have noticed a nested mapping as such
mapping(uint => mapping(uint => mapping(uint => uint))) lottoIdToPositionToNumberToCounter;
This mapping is used to store the count of each number in each position in order to calculate the prize. In the above example, we created a helper function to access it (getNumberCount). This mapping is updated in the buyTicket function using incrementNumberPos
Conclusion
Here comes the end of this article. We hope you enjoyed reading about our lottery as much as we enjoyed designing it. Such an approach to a decentralised raffle may be feasible for certain DeFi ecosystems. Of course, there are hundreds of ways to make a fun and addictive game for users, and this isn’t the only way. Our contract drew inspiration from PancakeSwap’s Lottery contract, which has a different ruleset from ours. We found their use of NFTs to be interesting and decided to make our own version with a twist.
If you found this to be helpful, follow us to keep up with our latest discoveries in DeFi
Code -> https://github.com/callisto-eth/callisto-lottery/tree/master
Written by Callisto Labs
Callisto Labs -> https://github.com/callisto-eth
0xKits -> https://github.com/0xKits
Obsidian -> https://github.com/redPanda69
gzfs -> https://github.com/gzfs