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)
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:
LINK
tokensTo 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.
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
}
}
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:
requestId
MyContract.rawFulfillRandomness
function that will in turns call the MyContract.fulfillRandomWords
functionFortunately, the @chainlink/contracts
library provides a VRFCoordinatorV2TestHelper
class that can be used to create a local COORDINATOR
. We need:
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,
}
);
blockHashStore
can be set to ethers.constants.AddressZero
linkAddress
and the linkEthFeed
are the one for rinkeby as described here and heresetConfig
function are the one found using getConfig
of the deployed coordinator on rinkeby, for example on etherscanThe 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.