DEV Community

Cover image for Creating a Lottery with Hardhat and Chainlink
Jonathan Burmester
Jonathan Burmester

Posted on • Updated on

Creating a Lottery with Hardhat and Chainlink

The code repo is here. In this tutorial, I will be covering only the smart contract. But the repo also has a frontend powered by Vite (React), Ethers.js, and Tailwind CSS to interact with the smart contract, and a simple backend because I wanted to keep the data off-chain, so I'm using WebSockets to listen to the contract events to sync data into a Postgres database.

A couple of months ago, I started my journey into web3 by learning Solidity and haven't stopped experimenting with it since then. One of the topics that caught my attention very early in this stage was randomness.

Randomness generation requires a source of entropy, which is typically related to hardware-related events such as keypress, free memory, mouse movements, or unpredictable physical variables. In Solidity, you could rely on the block variables as the source of entropy, but you need to be careful because miners can manipulate those values.

Fortunately, oracles are here to help us. These are entities that connect the blockchain with external data. The oracle will help us get the random number from outside the network in this tutorial. Some of the most established oracle solutions are Chainlink and Provable (Formerly Oraclize).

I think you already have an idea which one we will be using. Right?

Chainlink provides different alternatives to get off-chain data. To solve the random generation problem, it offers a Verifiable Random Function (VRF) to obtain random numbers from outside the blockchain.

What we will build

We will be building a lottery smart contract with the following features:

  • It will have an admin.
  • The admin will be the only one who will create lotteries.
  • A lottery will have a ticket price, an end date, and a single winner.
  • Players will be able to participate by paying the ticket price.
  • Players will be able to participate any number of times.
  • Each participation increases the likelihood of winning.
  • The winner will be declared after the end of the lottery by taking a random address from the participation list.

Prerequisites

  1. Nodejs installed on your machine.
  2. An Achemy or Infura account. (I will be using Alchemy)

Setup

We will start by creating a folder and initializing the project.

mkdir cryptoLottery
cd cryptoLottery
npm init -y
Enter fullscreen mode Exit fullscreen mode

Next, install hardhat using yarn or npm.

yarn add hardhat --dev
Enter fullscreen mode Exit fullscreen mode

Now, we will initialize hardhat. I will be using the advanced project because it installs some linters that I found helpful, but feel free to use the simple project to avoid unnecessary package installation.

npx hardhat

888    888                      888 888               888
888    888                      888 888               888
888    888                      888 888               888
8888888888  8888b.  888d888 .d88888 88888b.   8888b.  888888
888    888     "88b 888P"  d88" 888 888 "88b     "88b 888
888    888 .d888888 888    888  888 888  888 .d888888 888
888    888 888  888 888    Y88b 888 888  888 888  888 Y88b.
888    888 "Y888888 888     "Y88888 888  888 "Y888888  "Y888

Welcome to Hardhat v2.8.0

? What do you want to do? … 
  Create a basic sample project
▸ Create an advanced sample project
  Create an advanced sample project that uses TypeScript
  Create an empty hardhat.config.js
  Quit

Enter fullscreen mode Exit fullscreen mode

Next, we will install hardhat-deploy to improve our deployment experience and fund-link plugin to help us fund our contract with LINK. Hardhat-deploy will allow us to deploy on different networks and keep track of them. Also, it helps to re-use our deployments during tests.

yarn add hardhat-deploy @appliedblockchain/chainlink-plugins-fund-link --dev
Enter fullscreen mode Exit fullscreen mode

Now, change your hardhat.config.js to look like this:

require("dotenv").config();
require("@nomiclabs/hardhat-etherscan");
require("@nomiclabs/hardhat-waffle");
// Advance project aditional libraries
require("hardhat-gas-reporter");
require("solidity-coverage");
//////////////////////////////////////
require("hardhat-deploy");
require("@appliedblockchain/chainlink-plugins-fund-link");

