NFT Lottery Contract using ChainLink VRF

Callisto Labs
8 min readNov 7, 2023

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

Image credit: ChainLink docs

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

Sign up to discover human stories that deepen your understanding of the world.

Callisto Labs
Callisto Labs

Written by Callisto Labs

0 Followers

We are Callisto Labs, a group of highly skilled full-stack developers. We are specialized in Web3/Solidity, UI/UX design and Backend Engineering

No responses yet

Write a response