How to unit-test with Chainlink VRF V2

While it is quite straightforward to use the Chainlink VRF V2 oracle, the Request & Receive Data cycle is a bit less easy to use on a local network (e.g. a hardhat node for testing) where there is no Chainlink node listening to the calls.

This article aims at giving a step-by-step guide to a working solution for unit-testing a contract using the new Chainlink VRF V2 oracle (Chainlink actually provides an example for the VRF V1 version, see the hardhat starter kit)

Setup

First of all, we detail here how to use the Chainlink VRF V2 oracle. These steps are somehow described in the main documentation page of the Chainlink VRF V2 oracle.

In Chainlink's vocabulary:

  • a random number is called a "random word"
  • the contract is a consumer of the oracle, i.e. of the random number provider
  • this consumer requires to ask a coordinator to provide a random number
  • the consumer can do so by:
    • subscribing to the coordinator
    • funding its subscription with some LINK tokens
    • calling the coordinator whenever they wants to get a new random number
    • implementing a callback function that will be called by the coordinator

To interact with Chainlink's contracts, we first add their package to our project:

npm install @chainlink/contracts

Then, let's create a new contract:

pragma solidity ^0.8.12;

import "@chainlink/contracts/src/v0.8/VRFConsumerBaseV2.sol";

contract MyContract is VRFConsumerBaseV2 {
    constructor(address vrfCoordinator) VRFConsumerBaseV2(vrfCoordinator) {}
}

As mentioned earlier, the contract is a consumer of the oracle and hence we inherit from the VRFConsumerBaseV2 contract provided by Chainlink. We see that the VRFConsumerBaseV2 takes as input the address of the coordinator. For rinkeby and mainnet, this address is provided by Chainlink itself here.

In order to call the coordinator, MyContract needs to directly make a call to it. Hence we add:

pragma solidity ^0.8.12;

import "@chainlink/contracts/src/v0.8/interfaces/VRFCoordinatorV2Interface.sol";
import "@chainlink/contracts/src/v0.8/VRFConsumerBaseV2.sol";

contract MyContract is VRFConsumerBaseV2 {
  
    VRFCoordinatorV2Interface COORDINATOR;
    
    constructor(address vrfCoordinator) VRFConsumerBaseV2(vrfCoordinator) {
        COORDINATOR = VRFCoordinatorV2Interface(vrfCoordinator);
    }
}

As we said earlier, MyContract has to subscribe to the Chainlink's coordinator. Otherwise it would not be possible for Chainlink to differentiate between different consumers. The subscription can be done directly on the Chainlink's website but we will make it programmatically to be able to subscribe to our own coordinator when unit-testing locally. This can be done as follows:

pragma solidity ^0.8.12;

import "@chainlink/contracts/src/v0.8/interfaces/VRFCoordinatorV2Interface.sol";
import "@chainlink/contracts/src/v0.8/VRFConsumerBaseV2.sol";

contract MyContract is VRFConsumerBaseV2 {
  
    VRFCoordinatorV2Interface COORDINATOR;
    uint64 subscriptionId;
    
    constructor(address vrfCoordinator) VRFConsumerBaseV2(vrfCoordinator) {
        COORDINATOR = VRFCoordinatorV2Interface(vrfCoordinator);
        subscriptionId = COORDINATOR.createSubscription();
        COORDINATOR.addConsumer(subscriptionId, address(this));
    }
  
    function cancelSubscription() external {
        COORDINATOR.cancelSubscription(subscriptionId, msg.sender);
    }  
}

The last step is to fund the consumer with some LINK tokens to pay the oracle:

pragma solidity ^0.8.12;

import "@chainlink/contracts/src/v0.8/interfaces/VRFCoordinatorV2Interface.sol";
import "@chainlink/contracts/src/v0.8/VRFConsumerBaseV2.sol";
import "@chainlink/contracts/src/v0.8/interfaces/LinkTokenInterface.sol";

contract MyContract is VRFConsumerBaseV2 {
  
    VRFCoordinatorV2Interface COORDINATOR;
    LinkTokenInterface LINKTOKEN;
    uint64 subscriptionId;
    
    constructor(address vrfCoordinator, address link) VRFConsumerBaseV2(vrfCoordinator) {
        COORDINATOR = VRFCoordinatorV2Interface(vrfCoordinator);
        subscriptionId = COORDINATOR.createSubscription();
        COORDINATOR.addConsumer(subscriptionId, address(this));
        LINKTOKEN = LinkTokenInterface(link);
    }
  
    function cancelSubscription() external {
        COORDINATOR.cancelSubscription(subscriptionId, msg.sender);
    }
  
    function fund(uint96 amount) public {
        LINKTOKEN.transferAndCall(
            address(COORDINATOR),
            amount,
            abi.encode(subscriptionId)
        );
    }
}