module.exports = {
  networks: {
    rinkeby: {
      url: process.env.RINKEBY_URL || "",
      chainId: 4,
      accounts: [
        process.env.PRIVATE_KEY_DEPLOYER,
        process.env.PRIVATE_KEY_USER_2,
        process.env.PRIVATE_KEY_USER_3,
      ].filter((x) => x !== undefined),
    },
  },
  gasReporter: {
    enabled: process.env.REPORT_GAS !== undefined,
    currency: "USD",
  },
  etherscan: {
    apiKey: process.env.ETHERSCAN_API_KEY,
  },
  namedAccounts: {
    deployer: {
      default: 0,
      4: 0,
    },
    user2: {
      default: 1,
      4: 1,
    },
    user3: {
      default: 2,
      4: 2,
    },
  },
  solidity: {
    compilers: [
      {
        version: "0.8.7",
      },
      {
        version: "0.6.6",
      },
      {
        version: "0.4.24",
      },
    ],
  },
  mocha: {
    timeout: 10000000,
  },
};
Enter fullscreen mode Exit fullscreen mode

Some of the changes to this file:

  • Plugins hardhat-deploy and @appliedblockchain/chainlink-plugins-fund-link imported at the beginning of the file.
  • Rinkeby network configured in the networks list.
  • NamedAccounts is a feature from hardhat-deploy that allows us to name the accounts.
  • Additional compiler versions configured. This is required to compile the contracts we will use from Chainlink.

Finally, create a .env file with the following values:

RINKEBY_URL=https://eth-rinkeby.alchemyapi.io/v2/<key>
PRIVATE_KEY_DEPLOYER=<private_key_1>
PRIVATE_KEY_USER_2=<private_key_2>
PRIVATE_KEY_USER_3=<private_key_3>
Enter fullscreen mode Exit fullscreen mode

Coding the Contract

Hardhat sample project generates some files we won't use, so let's delete them.

rm contracts/Greeter.sol
rm scripts/deploy.js
rm test/sample-test.js
Enter fullscreen mode Exit fullscreen mode

Next, we create our smart contract in the contracts folder.

touch contracts/Lottery.sol
Enter fullscreen mode Exit fullscreen mode
pragma solidity ^0.8.0;

contract LotteryGame {

}
Enter fullscreen mode Exit fullscreen mode

Chainlink VRF prerequisites

Before getting into the details of our contract, these are the prerequisites we need to meet for Chainlink VRF to work.

  • Our contract should inherit VRFBaseConsumer from the Chainlink contracts package.
  • Call requestRandomness in our contract to request the random number.
  • Define the fulfillRandomness function, which is the function that will receive the random number and do something with it.
  • Define the following parameters in our contract:
    • LINK Token: Link token address on the network you are deploying your contract to.
    • VRF Coordinator address: address of the VRF Coordinator on the network you are deploying your contract to.
    • Key Hash: public key used to generate randomness.
    • Fee: LINK fee required on the network to fulfill the VRF request.

Warning: without LINK in our contract we won't be able to request a random number.

We will adapt our contract to meet all these requirements later.

Libraries

Besides using Chainlink, we will also use some libraries from Openzeppelin:

Counters to obtain an incremental integer that will be used as an id for our lotteries.
SafeMath to avoid overflow in math operations.

Let's install both of them:

yarn add @chainlink/contracts @openzeppelin/contracts
Enter fullscreen mode Exit fullscreen mode

And import them into our contract.

import "@openzeppelin/contracts/utils/Counters.sol";
import "@openzeppelin/contracts/utils/math/SafeMath.sol";
import "@chainlink/contracts/src/v0.8/VRFConsumerBase.sol";
Enter fullscreen mode Exit fullscreen mode

Our contract must inherit from VRFConsumerBase. This new contract will provide us with methods to request the random number and to receive the result.

contract LotteryGame is VRFConsumerBase {

using Counters for Counters.Counter;
using SafeMath for uint256;

....
}
Enter fullscreen mode Exit fullscreen mode

Structs

Next, we define our Lottery struct according to the definitions from above:

struct Lottery {
    uint256 lotteryId;
    address[] participants;
    uint256 ticketPrice;
    uint256 prize;
    address winner;
    bool isFinished;
    uint256 endDate;
}
Enter fullscreen mode Exit fullscreen mode

Then, we add some variables to help us with lotteries management.

Counters.Counter private lotteryId;
mapping(uint256 => Lottery) private lotteries;
mapping(bytes32 => uint256) private lotteryRandomnessRequest;
mapping(uint256 => mapping(address => uint256)) ppplayer; //participations per player
mapping(uint256 => uint256) playersCount;
bytes32 private keyHash;
uint256 private fee;
address private admin;
Enter fullscreen mode Exit fullscreen mode

