Create a Lottery Contract using the Randomness Precompile¶
by Erin Shaben
Introduction¶
Moonbeam utilizes verifiable random functions (VRF) to generate randomness that can be verified on-chain. A VRF is a cryptographic function that takes some input and produces random values, along with a proof of authenticity that these random values were generated by the submitter. The proof can be verified by anyone to ensure the random values generated were calculated correctly.
There are two available sources of randomness that provide random inputs based on block producers' VRF keys and past randomness results: local VRF and BABE epoch randomness. Local VRF is determined directly within Moonbeam using the collator of the block's VRF key and the last block's VRF output. On the other hand, BABE epoch randomness is based on all the VRF produced by the relay chain validators during a complete epoch.
For more information on the two sources of randomness, how the request and fulfillment process works, and security considerations, please refer to the Randomness on Moonbeam page.
Moonbeam provides a Randomness Precompile, which is a Solidity interface that enables smart contract developers to generate randomness via local VRF or BABE epoch randomness using the Ethereum API. Moonbeam also provides a Randomness Consumer Solidity contract that your contract must inherit from in order to consume fulfilled randomness requests.
This guide will show you how to use the Randomness Precompile and Randomness Consumer contract to create a lottery where the winners will randomly be selected.
Checking Prerequisites¶
For this tutorial, you'll need the following:
- Create or have three accounts on Moonbase Alpha to test out the lottery contract
- All of the accounts will need to be funded with
DEV
tokens. You can get DEV tokens for testing on Moonbase Alpha once every 24 hours from the Moonbase Alpha Faucet - An empty Hardhat project that is configured for the Moonbase Alpha TestNet. For step-by-step instructions, please refer to the Creating a Hardhat Project and the Hardhat Configuration File sections of our Hardhat documentation page
-
Install the Hardhat Ethers plugin. This provides a convenient way to use the Ethers.js library to interact with the network from your Hardhat project:
npm install @nomicfoundation/hardhat-ethers ethers@6
Note
To test out the examples in this guide on Moonbeam or Moonriver, you will need to have your own endpoint and API key, which you can get from one of the supported Endpoint Providers.
Contract Setup¶
The following are the contracts that we'll be working with today to create our lottery:
Randomness.sol
- the Randomness Precompile, which is a Solidity interface that allows you to request randomness, get information about randomness requests, fulfill requests, and moreRandomnessConsumer.sol
- the Randomness Consumer, which is an abstract Solidity contract that is used to interact with the Randomness Precompile. This contract is responsible for validating the origin of randomness requests, ensuring the Randomness Precompile is always the origin, and fulfilling requestsLottery.sol
- an example lottery contract that we'll be building in this guide together. It will rely on the Randomness Precompile and Consumer to request random words that will be used to select a winner for our lottery
If you don't already have a contracts
directory in your Hardhat project, you can create a new directory:
mkdir contracts && cd contracts
Then you can create the following three files, one for each of the aforementioned contracts:
touch Randomness.sol RandomnessConsumer.sol Lottery.sol
In the Randomness.sol
file, you can paste in the Randomness Precompile contract.
Randomness.sol
// SPDX-License-Identifier: GPL-3.0-only
pragma solidity >=0.8.3;
/// @dev The Randomness contract's address.
address constant RANDOMNESS_ADDRESS = 0x0000000000000000000000000000000000000809;
/// @dev The Randomness contract's instance.
Randomness constant RANDOMNESS_CONTRACT = Randomness(RANDOMNESS_ADDRESS);
/// @dev Maximum number of random words being requested
uint32 constant MAX_RANDOM_WORDS = 100;
/// @dev Minimum number of blocks before a request can be fulfilled for Local VRF Request
uint32 constant MIN_VRF_BLOCKS_DELAY = 2;
/// @dev Maximum number of blocks before a request can be fulfilled for Local VRF Request
uint32 constant MAX_VRF_BLOCKS_DELAY = 2000;
/// @dev The deposit amount needed to request random words. There is 1 deposit per request
uint256 constant REQUEST_DEPOSIT_AMOUNT = 1000000000000000000;
/// @author The Moonbeam Team
/// @title Pallet Randomness Interface
/// @dev The interface through which solidity contracts will interact with Randomness
/// @custom:address 0x0000000000000000000000000000000000000809
interface Randomness {
/// @notice Event emitted when the request has been successfully executed
event FulFillmentSucceeded();
/// @notice Event emitted when the request has failed to execute fulfillment
event FulFillmentFailed();
/// @notice The status of the request
/// @param DoesNotExist The request doesn't exist
/// @param Pending The request cannot be fulfilled yet
/// @param Ready The request is ready to be fulfilled
/// @param Expired The request has expired
enum RequestStatus {
DoesNotExist,
Pending,
Ready,
Expired
}
/// @notice The type of randomness source
/// @param LocalVRF Randomness VRF using the parachain material as seed
/// @param RelayBabeEpoch Randomness VRF using relay material from previous epoch
enum RandomnessSource {
LocalVRF,
RelayBabeEpoch
}
/// @notice The request details
/// @param id The id of the request (is always < 2**64)
/// @param refundAddress The address receiving the left-over fees after the fulfillment
/// @param contractAddress The address of the contract being called back during fulfillment
/// @param fee The amount to set aside to pay for the fulfillment
/// @param gasLimit The gas limit to use for the fulfillment
/// @param salt A string being mixed with the randomness seed to obtain different random words. This should be as unique as possible; using the same salt will lead to same randomness result.
/// @param numWords The number of random words requested (from 1 to MAX_RANDOM_WORDS)
/// @param randomnessSource The type of randomness source used to generate the random words
/// @param fulfillmentBlock The parachain block number at which the request can be fulfilled (for LocalVRF only)
/// @param fulfillmentEpochIndex The relay epoch index at which the request can be fulfilled (for RelayBabeEpoch)
/// @param expirationBlock The parachain block number at which the request expires (for LocalVRF only)
/// @param expirationEpochIndex The relay epoch index at which the request expires (for RelayBabeEpoch)
/// @param status The current status of the request
struct Request {
uint256 id;
address refundAddress;
address contractAddress;
uint256 fee;
uint256 gasLimit;
bytes32 salt;
uint32 numWords;
RandomnessSource randomnessSource;
uint32 fulfillmentBlock;
uint64 fulfillmentEpochIndex;
uint32 expirationBlock;
uint64 expirationEpochIndex;
RequestStatus status;
}
/// Return the current relay epoch index
/// @dev An epoch represents real time and not a block number
/// @dev Currently, time between epoch changes cannot be longer than:
/// @dev - Kusama/Westend/Rococo: 600 relay blocks (1 hour)
/// @dev - Polkadot: 2400 relay blocks (4 hours)
/// @custom:selector 81797566
function relayEpochIndex() external view returns (uint64);
/// Return the deposit required to perform a request
/// @dev Each request will need a deposit.
/// @custom:selector fb7cfdd7
function requiredDeposit() external view returns (uint256);
/// @notice Returns the request status
/// @param requestId The id of the request to check (must be < 2**64)
/// @return status Status of the request
/// @custom:selector d8a4676f
function getRequestStatus(uint256 requestId)
external
view
returns (RequestStatus status);
/// @notice Returns the request or revert
/// @param requestId The id of the request to check (must be < 2**64)
/// @return request The request
/// @custom:selector c58343ef
function getRequest(uint256 requestId)
external
view
returns (Request memory request);
/// @notice Request random words generated from the parachain VRF
/// @dev This is using pseudo-random VRF executed by the collator at the fulfillment
/// @dev Warning:
/// @dev The collator in charge of producing the block at fulfillment can decide to skip
/// @dev producing the block in order to have a different random word generated by the next
/// @dev collator, at the cost of a block reward. It is therefore economically viable to use
/// @dev this randomness source only if the financial reward at stake is lower than the block
/// @dev reward.
/// @dev In order to reduce the risk of a collator being able to predict the random words
/// @dev when the request is performed, it is possible to increase the delay to multiple blocks
/// @dev The higher the delay is, the less likely the collator will be able to know which
/// @dev collator will be in charge of fulfilling the request.
/// @dev Fulfillment is manual and can be executed by anyone (for free) after the given delay
/// @param refundAddress The address receiving the left-over fees after the fulfillment
/// @param fee The amount to set aside to pay for the fulfillment
/// @param gasLimit The gas limit to use for the fulfillment
/// @param salt A string being mixed with the randomness seed to obtain different random words
/// @param numWords The number of random words requested (from 1 to MAX_RANDOM_WORDS)
/// @param delay The number of blocks until the request can be fulfilled (between MIN_DELAY_BLOCKS and MAX_DELAY_BLOCKS)
/// @return requestId The id of the request requestLocalVRFRandomWords
/// @custom:selector 9478430c
function requestLocalVRFRandomWords(
address refundAddress,
uint256 fee,
uint64 gasLimit,
bytes32 salt,
uint8 numWords,
uint64 delay
) external returns (uint256);
/// @notice Request random words generated from the relaychain Babe consensus
/// @dev The random words are generated from the hash of the all the VRF provided by the
/// @dev relaychain validator during 1 epoch.
/// @dev It requires a delay of at least 1 epoch after the current epoch to be unpredictable
/// @dev at the time the request is performed.
/// @dev Warning:
/// @dev The validator (on the relaychain) of the last block of an epoch can decide to skip
/// @dev producing the block in order to choose the previous generated epoch random number
/// @dev at the cost of a relaychain block rewards. It is therefore economically viable to use
/// @dev this randomness source only if the financial reward at stake is lower than the relaychain
/// @dev block reward.
/// @dev (see https://crates.parity.io/pallet_babe/struct.RandomnessFromOneEpochAgo.html)
/// @dev Fulfillment is manual and can be executed by anyone (for free) at
/// @dev the beginning of the 2nd relay epoch following the current one
/// @param refundAddress The address receiving the left-over fees after the fulfillment
/// @param fee Amount to set aside to pay for the fulfillment. Those fees are taken from the contract
/// @param gasLimit Gas limit for the fulfillment
/// @param salt Salt to be mixed with raw randomness to get output
/// @param numWords Number of random words to be returned (limited to MAX_RANDOM_WORDS)
/// @return requestId The id of the request
/// @custom:selector 33c14a63
function requestRelayBabeEpochRandomWords(
address refundAddress,
uint256 fee,
uint64 gasLimit,
bytes32 salt,
uint8 numWords
) external returns (uint256);
/// @dev fulFill the request which will call the contract method "fulfillRandomWords"
/// @dev Fees of the caller are refunded if the request is fulfillable
/// @param requestId Request to be fulfilled (must be < 2**64)
/// @custom:selector 9a91eb0d
function fulfillRequest(uint256 requestId) external;
/// @param requestId Request receiving the additional fees (must be < 2**64)
/// @param feeIncrease Amount to increase
/// @custom:selector d0408a7f
function increaseRequestFee(uint256 requestId, uint256 feeIncrease)
external;
/// @param requestId Request to be purged (must be < 2**64)
/// @custom:selector 1d26cbab
function purgeExpiredRequest(uint256 requestId) external;
}
Similarly, in the RandomnessConsumer.sol
file, you can paste in the Randomness Consumer contract.
RandomnessConsumer.sol
// Inspired by: https://raw.githubusercontent.com/smartcontractkit/chainlink/8e8a996fd882c0861bdc9824c1ca27c857c87d03/contracts/src/v0.8/VRFConsumerBaseV2.sol
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.8.3;
/// @dev The Randomness contract's address.
address constant RANDOMNESS_ADDRESS = 0x0000000000000000000000000000000000000809;
/** ****************************************************************************
* @notice Interface for contracts using VRF randomness
* *****************************************************************************
* @dev PURPOSE
*
* @dev The purpose of this contract is to make it easy for contracts to talk to
* @dev the Randomness Precompile. It ensures 2 things:
* @dev 1. The fulfillment came from the Randomness Precompile
* @dev 2. The consumer contract implements fulfillRandomWords.
* *****************************************************************************
* @dev USAGE
*
* @dev Calling contracts must inherit from RandomnessConsumer
*
* @dev Call one of the randomness request functions:
* @dev 1. requestLocalVRFRandomWords(refundAddress, fee, gasLimit, salt
* @dev numWords, delay),
* @dev 2. requestRelayBabeEpochRandomWords(refundAddress, fee, gasLimit, salt
* @dev numWords),
* @dev see (Randomness.sol for a description of each function and their arguments).
*
* @dev Once the request has been registered and the minimum delay is passed, the
* @dev request then can be executed (for 0 fee) by anyone. it will call your
* @dev contract's fulfillRandomWords method.
*
* @dev The randomness argument to fulfillRandomWords is a set of random words
* @dev generated from your requestId.
*
* @dev If your contract could have concurrent requests open, you can use the
* @dev requestId returned from requestRandomWords to track which response is associated
* @dev with which randomness request.
* @dev See "SECURITY CONSIDERATIONS" for principles to keep in mind,
* @dev if your contract could have multiple requests in flight simultaneously.
*
* @dev Colliding `requestId`s are cryptographically impossible as long as seeds
* @dev differ.
*
* *****************************************************************************
* @dev SECURITY CONSIDERATIONS
*
* @dev A method with the ability to call your fulfillRandomness method directly
* @dev could spoof a VRF response with any random value, so it's critical that
* @dev it cannot be directly called by anything other than this base contract
* @dev (specifically, by the RandomnessConsumer.rawFulfillRandomness method).
*
* @dev For your users to trust that your contract's random behavior is free
* @dev from malicious interference, it's best if you can write it so that all
* @dev behaviors implied by a VRF response are executed *during* your
* @dev fulfillRandomness method. If your contract must store the response (or
* @dev anything derived from it) and use it later, you must ensure that any
* @dev user-significant behavior which depends on that stored value cannot be
* @dev manipulated by a subsequent VRF request.
*
* @dev Similarly, the collators have some influence over the order in which
* @dev VRF responses appear on the blockchain, so if your contract could have
* @dev multiple VRF requests in flight simultaneously, you must ensure that
* @dev the order in which the VRF responses arrive cannot be used to manipulate
* @dev your contract's user-significant behavior.
*
* @dev Since the output of the random words generated for
* @dev *requestLocalVRFRandomWords* is dependant of the collator producing the
* @dev block at fulfillment, the collator could skip its block forcing the
* @dev fulfillment to be executed by a different collator, and therefore
* @dev generating a different VRF.
* @dev However, such an attack would incur the cost of losing the block reward to
* @dev the collator.
* @dev It is also possible for a collator to be able to predict some of the
* @dev possible outcome of the VRF if the delay between the request and the
* @dev fulfillment is too short. It is for this reason that we allow to provide
* @dev a higher delay
*
* @dev Since the output of the random words generated for
* @dev *requestRelayBabeEpochRandomWords* is dependant of the relaychain
* @dev validator producing the blocks during an epoch, it is possible for
* @dev the last validator of an epoch to choose between 2 possible VRF
* @dev outputs by skipping the production of a block.
* @dev However, such an attack would incur the cost of losing the block reward to
* @dev the validator
* @dev It is not possible for a parachain collator to predict nor influence
* @dev the output of the relaychain VRF, not to censor the fulfillment as long as
* @dev there is one honest collator.
*/
abstract contract RandomnessConsumer {
error OnlyRandomnessPrecompileCanFulfill(address have, address want);
/**
* @notice fulfillRandomness handles the VRF response. Your contract must
* @notice implement it. See "SECURITY CONSIDERATIONS" above for important
* @notice principles to keep in mind when implementing your fulfillRandomness
* @notice method.
*
* @dev RandomnessConsumer expects its subcontracts to have a method with this
* @dev signature, and will call it once it has verified the proof
* @dev associated with the randomness. (It is triggered via a call to
* @dev rawFulfillRandomness, below.)
*
* @param requestId The Id initially returned by requestLocalVRFRandomWords or requestRelayBabeEpochRandomWords
* @param randomWords The VRF output expanded to the requested number of words
*/
function fulfillRandomWords(uint256 requestId, uint256[] memory randomWords)
internal
virtual;
// rawFulfillRandomness is called by Randomness Precompile when the executeFulfillement
// is called. rawFulfillRandomness then calls fulfillRandomness, after validating
// the origin of the call
function rawFulfillRandomWords(
uint256 requestId,
uint256[] memory randomWords
) external {
if (msg.sender != RANDOMNESS_ADDRESS) {
revert OnlyRandomnessPrecompileCanFulfill(
msg.sender,
RANDOMNESS_ADDRESS
);
}
fulfillRandomWords(requestId, randomWords);
}
}
We'll start adding the functionality to the Lottery.sol
contract in the following section.
Create the Lottery Smart Contract¶
At a high level, the lottery contract we're creating will define the rules of the lottery, enable participation, and use randomly generated words to select winners fairly. We'll be requesting the random words via the Randomness Precompile. Then we'll use the Randomness Consumer interface to consume the results of the fulfilled request so that our contract can use the randomly generated words to select the winners and pay them out. We'll break down each step of the process as we build the lottery contract, but for now, you can review the following diagram for an overview of the process.
This contract is for educational purposes only and is not meant for production use.
To get started, let's set up our lottery contract. We'll need to:
- Import the
Randomness.sol
precompile andRandomnessConsumer.sol
interface - Inherit the Randomness Consumer interface
- Create a variable for the Randomness Precompile so we can easily access it's functions later on
// SPDX-License-Identifier: GPL-3.0-only
pragma solidity >=0.8.0;
import "./Randomness.sol";
import {RandomnessConsumer} from "./RandomnessConsumer.sol";
contract Lottery is RandomnessConsumer {
// Randomness Precompile interface
Randomness public randomness =
Randomness(0x0000000000000000000000000000000000000809);
}
Define Parameters for the Lottery and Randomness Request¶
Next we're going to need to define the rules of our lottery, such as:
- The participation fee
- The minimum and maximum number of participants
- The minimum length of the lottery
- The number of winners
Inside of the Lottery
contract, you can add these parameters:
// The number of winners. This number corresponds to how many random words
// will be requested. Cannot exceed MAX_RANDOM_WORDS (from the Randomness
// Precompile)
uint8 public NUM_WINNERS = 2;
// The number of blocks before the request can be fulfilled (for Local VRF
// randomness). The MIN_VRF_BLOCKS_DELAY (from the Randomness Precompile)
// provides a minimum number that is safe enough for games with low economical
// value at stake. Increasing the delay slightly reduces the probability
// (already very low) of a collator being able to predict the pseudo-random number
uint32 public VRF_BLOCKS_DELAY = MIN_VRF_BLOCKS_DELAY;
// The minimum number of participants to start the lottery
uint256 public MIN_PARTICIPANTS = 3;
// The maximum number of participants allowed to participate. It is important
// to limit the total jackpot (by limiting the number of participants) to
// guarantee the economic incentive of a collator to avoid trying to influence
// the pseudo-random. (See Randomness.sol for more details)
uint256 public MAX_PARTICIPANTS = 20;
// The fee needed to participate in the lottery. Will go into the jackpot
uint256 public PARTICIPATION_FEE = 100000 gwei;
We will also need to define some parameters specifically related to requesting randomness:
- The gas limit for the transaction that fulfills a randomness request
- The minimum fee needed to start the lottery and request the random words. Each request for randomness requires a fulfillment fee. The purpose of this fee is to pay for the fulfillment of a randomness request, which allows anyone to fulfill a request since the request will already have been paid for. When submitting a randomness request, a refund account can be specified, where any excess fees will be returned to. Our contract will be set up so that the owner of the lottery contract will receive the refund
- A salt prefix and the global request count, both of which will be used to generate unique randomness requests
You can go ahead and add these parameters:
// The gas limit allowed to be used for the fulfillment, which depends on the
// code that is executed and the number of words requested. Test and adjust
// this limit based on the size of the request and the processing of the
// callback request in the fulfillRandomWords() function
uint64 public FULFILLMENT_GAS_LIMIT = 100000;
// The minimum fee needed to start the lottery. This does not guarantee that
// there will be enough fee to pay for the gas used by the fulfillment.
// Ideally it should be over-estimated considering possible fluctuation of
// the gas price. Additional fee will be refunded to the caller
uint256 public MIN_FEE = FULFILLMENT_GAS_LIMIT * 150 gwei;
// A string used to allow having different salt than other contracts
bytes32 public SALT_PREFIX = "my_demo_salt_change_me";
// Stores the global number of requests submitted. This number is used as a
// salt to make each request unique
uint256 public globalRequestCount;
Aside from these parameters, we'll need to create some variables which will be used to keep track of the current lottery:
- The current request ID
- The list of current participants
- The jackpot
- The owner of the lottery contract. This is necessary because only the owner of the contract will be allowed to start the lottery
- The source of randomness (local VRF or BABE epoch) that is being used
// The current request id
uint256 public requestId;
// The list of current participants
address[] public participants;
// The current amount of token at stake in the lottery
uint256 public jackpot;
// the owner of the contract
address owner;
// Which randomness source to use. This correlates to the values in the
// RandomnessSource enum in the Randomness Precompile
Randomness.RandomnessSource randomnessSource;
Create the Constructor¶
Now that we have completed the initial set up of all of the variables required for the lottery, we can start to code the functions that will bring the lottery to life. First, we'll start off by creating a constructor function.
The constructor will accept a uint8 as the randomness source, which corresponds to the index of the type of randomness defined in the RandomnessSource
enum, located in the Randomness Precompile. So, we can either pass in 0
for local VRF or 1
for BABE epoch randomness. It will also be payable
, as we'll submit the deposit at the time of deployment and will be used to perform the randomness request later on.
The deposit is defined in the Randomness Precompile and is required in addition to the fulfillment fee. The deposit will be refunded to the original requester, which in our case is the owner of the contract, after the request has been fulfilled. If a request never gets fulfilled, it will expire and need to be purged. Once it is purged, the deposit will be returned.
constructor(
Randomness.RandomnessSource source
) payable RandomnessConsumer() {
// Because this contract can only perform one randomness request at a time,
// we only need to have one required deposit
uint256 requiredDeposit = randomness.requiredDeposit();
if (msg.value < requiredDeposit) {
revert("Deposit too Low");
}
// Update parameters
randomnessSource = source;
owner = msg.sender;
globalRequestCount = 0;
jackpot = 0;
// Set the requestId to the maximum allowed value by the precompile (64 bits)
requestId = 2 ** 64 - 1;
}
Add Logic to Participate in the Lottery¶
Next we can create the function that will allow users to participate in the lottery. The participate
function will be payable
as each participant will need to submit a participation fee.
The participate
function will include the following logic:
- Check that the lottery hasn't started yet using the
getRequestStatus
function of the Randomness Precompile. This function returns the status as defined by theRequestStatus
enum. If the status is anything other thanDoesNotExist
, then the lottery has already been started - Check that the participation fee meets the requirement
- If both of the above are true, then the participant will be added to the list of participants and their participation fee will be added to the jackpot
function participate() external payable {
// We check that the lottery hasn't started yet
if (
randomness.getRequestStatus(requestId) !=
Randomness.RequestStatus.DoesNotExist
) {
revert("Request already initiated");
}
// Each player must submit a fee to participate, which is added to
// the jackpot
if (msg.value != PARTICIPATION_FEE) {
revert("Invalid participation fee");
}
participants.push(msg.sender);
jackpot += msg.value;
}
Challenge
In the above function, we check that the lottery hasn't started yet, but what if we want to know the exact status of the lottery? Create a function that solves this problem and returns the status of the lottery.
Add Logic to Start the Lottery and Request Randomness¶
The logic for starting the lottery contains a crucial component: requesting randomness. As previously mentioned, only the owner of the lottery contract will be able to start the lottery. As such, the owner will need to submit the fulfillment fee for the request.
The startLottery
function will include the following logic:
- Check that the lottery hasn't started yet, as we did in the
participate
function - Check that there is an acceptable number of participants
- Check that the fulfillment fee meets the minimum requirements
- Check that the balance of the contract is enough to pay for the deposit. Remember how the constructor accepts the request deposit? That deposit is stored in the contract until this function is called
- If all of the above are true, we submit the randomness request via the Randomness Precompile along with the fulfillment fee. Depending on the source of randomness, either the
requestLocalVRFRandomWords
or therequestRelayBabeEpochRandomWords
function of the Randomness Precompile will be called along with the following parameters:- The address where excess fees will be refunded to
- The fulfillment fee
- The gas limit to use for the fulfillment
- The salt, which is a string that is mixed with the randomness seed to obtain different random words. The
globalRequestCount
is used to ensure uniqueness - The number of random words requested, which is based off the number of winners that will be selected
- (For local VRF only) The delay, which is the number of blocks that must pass before the request can be fulfilled
Since the lottery function should only be called by the owner, we'll also add in an onlyOwner
modifer that requires the msg.sender
to be the owner
.
function startLottery() external payable onlyOwner {
// Check we haven't started the randomness request yet
if (
randomness.getRequestStatus(requestId) !=
Randomness.RequestStatus.DoesNotExist
) {
revert("Request already initiated");
}
// Check that the number of participants is acceptable
if (participants.length < MIN_PARTICIPANTS) {
revert("Not enough participants");
}
if (participants.length >= MAX_PARTICIPANTS) {
revert("Too many participants");
}
// Check the fulfillment fee is enough
uint256 fee = msg.value;
if (fee < MIN_FEE) {
revert("Not enough fee");
}
// Check there is enough balance on the contract to pay for the deposit.
// This would fail only if the deposit amount required is changed in the
// Randomness Precompile.
uint256 requiredDeposit = randomness.requiredDeposit();
if (address(this).balance < jackpot + requiredDeposit) {
revert("Deposit too low");
}
if (randomnessSource == Randomness.RandomnessSource.LocalVRF) {
// Request random words using local VRF randomness
requestId = randomness.requestLocalVRFRandomWords(
msg.sender,
fee,
FULFILLMENT_GAS_LIMIT,
SALT_PREFIX ^ bytes32(globalRequestCount++),
NUM_WINNERS,
VRF_BLOCKS_DELAY
);
} else {
// Requesting random words using BABE Epoch randomness
requestId = randomness.requestRelayBabeEpochRandomWords(
msg.sender,
fee,
FULFILLMENT_GAS_LIMIT,
SALT_PREFIX ^ bytes32(globalRequestCount++),
NUM_WINNERS
);
}
}
modifier onlyOwner() {
require(msg.sender == owner);
_;
}
Add Logic to Fulfill the Randomness Request¶
In this section, we'll be adding in two functions required to request fulfillment and handle the result of the fulfillment: fulfillRequest
and fulfillRandomWords
.
Our fulfillRequest
function will call the fulfillRequest
method of the Randomness Precompile. When this method is called, under the hood the rawFulfillRandomWords
method of the Randomness Consumer is called, which will verify that the call originated from the Randomness Precompile. From there, the fulfillRandomWords
function of the Randomness Consumer contract is called and the requested number of random words are computed using the block's randomness result and a given salt, and then it is returned. If the fulfillment was successful, the FulfillmentSucceeded
event will be emitted; otherwise, the FulfillmentFailed
event will be emitted.
For fulfilled requests, the cost of execution will be refunded from the request fee to the caller of fulfillRequest
. Then any excess fees and the request deposit are transferred to the specified refund address.
Our fulfillRandomWords
function defines a callback, the pickWinners
function, that is responsible for handling the fulfillment. So, in our case, the callback will use the random words to select a winner and payout the winnings. The signature of our fulfillRandomWords
function must match the signature of the Randomness Consumer's fulfillRandomWords
function.
function fulfillRequest() public {
randomness.fulfillRequest(requestId);
}
function fulfillRandomWords(
uint256 /* requestId */,
uint256[] memory randomWords
) internal override {
pickWinners(randomWords);
}
We'll create the logic for the pickWinners
function in the next section.
Challenge
What if gas prices change significantly before we request the fulfillment, and as a result this function fails? Currently, we wouldn't be able to increase the fulfillment fee. Create a function that solves this problem and allows us to increase the fulfillment fee.
Add Logic to Pick the Lottery Winners¶
The last step for our lottery contract will be to create the pickWinners
function, which, as previously mentioned, is responsible for using the random words to select a winner of the lottery.
The pickWinners
function contains the following logic:
- Determine the number of winners. This is only necessary if you happened to change either the
NUM_WINNERS
or the number ofMIN_PARTICIPANTS
, so that theNUM_WINNERS
is greater than theMIN_PARTICIPANTS
- Calculate the amount to be awarded to the winners based on the amount in the jackpot and the total number of winners
- Determine the winners by using the random words
- Distribute the winnings to each of the winners, making sure to deduct the winnings from the jackpot before transferring them
// This function is called only by the fulfillment callback
function pickWinners(uint256[] memory randomWords) internal {
// Get the total number of winners to select
uint256 totalWinners = NUM_WINNERS < participants.length
? NUM_WINNERS
: participants.length;
// The amount distributed to each winner
uint256 amountAwarded = jackpot / totalWinners;
for (uint32 i = 0; i < totalWinners; i++) {
// This is safe to index randomWords with i because we requested
// NUM_WINNERS random words
uint256 randomWord = randomWords[i];
// Using modulo is not totally fair, but fair enough for this demo
uint256 index = randomWord % participants.length;
address payable winner = payable(participants[index]);
delete participants[index];
jackpot -= amountAwarded;
winner.transfer(amountAwarded);
}
}
Congratulations! You've gone through the entire process of creating the Lottery.sol
contract! You can view the completed version below. Remember, this contract is for educational purposes only and is not meant for production use.
Lottery.sol
// SPDX-License-Identifier: GPL-3.0-only
pragma solidity >=0.8.0;
import "./Randomness.sol";
import {RandomnessConsumer} from "./RandomnessConsumer.sol";
contract Lottery is RandomnessConsumer {
// Randomness Precompile interface
Randomness public randomness =
Randomness(0x0000000000000000000000000000000000000809);
// The number of winners. This number corresponds to how many random words
// will be requested. Cannot exceed MAX_RANDOM_WORDS (from the Randomness
// Precompile)
uint8 public NUM_WINNERS = 2;
// The number of blocks before the request can be fulfilled (for Local VRF
// randomness). The MIN_VRF_BLOCKS_DELAY (from the Randomness Precompile)
// provides a minimum number that is safe enough for games with low economical
// value at stake. Increasing the delay slightly reduces the probability
// (already very low) of a collator being able to predict the pseudo-random number
uint32 public VRF_BLOCKS_DELAY = MIN_VRF_BLOCKS_DELAY;
// The minimum number of participants to start the lottery
uint256 public MIN_PARTICIPANTS = 3;
// The maximum number of participants allowed to participate. It is important
// to limit the total jackpot (by limiting the number of participants) to
// guarantee the economic incentive of a collator to avoid trying to influence
// the pseudo-random. (See Randomness.sol for more details)
uint256 public MAX_PARTICIPANTS = 20;
// The fee needed to participate in the lottery. Will go into the jackpot
uint256 public PARTICIPATION_FEE = 100000 gwei;
// The gas limit allowed to be used for the fulfillment, which depends on the
// code that is executed and the number of words requested. Test and adjust
// this limit based on the size of the request and the processing of the
// callback request in the fulfillRandomWords() function
uint64 public FULFILLMENT_GAS_LIMIT = 100000;
// The minimum fee needed to start the lottery. This does not guarantee that
// there will be enough fee to pay for the gas used by the fulfillment.
// Ideally it should be over-estimated considering possible fluctuation of
// the gas price. Additional fee will be refunded to the caller
uint256 public MIN_FEE = FULFILLMENT_GAS_LIMIT * 150 gwei;
// A string used to allow having different salt than other contracts
bytes32 public SALT_PREFIX = "INSERT_ANY_STRING_FOR_SALT";
// Stores the global number of requests submitted. This number is used as a
// salt to make each request unique
uint256 public globalRequestCount;
// The current request id
uint256 public requestId;
// The list of current participants
address[] public participants;
// The current amount of token at stake in the lottery
uint256 public jackpot;
// the owner of the contract
address owner;
// Which randomness source to use. This correlates to the values in the
// RandomnessSource enum in the Randomness Precompile
Randomness.RandomnessSource randomnessSource;
constructor(
Randomness.RandomnessSource source
) payable RandomnessConsumer() {
// Because this contract can only perform one randomness request at a time,
// we only need to have one required deposit
uint256 requiredDeposit = randomness.requiredDeposit();
if (msg.value < requiredDeposit) {
revert("Deposit too Low");
}
// Update parameters
randomnessSource = source;
owner = msg.sender;
globalRequestCount = 0;
jackpot = 0;
// Set the requestId to the maximum allowed value by the precompile (64 bits)
requestId = 2 ** 64 - 1;
}
function participate() external payable {
// We check that the lottery hasn't started yet
if (
randomness.getRequestStatus(requestId) !=
Randomness.RequestStatus.DoesNotExist
) {
revert("Request already initiated");
}
// Each player must submit a fee to participate, which is added to
// the jackpot
if (msg.value != PARTICIPATION_FEE) {
revert("Invalid participation fee");
}
participants.push(msg.sender);
jackpot += msg.value;
}
function startLottery() external payable onlyOwner {
// Check we haven't started the randomness request yet
if (
randomness.getRequestStatus(requestId) !=
Randomness.RequestStatus.DoesNotExist
) {
revert("Request already initiated");
}
// Check that the number of participants is acceptable
if (participants.length < MIN_PARTICIPANTS) {
revert("Not enough participants");
}
if (participants.length >= MAX_PARTICIPANTS) {
revert("Too many participants");
}
// Check the fulfillment fee is enough
uint256 fee = msg.value;
if (fee < MIN_FEE) {
revert("Not enough fee");
}
// Check there is enough balance on the contract to pay for the deposit.
// This would fail only if the deposit amount required is changed in the
// Randomness Precompile.
uint256 requiredDeposit = randomness.requiredDeposit();
if (address(this).balance < jackpot + requiredDeposit) {
revert("Deposit too low");
}
if (randomnessSource == Randomness.RandomnessSource.LocalVRF) {
// Request random words using local VRF randomness
requestId = randomness.requestLocalVRFRandomWords(
msg.sender,
fee,
FULFILLMENT_GAS_LIMIT,
SALT_PREFIX ^ bytes32(globalRequestCount++),
NUM_WINNERS,
VRF_BLOCKS_DELAY
);
} else {
// Requesting random words using BABE Epoch randomness
requestId = randomness.requestRelayBabeEpochRandomWords(
msg.sender,
fee,
FULFILLMENT_GAS_LIMIT,
SALT_PREFIX ^ bytes32(globalRequestCount++),
NUM_WINNERS
);
}
}
function fulfillRequest() public {
randomness.fulfillRequest(requestId);
}
function fulfillRandomWords(
uint256 /* requestId */,
uint256[] memory randomWords
) internal override {
pickWinners(randomWords);
}
// This function is called only by the fulfillment callback
function pickWinners(uint256[] memory randomWords) internal {
// Get the total number of winners to select
uint256 totalWinners = NUM_WINNERS < participants.length
? NUM_WINNERS
: participants.length;
// The amount distributed to each winner
uint256 amountAwarded = jackpot / totalWinners;
for (uint32 i = 0; i < totalWinners; i++) {
// This is safe to index randomWords with i because we requested
// NUM_WINNERS random words
uint256 randomWord = randomWords[i];
// Using modulo is not totally fair, but fair enough for this demo
uint256 index = randomWord % participants.length;
address payable winner = payable(participants[index]);
delete participants[index];
jackpot -= amountAwarded;
winner.transfer(amountAwarded);
}
}
modifier onlyOwner() {
require(msg.sender == owner);
_;
}
}
Challenge
To make the contract easier to work with, add some events for when a lottery has started, a winner has been chosen, and a winner has been awarded.
Interact with the Lottery Contract¶
Now that we've gone through and created our lottery contract, let's deploy it and start a lottery!
Compile & Deploy the Lottery Contract¶
To compile our contracts, you can simply run:
npx hardhat compile
After compilation, an artifacts
directory is created: it holds the bytecode and metadata of the contracts, which are .json
files. It’s a good idea to add this directory to your .gitignore
.
Before we can deploy the Lottery.sol
contract, we'll need to create a deployment script.
You can create a new directory for the script and name it scripts
and add a new file to it called deploy.js
:
mkdir scripts &&
touch scripts/deploy.js
Now to write the deployment script we can use ethers
. Because we'll be running it with Hardhat, we don't need to import any libraries. We can simply take the following steps:
- Create a local instance of the lottery contract with the
getContractFactory
method - Get the deposit required for a randomness request using the
requiredDeposit
function of the Randomness Precompile - Use the
deploy
method that exists within this instance to instantiate the smart contract. You can pass in0
to use local VRF randomness or1
for BABE epoch randomness. For this example, we'll use local VRF randomness. We'll also need to submit the deposit upon deployment - Wait for the deployment by using
waitForDeployment
- Once deployed, we can fetch the address of the contract using the contract instance
async function main() {
// 1. Get the contract to deploy
const Lottery = await ethers.getContractFactory('Lottery');
// 2. Get the required deposit amount from the Randomness Precompile
const Randomness = await ethers.getContractAt(
'Randomness',
'0x0000000000000000000000000000000000000809'
);
const deposit = await Randomness.requiredDeposit();
// 3. Instantiate a new Lottery smart contract that uses local VRF
// randomness and pass in the required deposit
const lottery = await Lottery.deploy(0, { value: deposit });
console.log('Deploying Lottery...');
// 4. Waiting for the deployment to resolve
await lottery.waitForDeployment();
// 5. Use the contract instance to get the contract address
console.log('Lottery deployed to:', lottery.target);
}
main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
To deploy our lottery contract, we'll use the run
command and specify moonbase
as the network:
npx hardhat run --network moonbase scripts/deploy.js
If you're using another Moonbeam network, make sure that you specify the correct network. The network name needs to match how it's defined in the hardhat.config.js
.
After a few seconds, the contract is deployed, and you should see the address in the terminal. Save the address, as we will use it to interact with this contract instance in the next step.
Create Scripts to Interact with the Lottery Contract¶
We can continue to work with our Hardhat project and create additional scripts to interact with our lottery contract and call some of it's functions. For example, to participate in the lottery, we can create another script in our scripts
directory:
touch participate.js
Then we can add the following code, which will create an instance of the lottery contract using the name of the contract and the contract address. Then we can obtain the participation fee directly from the contract and call the contract's participate
function:
async function participate() {
const lottery = await ethers.getContractAt(
'Lottery',
'INSERT_CONTRACT_ADDRESS'
);
const participationFee = await lottery.PARTICIPATION_FEE();
const tx = await lottery.participate({ value: participationFee });
console.log('Participation transaction hash:', tx.hash);
}
participate()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
To run this script, you can use the following command:
npx hardhat run --network moonbase scripts/participate.js
The transaction hash will be printed to the console. You can use the hash to look up the transaction on Moonscan.
And that's it! You can feel free to continue creating additional scripts to perform the next steps of the lottery, such as starting the lottery and picking the winners.
| Created: March 17, 2023