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) when there is no Chainlink node listening to the calls.
This articles aims at giving a step-by-step guide to a working solution for unit-testing a contract using the new Chainlink VRF oracle (Chainlink actually provides an example for the VRF V1 version, see the hardhat starter kit)
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
LINKtokens - calling the coordinator whenever it 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/contractsThen, 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 the 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 address is outside of the constructor because we want to be able to call it at any time to refill
our contract.
When one want 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 name almost speak for themselves. The keyHash is a parameter to given 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 solution 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
}
}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.rawFulfillRandomnessfunction that will in turns call theMyContract.fulfillRandomWordsfunction
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
blockHashStorecan be set toethers.constants.AddressZero - the
linkAddressand thelinkEthFeedare the one for rinkeby as described here and here - the parameters of the
setConfigfunction are the one found usinggetConfigof the deployed coordinator on rinkeby, for example on etherscan
The last part of the deployment process should be 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.
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.
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.