Skip to content

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 more
  • RandomnessConsumer.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 requests
  • Lottery.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.

Diagram of the Lottery 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 and RandomnessConsumer.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 the RequestStatus enum. If the status is anything other than DoesNotExist, 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 the requestRelayBabeEpochRandomWords 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 of MIN_PARTICIPANTS, so that the NUM_WINNERS is greater than the MIN_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

Compile the contracts using Hardhat's compile command.

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:

  1. Create a local instance of the lottery contract with the getContractFactory method
  2. Get the deposit required for a randomness request using the requiredDeposit function of the Randomness Precompile
  3. Use the deploy method that exists within this instance to instantiate the smart contract. You can pass in 0 to use local VRF randomness or 1 for BABE epoch randomness. For this example, we'll use local VRF randomness. We'll also need to submit the deposit upon deployment
  4. Wait for the deployment by using waitForDeployment
  5. 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.

Deploy the Lottery contract using Hardhat's run command.

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.

Run the partipation script using Hardhat's run command.

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.

This tutorial is for educational purposes only. As such, any contracts or code created in this tutorial should not be used in production.
The information presented herein has been provided by third parties and is made available solely for general information purposes. Moonbeam does not endorse any project listed and described on the Moonbeam Doc Website (https://docs.moonbeam.network/). Moonbeam Foundation does not warrant the accuracy, completeness or usefulness of this information. Any reliance you place on such information is strictly at your own risk. Moonbeam Foundation disclaims all liability and responsibility arising from any reliance placed on this information by you or by anyone who may be informed of any of its contents. All statements and/or opinions expressed in these materials are solely the responsibility of the person or entity providing those materials and do not necessarily represent the opinion of Moonbeam Foundation. The information should not be construed as professional or financial advice of any kind. Advice from a suitably qualified professional should always be sought in relation to any particular matter or circumstance. The information herein may link to or integrate with other websites operated or content provided by third parties, and such other websites may link to this website. Moonbeam Foundation has no control over any such other websites or their content and will have no liability arising out of or related to such websites or their content. The existence of any such link does not constitute an endorsement of such websites, the content of the websites, or the operators of the websites. These links are being provided to you only as a convenience and you release and hold Moonbeam Foundation harmless from any and all liability arising from your use of this information or the information provided by any third-party website or service.
Last update: January 25, 2024
| Created: March 17, 2023