Note that the fund function is outside of the constructor because we want to be able to call it at any time to refill our contract.

Requesting a random number

When one wants to request a random number, one then only needs to call the coordinator's requestRandomWords function. For example, let's implement a randomnessIsRequestedHere function:

pragma solidity ^0.8.12;

import "@chainlink/contracts/src/v0.8/interfaces/VRFCoordinatorV2Interface.sol";
import "@chainlink/contracts/src/v0.8/VRFConsumerBaseV2.sol";
import "@chainlink/contracts/src/v0.8/interfaces/LinkTokenInterface.sol";

contract MyContract is VRFConsumerBaseV2 {
  
    VRFCoordinatorV2Interface COORDINATOR;
    LinkTokenInterface LINKTOKEN;
    uint64 subscriptionId;
    
    constructor(address vrfCoordinator, address link) VRFConsumerBaseV2(vrfCoordinator) {
        COORDINATOR = VRFCoordinatorV2Interface(vrfCoordinator);
        subscriptionId = COORDINATOR.createSubscription();
        COORDINATOR.addConsumer(subscriptionId, address(this));
        LINKTOKEN = LinkTokenInterface(link);
    }
  
    function cancelSubscription() external {
        COORDINATOR.cancelSubscription(subscriptionId, msg.sender);
    }
  
    function fund(uint96 amount) public {
        LINKTOKEN.transferAndCall(
            address(COORDINATOR),
            amount,
            abi.encode(subscriptionId)
        );
    }
  
    function randomnessIsRequestedHere() public {
        uint256 requestId = COORDINATOR.requestRandomWords(
            keyHash,
            subscriptionId,
            minimumRequestConfirmations,
            callbackGasLimit,
            numWords
        );
    }
}

The requestRandomWords function is documented here. The parameter names almost speak for themselves. The keyHash is a parameter to give a priority level to the callback transaction. What is this callback transaction? It is the transaction that will be executed by the coordinator when the random number is ready. Actually, when inheriting from VRFConsumerBaseV2, MyContract has to implement the fulfillRandomWords function. As mentioned in the contract's documentation:

  // rawFulfillRandomness is called by VRFCoordinator when it receives a valid VRF
  // proof. rawFulfillRandomness then calls fulfillRandomness, after validating
  // the origin of the call
  function rawFulfillRandomWords(uint256 requestId, uint256[] memory randomWords) external {
    if (msg.sender != vrfCoordinator) {
      revert OnlyCoordinatorCanFulfill(msg.sender, vrfCoordinator);
    }
    fulfillRandomWords(requestId, randomWords);
  }

This is what makes the fulfillRandomWords hard to test locally as we will see later on. In any case, for now, a possible working implementation is:

pragma solidity ^0.8.12;

import "@chainlink/contracts/src/v0.8/interfaces/VRFCoordinatorV2Interface.sol";
import "@chainlink/contracts/src/v0.8/VRFConsumerBaseV2.sol";
import "@chainlink/contracts/src/v0.8/interfaces/LinkTokenInterface.sol";

contract MyContract is VRFConsumerBaseV2 {
  
    VRFCoordinatorV2Interface COORDINATOR;
    LinkTokenInterface LINKTOKEN;
    uint64 subscriptionId;
    
    constructor(address vrfCoordinator, address link) VRFConsumerBaseV2(vrfCoordinator) {
        COORDINATOR = VRFCoordinatorV2Interface(vrfCoordinator);
        subscriptionId = COORDINATOR.createSubscription();
        COORDINATOR.addConsumer(subscriptionId, address(this));
        LINKTOKEN = LinkTokenInterface(link);
    }
  
    function cancelSubscription() external {
        COORDINATOR.cancelSubscription(subscriptionId, msg.sender);
    }
  
    function fund(uint96 amount) public {
        LINKTOKEN.transferAndCall(
            address(COORDINATOR),
            amount,
            abi.encode(subscriptionId)
        );
    }
  
    function randomnessIsRequestedHere() public {
        uint256 requestId = COORDINATOR.requestRandomWords(
            keyHash,
            subscriptionId,
            minimumRequestConfirmations,
            callbackGasLimit,
            numWords
        );
    }
  
    function fulfillRandomWords(uint256 requestId, uint256[] memory randomWords)
        internal
        override
    {
        // do something with the random words
        // use requestId to identify the request
    }  
}

Testing the contract

For this section, with specifically use hardhat and the hardhat-deploy plugin.

If we want to unit-test locally this contract, we need to be able to call both the randomnessIsRequestedHere and the fulfillRandomWords functions. In other words, we need to have a working local COORDINATOR that will:

  • returns a valid requestId
  • be used to call the MyContract.rawFulfillRandomness function that will in turns call the MyContract.fulfillRandomWords function