lotteryId will be an incremental uint we will use to identify our lotteries.
lotteries will store all of our lotteries.
lotteryRandomnessRequest will let us know how the request for a random number and a lottery are related. More on this later.
ppplayer will store the number of times an address have participated in a lottery.
playersCount will keep the number of unique addresses participating in a lottery.
keyHash and fee are parameters required by Chainlink VRF.
admin will be the deployer's address and will be the only address capable of creating lotteries and declaring winners.

Events

We also define some events:

event RandomnessRequested(bytes32,uint256);
event WinnerDeclared(bytes32,uint256,address);
event PrizeIncreased(uint256,uint256);
event LotteryCreated(uint256,uint256,uint256,uint256);
Enter fullscreen mode Exit fullscreen mode

Functions

Following VRF prerequisites, our contract constructor will take all the VRF required parameters as arguments and supply the first two to the VRFConsumerBase constructor. Also, we will set the admin of our contract.

constructor(address vrfCoordinator, address link, bytes32 _keyhash, uint256 _fee)
VRFConsumerBase(vrfCoordinator, link)
{
   keyHash = _keyhash;
   fee = _fee;
   admin = msg.sender;
}
Enter fullscreen mode Exit fullscreen mode

We also define a modifier to limit some methods to be called from admin only.

modifier onlyAdmin {
  require(msg.sender == admin, "Only admin can call this function");
  _;
}
Enter fullscreen mode Exit fullscreen mode

Then, we define a function to create lotteries:

function createLottery(uint256 _ticketPrice,uint256 _seconds) payable public onlyAdmin {
    require(_ticketPrice > 0, "Ticket price must be greater than 0");
    require(_seconds > 0, "Lottery time must be greater than 0");
    Lottery memory lottery = Lottery({
    lotteryId: lotteryId.current(),
    participants: new address[](0),
    prize: 0,
    ticketPrice: _ticketPrice,
    winner: address(0),
    isFinished: false,
    endDate: block.timestamp + _seconds * 1 seconds
    });
    lotteries[lotteryId.current()] = lottery;
    lotteryId.increment();
    emit LotteryCreated(lottery.lotteryId,lottery.ticketPrice,lottery.prize,lottery.endDate);
}
Enter fullscreen mode Exit fullscreen mode

As you can see, we are validating that the ticketPrice and seconds are greater than 0. Then, we create a new instance of a lottery and put it into our mapping using the current value of our lotteryId as key. Next, we increment the lotteryId for the next lottery and emit an event to notify the creation of a lottery.

Also, note that I'm using block.timestamp as a reference to establish the end of the lottery, but depending on your contract, you should consider using block.number and block times to calculate time. This is a good lecture if you are interested in this topic.

Next, we implement the participate function:

function participate(uint256 _lotteryId) public payable {
        Lottery storage lottery = lotteries[_lotteryId];
        require(block.timestamp < lottery.endDate,"Lottery participation is closed");
        require(lottery.ticketPrice == msg.value, "Value must be equal to ticket price");
        lottery.participants.push(msg.sender);
        lottery.prize += msg.value;
        uint256 uniqueP = participations[_lotteryId][msg.sender];
        if(uniqueP == 0) {
            uniqueParticipations[_lotteryId]++;
        }
        participations[_lotteryId][msg.sender]++;

        emit PrizeIncreased(lottery.lotteryId, lottery.prize);
    }
Enter fullscreen mode Exit fullscreen mode

This function will let players participate in the lottery by sending some ETH equals to the ticketPrice when the lottery has not ended. We also keep track of players with multiple participations to avoid requesting a random number when we only have a single player.

Next, is the declareWinner function:

function declareWinner(uint256 _lotteryId) public onlyAdmin {
        Lottery storage lottery = lotteries[_lotteryId];
        require(block.timestamp > lottery.endDate,"Lottery is still active");
        require(!lottery.isFinished,"Lottery has already declared a winner");
        if(uniqueParticipations[_lotteryId] == 1) {
            require(lottery.participants[0] != address(0), "There has been no participation in this lottery");
            lottery.winner = lottery.participants[0];
            lottery.isFinished = true;
            lottery.winner.call{value: lottery.prize }("");
            require(success, "Transfer failed");
        } else {
            require(LINK.balanceOf(address(this)) >= fee, "Not enough LINK");
            bytes32 requestId = requestRandomness(keyHash, fee);
            lotteryRandomnessRequest[requestId] = _lotteryId;
            emit RandomnessRequested(requestId,_lotteryId);
        }
    }