Fortunately, the @chainlink/contracts library provides a VRFCoordinatorV2TestHelper class that can be used to create a local COORDINATOR. We need:

  • to deploy it
  • to update its config
  • to fund it

This later part requires to write a new contract because the @chainlink's contract does not implement the receive function.

pragma solidity ^0.8.12;

import {VRFCoordinatorV2TestHelper as Helper} from "@chainlink/contracts/src/v0.8/tests/VRFCoordinatorV2TestHelper.sol";

contract VRFCoordinatorV2TestHelper is Helper {
    receive() external payable {}

    constructor(
        address link,
        address blockhashStore,
        address linkEthFeed
    ) Helper(link, blockhashStore, linkEthFeed) {}
}

To ease the local deployment, it is possible to use rinkeby forking. This will make it possible to avoid deploying also the LinkToken contract and the Link/Eth contract feed, see hardhat doc:

// hardhat.config.ts
const config: HardhatUserConfig = {
  // ...
  networks: {
    hardhat: {
      // ...
      forking: {
        url: "provider-url",
      }
      // ...
    },
  },
  // ...
}

Using the hardhat-deploy plugin, it can then look like this:

const vrfTx = await deploy("VRFCoordinatorV2TestHelper", {
  from: deployer,
  log: true,
  args: [linkAddress, blockHashStore, linkEthFeed],
  contract:
    "contracts/test/VRFCoordinatorV2TestHelper.sol:VRFCoordinatorV2TestHelper",
});
const vrfCoordinatorAddress = vrfTx.address;
await execute(
  "VRFCoordinatorV2TestHelper",
  { from: deployer },
  "setConfig",
  3,
  2500000,
  86400,
  33285,
  "60000000000000000",
  {
    fulfillmentFlatFeeLinkPPMTier1: 250000,
    fulfillmentFlatFeeLinkPPMTier2: 250000,
    fulfillmentFlatFeeLinkPPMTier3: 250000,
    fulfillmentFlatFeeLinkPPMTier4: 250000,
    fulfillmentFlatFeeLinkPPMTier5: 250000,
    reqsForTier2: 0,
    reqsForTier3: 0,
    reqsForTier4: 0,
    reqsForTier5: 0,
  }
);
  • the blockHashStore can be set to ethers.constants.AddressZero
  • the linkAddress and the linkEthFeed are the one for rinkeby as described here and here
  • the parameters of the setConfig function are the one found using getConfig of the deployed coordinator on rinkeby, for example on etherscan

The last part of the deployment process is to fund the subscription. Using the rinkeby forking, you will automatically have available locally the LINK token that you will have requested in the chainlink faucet. So you just need to send them to the contract:

const LinkToken = await ethers.getContractAt(
    [
      "function balanceOf(address owner) view returns (uint256 balance)",
      "function transferFrom(address from, address to, uint256 value) returns (bool success)",
      "function approve(address _spender, uint256 _value) returns (bool)",
    ],
    linkAddress,
    deployer
);
const deployerBalance = await LinkToken.balanceOf(deployer);
// deployerBalance is the balance of your deployer account on rinkeby
await LinkToken.approve(deployer, deployerBalance, {
    from: deployer,
});
// this sounds weired but I could not make it work without it
const txTransfer = await LinkToken.transferFrom(
    deployer,
    MyContract.address,
    deployerBalance
);
await txTransfer.wait();
await execute(
    "MyContract",
    { from: deployer },
    "fund",
    deployerBalance
);

And that's it! You can now call MyContract.randomnessIsRequestedHere and MyContract.rawFulfillRandomness locally in your unit tests. Wait. Not exactly!

For the coordinator to be able to call MyContract.rawFulfillRandomness, they need to have some ETH to pay the transaction fees with… and hardhat needs to understands that it’s not the default signer calling the contracts. Using the createFixture` of the hardhat-deploy plugin, you can do something like this to send 1 ETH to the coordinator:

const vrfFixture = deployments.createFixture(async ({}) => {
  await network.provider.request({
    method: "hardhat_impersonateAccount",
    params: [VRFCoordinator.address],
  });
  await (
    await ethers.getSigner(users[0].address)
  ).sendTransaction({
    to: VRFCoordinator.address,
    value: ethers.utils.parseEther("1"),
  });
});

And in before each test where you call on behalf on the coordinator, just await vrfFixture() beforehand.

I hope this helps you to get started with Chainlink VRF V2. If you have any questions, please reach to me on Twitter or discord.

Subscribe to Clemlaflemme
Receive the latest updates directly to your inbox.
Verification
This entry has been permanently stored onchain and signed by its creator.