Enter fullscreen mode Exit fullscreen mode

This function verifies that all the requirements have been met before declaring a winner. If we have a single player, we transfer the "prize" back to that player. Otherwise, we validate if the contract has enough LINK to request the random number to the oracle. If there is enough LINK, we call the requestRandomness function with keyHash and fee as the arguments. Then, we map the requestId to the lotteryId because when the random number comes back within the fulfillRandomness function it will only have the requestId and the random number, so without the mapping we won't be able to determine the winner of the right lottery.

Almost done, we define the fulfillRandomness function:

function fulfillRandomness(bytes32 requestId, uint256 randomness) internal override {
        uint256 _lotteryId = lotteryRandomnessRequest[requestId];
        Lottery storage lottery = lotteries[_lotteryId];
        uint256 winner = randomness.mod(lottery.participants.length);
        lottery.isFinished = true;
        lottery.winner = lottery.participants[winner];
        delete lotteryRandomnessRequest[requestId];
        delete uniqueParticipations[_lotteryId];
        lottery.winner.call{value: lottery.prize }("");
        emit WinnerDeclared(requestId,lottery.lotteryId,lottery.winner);
    }
Enter fullscreen mode Exit fullscreen mode

Notice that we override the function's implementation to declare the winner with the random number. Then we are using mod (modulo) from the SafeMath library to get a number between 0 and the length of participations - 1. Finally, we pick the winner using the new number and declare the winner. Also, we save some gas by deleting some of the mapping values :).

Great! We now have our contract completed, but how do we deploy it? how do we test it if we don't have LINK in our local environment and can't interact with the VRFCoordinator?

Deployment

At the beginning of this tutorial, we installed hardhat-deploy plugin to help us with the deployment. Before using it, let's compare it with the default deployment method from Hardhat. Hardhat deployments require you to create a script within the scripts folder. The script looks like this:

const Greeter = await hre.ethers.getContractFactory("Greeter");
const greeter = await Greeter.deploy("Hello, Hardhat!");
await greeter.deployed();
Enter fullscreen mode Exit fullscreen mode

And you run it with:

npx hardhat run scripts/deploy.js
Enter fullscreen mode Exit fullscreen mode

On the other hand, hardhat-deploy will read our deployment scripts from the deploy folder.

hardhat-deploy extends Hardhat Runtime Environment (HRE) by including 4 new fields:

getNamedAccounts: hardhat-deploy plugin extends Hardhat config to associate names to addresses and have them configured per chain. This function returns those names and addresses.

getUnnamedAccounts: similar to getNamedAccounts but returns accounts with no name.

deployments: contains functions to access deployments.

getChainId: fetch the current chain id.

The previous script would be written like this:

module.exports = async ({ getNamedAccounts, deployments, getChainId }) => {
  const { deploy, log } = deployments;
  const { deployer } = await getNamedAccounts();

  log("Deploying Greeter");

  await deploy("Greeter", {
      from: deployer,
      log: true,
      args: ["Hello, Hardhat!"],
   });
  }
};

module.exports.tags = ["main"];
Enter fullscreen mode Exit fullscreen mode

We are including tags, a list of string values that can be referenced to execute either a single script or a group of them. Moreover, it allows us to establish dependencies between deployments which could be handy when writing complex deployment procedures.

Then, you can deploy it with:

npx hardhat deploy 
Enter fullscreen mode Exit fullscreen mode

Ok, let's see this in action. First, create the deploy folder at the root of our hardhat project.

mkdir deploy
Enter fullscreen mode Exit fullscreen mode

To test the Chainlink VRF, we need to deploy the LINK token in our HardHat environment and mock the VRFCoordinador to simulate the random number request. Fortunately, the Chainlink contracts package already comes with the contracts we need. We will create new contracts and import the Chainlink contracts to avoid copying all the code into our project.

Let's create the new contracts:

mkdir contracts/external
touch contracts/external/LinkToken.sol
touch contracts/external/VRFCoordinatorMock.sol
Enter fullscreen mode Exit fullscreen mode

LinkToken.sol

pragma solidity ^0.4.0;
import "@chainlink/contracts/src/v0.4/LinkToken.sol";
Enter fullscreen mode Exit fullscreen mode

VRFCoordinatorMock.sol

pragma solidity ^0.6.0;
import "@chainlink/contracts/src/v0.6/tests/VRFCoordinatorMock.sol";
Enter fullscreen mode Exit fullscreen mode

Now, we code our deployment script

touch contracts/00_Deploy_External.js
Enter fullscreen mode Exit fullscreen mode

00_Deploy_External.js

module.exports = async ({ getNamedAccounts, deployments, getChainId }) => {
  const { deploy, log } = deployments;
  const { deployer } = await getNamedAccounts();
  const chainId = await getChainId();

  if (chainId == 31337) {
    log("Local Network Detected, Deploying external contracts");
    const linkToken = await deploy("LinkToken", { from: deployer, log: true });
    await deploy("VRFCoordinatorMock", {
      from: deployer,
      log: true,
      args: [linkToken.address],
    });
  }
};

module.exports.tags = ["all", "mocks", "main"];
Enter fullscreen mode Exit fullscreen mode

This deployment script will be executed when any of the tags are referenced in the deploy command or in tests. But, it will deploy the LinkToken and VRFCoordinatorMock contracts only when the chainId is 31337, which is the chain Id of our HardHat environment node. There's no need to deploy it on the others supported chains because the contracts are already there.

Do you remember that our contract constructor has four input parameters required by ChainLink? Where do we get those values from? the answer is here

To include these values in our project, we create a config file:

mkdir config
touch config/chainlink.config.js
Enter fullscreen mode Exit fullscreen mode

chainlink.config.js

const config = {
  // Hardhat local network
  // Mock Data (it won't work)
  31337: {
    name: "hardhat",
    keyHash:
      "0x6c3699283bda56ad74f6b855546325b68d482e983852a7a82979cc4807b641f4",
    fee: "0.1",
    fundAmount: "10000000000000000000",
  },
  // Rinkeby
  4: {
    name: "rinkeby",
    linkToken: "0x01BE23585060835E02B77ef475b0Cc51aA1e0709",
    vrfCoordinator: "0xb3dCcb4Cf7a26f6cf6B120Cf5A73875B7BBc655B",
    keyHash:
      "0x2ed0feb3e7fd2022120aa84fab1945545a9f2ffc9076fd6156fa96eaff4c1311",
    fee: "0.1",
    fundAmount: "2000000000000000000",
  },
};

module.exports = {
  config
};
Enter fullscreen mode Exit fullscreen mode
touch contracts/01_Deploy_Lottery.js
Enter fullscreen mode Exit fullscreen mode

01_Deploy_Lottery.js

const { config } = require("../config/link.config");

module.exports = async ({ getNamedAccounts, deployments, getChainId, ethers }) => {
  const { deploy, get, log } = deployments;
  const { deployer } = await getNamedAccounts();
  const chainId = await getChainId();
  let linkToken;
  let linkTokenAddress;
  let VRFCoordinatorMock;
  let vrfCoordinatorAddress;
  let additionalMessage = "";

  if (chainId == 31337) {
    linkToken = await get("LinkToken");
    VRFCoordinatorMock = await get("VRFCoordinatorMock");
    linkTokenAddress = linkToken.address;
    vrfCoordinatorAddress = VRFCoordinatorMock.address;
    additionalMessage =
      " --linkaddress " +
      linkTokenAddress +
      " --fundadmount " +
      config[chainId].fundAmount;
  } else {
    linkTokenAddress = config[chainId].linkToken;
    vrfCoordinatorAddress = config[chainId].vrfCoordinator;
  }
  const keyHash = config[chainId].keyHash;
  const fee = config[chainId].fee;

  const lottery = await deploy("LotteryGame", {
    from: deployer,
    args: [
      vrfCoordinatorAddress,
      linkTokenAddress,
      keyHash,
      ethers.utils.parseUnits(fee, 18),
    ],
    log: true,
  });

  log("Run the following command to fund contract with LINK:");
  log(
    "npx hardhat fund-link --contract " +
      lottery.address +
      " --network " +
      config[chainId].name +
      additionalMessage
  );
  log("----------------------------------------------------");
};

module.exports.tags = ["all", "main"];
Enter fullscreen mode Exit fullscreen mode

If we are in our local environment (chain id 31337), the script will get the address of our local LinkToken and VRFCoordinator contracts and use them to deploy our Lottery contract. On the other hand, if we deploy to non-local networks, we will use the addresses from the config file.

Also, we log a message to remind the user to run the fund-link command to send LINK to our Lottery contract.

npx hardhat deploy

Local Network Detected, Deploying Mocks
deploying "LinkToken" (tx: 0x596222d755670dab88fa5005d6e6f25097b05ccef8810d13ec5b6c8adb02e05d)...:
deployed at 0x5FbDB2315678afecb367f032d93F642f64180aa3 with 1279067 gas
deploying "VRFCoordinatorMock" (tx: 0xa7a3d8b4f67818b87d496938f4075c3c46f04d392e7ae2758b4c72807e215d3f)...:
deployed at 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 with 363662 gas
deploying "LotteryGame" (tx: 0x92d0807c1a47825c8197d00ec44425c23e4499b84bbd91d82308e03363659e37)...:
deployed at 0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0
.........
Enter fullscreen mode Exit fullscreen mode

Great! We have deployed all the contracts into our local environments. Now, let's focus on testing.

Testing

You can find all the tests in the repository. Here I will only focus on VRF testing.

Let's start by creating our unit test file:

touch test/lottery_unit_test.js
Enter fullscreen mode Exit fullscreen mode

lottery_unit_test.js

const { expect } = require("chai");
const { ethers, getChainId, deployments } = require("hardhat");
const { config, autoFundCheck } = require("../config/link.config");

describe("LotteryGame Unit Tests", () => {
  let LotteryGame;
  let lotteryGame;
  let LinkToken;
  let linkToken;
  let chainId;
  let deployer;
  let user2;
  let user3;
  const DAY = 3600 * 24;
  before(async () => {
    [deployer, user2, user3] = await ethers.getSigners();
    chainId = await getChainId();
    await deployments.fixture(["main"]);
    LinkToken = await deployments.get("LinkToken");
    linkToken = await ethers.getContractAt("LinkToken", LinkToken.address);
    LotteryGame = await deployments.get("LotteryGame");
    lotteryGame = await ethers.getContractAt(
      "LotteryGame",
      LotteryGame.address
    );
  });
it("Should Declare a winner", async () => {
    const networkName = config[chainId].name;
    const additionalMessage = " --linkaddress " + linkToken.address;
    if (
      await autoFundCheck(
        lotteryGame.address,
        networkName,
        linkToken.address,
        additionalMessage
      )
    ) {
      await hre.run("fund-link", {
        contract: lotteryGame.address,
        linkaddress: linkToken.address,
      });
    }

    await lotteryGame.createLottery(ethers.utils.parseEther("0.0005"), DAY * 1);
    await lotteryGame.connect(user2).participate(0, {
      value: ethers.utils.parseEther("0.0005"),
    });
    await lotteryGame.connect(user3).participate(0, {
      value: ethers.utils.parseEther("0.0005"),
    });
    await ethers.provider.send("evm_increaseTime", [3600 * 24 * 2]);
    const tx = await lotteryGame.declareWinner(0);
    const txReceipt = await tx.wait();
    const requestId = txReceipt.events[2].topics[1];

    // eslint-disable-next-line no-unused-expressions
    expect(requestId).to.be.not.null;
  });
});
Enter fullscreen mode Exit fullscreen mode

We start the unit test script by deploying our contracts with await deployments.fixture(["main"]). In this case, we are deploying all the scripts that include the tag "main". Then, we get the reference to our Lottery Contract and the LinkToken.

Next, we define the test itself and start by running the fund-link command to fund our Lottery contract with LINK using hre.run. Then, we create a lottery and impersonate some users to participate in it. Since we only can declare a winner after the lottery has ended, we will jump forward in time with await ethers.provider.send("evm_increaseTime", [3600 * 24 * 2]). Finally, we declare the winner and since we can't expect a random number, the only way we have to validate that the random number was requested is to read events from the transaction receipt.

The transaction receipt from const txReceipt = await tx.wait(); contains an event array with all the events emitted by our contract method call. Each event will have an array of topics up to four elements where the first element will be the hash obtained from the function signature and the rest will be indexed arguments. You can read more about this here.

There are four events that will be emitted with our method call:

  • events[0]: Transfer(address,address,uint256) from ERCBasic.sol
  • events[1]: Transfer(address,address,uint256,bytes) from ERC677 called from ERC677Token.sol
  • events[2]: RandomnessRequest(address,bytes32,uint256) from VRFCoordinatorMock.sol
  • events[3]: WinnerDeclared from Lottery.sol

So, if the random number was successfully requested, a RandomnessRequest event should be emitted with the second indexed argument not equal to 0.

const requestId = txReceipt.events[2].topics[1];
expect(requestId).to.be.not.null;
Enter fullscreen mode Exit fullscreen mode

If you wonder were ERCBasic, ERC677Token contracts are, you can find them inside the @chainlink/contracts package.

Next, we run our unit test script:

npx hardhat test ./test/lottery_unit_test.js
Enter fullscreen mode Exit fullscreen mode

The integration script is similar to the previous one. But we need to fund our contract with LINK from the network we will deploy our contract to. We can use Chainlink faucets to fund our wallets with LINK and then run fund-link command to send LINK to our contract.

Warning: It is essential to select the correct network when requesting LINK. Otherwise, you won't be able to send LINK into your contract.

Chainlink Faucet

Then, we deploy our contract to a testnet:

touch test/lottery_integration_test.js
Enter fullscreen mode Exit fullscreen mode

lottery_integration_test.js

describe("Lottery Integration Tests", () => {
  let LotteryGame;
  let lotteryGame;
  let chainId;
  let deployer;
  let user2;
  let user3;
  before(async () => {
    LotteryGame = await deployments.get("LotteryGame");
    lotteryGame = await ethers.getContractAt(
      "LotteryGame",
      LotteryGame.address
    );
    [deployer, user2, user3] = await ethers.getSigners();
  });
  it("Should receive the random number from the Oracle", async () => {
    const createLotteryTx = await lotteryGame.createLottery(120, {
      value: ethers.utils.parseEther("0.0005"),
    });
    await createLotteryTx.wait();
    console.log("Lottery created", new Date());
    const length = await lotteryGame.getLotteryCount();
    const lengthNumber = length.toNumber() - 1;
    console.log("Current lottery id", lengthNumber);
    const lottery = await lotteryGame.getLottery(lengthNumber);
    console.log("EndDate: ", new Date(lottery.endDate * 1000));
    const participateTx = await lotteryGame
      .connect(user2)
      .participate(lengthNumber, {
        value: ethers.utils.parseEther("0.0005"),
      });
    console.log(participateTx);
    await participateTx.wait();
    console.log("User2 participated", new Date());
    const lottery2 = await lotteryGame.getLottery(lengthNumber);
    console.log(lottery2);
    await new Promise((resolve) => setTimeout(resolve, 120000));
    const winnerTx = await lotteryGame.declareWinner(lengthNumber);
    await winnerTx.wait();
    // Give the oracle some minutes to update the random number
    await new Promise((resolve) => setTimeout(resolve, 180000));
    const lotteryAfter = await lotteryGame.getLottery(lengthNumber);
    console.log(lotteryAfter);
    expect(lotteryAfter.winner).to.not.be.eq(
      "0x0000000000000000000000000000000000000000"
    );
  });
});

Enter fullscreen mode Exit fullscreen mode

Finally, we run our integration test script:

npx hardhat test ./test/lottery_unit_test.js
Enter fullscreen mode Exit fullscreen mode

The result should be:

✓ Should receive the random number from the Oracle
  1 passing (6m)
Enter fullscreen mode Exit fullscreen mode

Conclusion

This ended up being a more extensive tutorial than I expected. When I started playing with Chainlink VRF I was worried about testing and even read VRFConsumerBase contract and its dependencies to understand how it worked.

I encourage you to read about hardhat-deploy plugin. It has many other features we haven't covered here that could be helpful in your future projects.

In future posts, I'll be writing about smart contracts upgradability patterns and IPFS. If you have some questions about this tutorial or suggestions for future posts, leave some comments below.

Discussion (0)