20 min read
This guide includes references to the Goerli testnet, which is no longer actively maintained. While specific steps related to this chain may not be applicable, the overall process may be valid for other chains. We recommend exploring current alternatives for your implementation. If you’d like to see an updated version of this guide, please let us know!
Overview
Generating randomness on a blockchain can be difficult since smart contracts are deterministic and can be predicted. However, Chainlink solves this problem. This article will teach you more about Chainlink VRF and how it works. Beyond that, you will learn about the two different Chainlink VRF methods - the direct funding method and the subscription method. Later in the article, you use the Remix.IDE to deploy a custom lottery smart contract and generate a random number using Chainlink VRF.
What You Will Need
- Basic understanding of Solidity
- Remix IDE
- MetaMask wallet
- ETH on Goerli testnet (you can get some at the QuickNode Faucet)
- Testnet LINK tokens
What You Will Do
- Learn about Chainlink VRF and the different request methods
- Create and deploy a custom lottery smart contract with Chainlink VRF
- Test the lottery smart contract on the Goerli testnet with the Remix.IDE
What is Chainlink VRF?
Chainlink VRF is a fair and verifiable random number generator (RNG) that allows smart contracts to access random values without jeopardizing security. For each request, the Chainlink VRF returns one or more random values along with the on-chain cryptographic proof.
You can use the Chainlink VRF to build provably fair and verifiable random numbers for your smart contract. Potential use cases include:
- Blockchain Gaming/Metaverse
- Matchmaking PVP: Create provably fair matchmaking systems that let users know no manipulation can occur.
- Unpredictable games: Add provably fair randomness to your game (e.g., random prizes throughout game) - NFTs
- Randomize winners that are part of your community or online campaign - DeFi
- Picking winners in staked pools - Consumer advertising & rewards
- Randomize winners in online campaigns
- Randomize winners in loyalty membership clubs - Fair probabilities (e.g., online betting)
- Randomizing probabilities in online betting games, lootboxes, etc. - Security
- Add randomness that can't be predicted to your authentication flow
These are just a few examples. Use your creativity and experience to experiment with ideas!
How does Chainlink VRF work?
As we have discussed so far, Chainlink VRF helps you to get and return random data from your smart contract. But before you can tap into it, you will need to fund your account with the LINK token, which is the native and utility token of Chainlink. If you want to deploy the contract on mainnet, you will need to get the LINK token from an exchange.
However, for developmental purposes, you can use the testnet LINK tokens on either Goerli or Mumbai test network (you can get some at this faucet). The next step will be to import Chainlink VRF implementations and use them accordingly in your contract.
Once the Chainlink VRF is incorporated into your smart contract, whenever the randomness is requested, this is what Chainlink will do in the backend:
A Chainlink node would use some data from the current block to program randomness.
After generating the randomness, it will send the cryptographic proof to the VRF contract. This will have to be approved before the randomness is publicly displayed on the blockchain.
All these on-chain and off-chain communications happen within a relatively short time frame.
Methods of Requesting Randomness
Chainlink VRF offers two methods for requesting randomness:
- Subscription Model
To use this method, you must create a subscription account and fund the account with LINK tokens. The user can then connect multiple contracts without funding each one separately.
This method allows you to fund bids for multiple consumer contracts from a single subscription. Once the consuming contracts request random values, the request will be fulfilled, and the transaction costs will be calculated. Then the balance will be deducted accordingly.
- Direct funding
In this method, once the users request random values, the consuming contract will pay with the LINK token. Therefore, you must directly fund your consumer contracts and ensure enough LINK tokens to pay for random requests. Alternatively, you can also pass through the LINK costs to your user by adding extra logic in your smart contract.
How to Choose a Suitable Method between the Subscription Model and the Direct Funding Model
Each model has its benefits, and one is not better than the other. It all depends on the particular use cases of your contracts and DApps. On this note, you can consider these facts to make a better choice:
- The subscription model is better and more cost-effective if your DApp requires regular random requests. On the other hand, the direct funding method is a better option for handling one-off requests.
- The subscription model is better if you have several VRF-consuming contracts.
- The subscription method is more advisable if you are concerned about optimizing gas prices for requests.
- Unlike the direct funding method, the subscription can return many random words in a single request.
Creating a Custom Lottery Contract with Chainlink VRF
So far, in this piece, we have built and maintained enough conceptual understanding of how the Chainlink VRF works. Thus, we shall proceed to build a lottery contract to try out everything we have discussed from a development perspective.
Open a new blank workspace in Remix.IDE, then create a solidity file called game.sol.
Step 1: Importing Dependencies
Since we would be interacting with the chainlink interfaces, we would have to import and inherit three dependencies in our contract: the VRFCoordinatorV2Interface, the VRFConsumerBaseV2, and the ConfirmedOwner contracts.
The VRFCoordinatorV2Interface is a dependency with functions that can request randomness and manage subscriptions. The VRFConsumerBaseV2 is for effective communications with the consumer contracts. Then the ConfirmedOwner dependency is for security as it stamps the owner and sets away manipulators.
Open the game.sol file and add the following code to the top:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
import "https://github.com/smartcontractkit/chainlink/blob/develop/contracts/src/v0.8/interfaces/VRFCoordinatorV2Interface.sol";
import "https://github.com/smartcontractkit/chainlink/blob/develop/contracts/src/v0.8/VRFConsumerBaseV2.sol";
import "@chainlink/contracts/src/v0.8/ConfirmedOwner.sol";
Step 2: Setting The State Variables
Next, we will create a contract called Game and then declare the smart contract logic (i.e., events, structs, mappings, and state variables). Add the following code under the import statements in game.sol:
contract Game is VRFConsumerBaseV2, ConfirmedOwner {
event RequestSent(uint256 requestId, uint32 numWords);
event RequestFulfilled(uint256 requestId, uint256[] randomWords);
struct RequestStatus {
bool fulfilled; // whether the request has been successfully fulfilled
bool exists; // whether a requestId exists
uint256[] randomWords;
}
mapping(uint256 => RequestStatus)
public s_requests; /* requestId --> requestStatus */
VRFCoordinatorV2Interface COORDINATOR;
// Your subscription ID.
uint64 s_subscriptionId;
// past requests Id.
uint256[] public requestIds;
uint256 public lastRequestId;
bytes32 immutable keyHash;
address public immutable linkToken;
uint32 callbackGasLimit = 150000;
uint16 requestConfirmations = 3;
uint32 numWords = 1;
uint public randomWordsNum;
The two initial events (i.e., RequestSent and RequestFulfilled) are logged when the user requests random values; one logs the sent values, while the other log the fulfilled values.
Then there is a struct (i.e., RequestStatus) that embodies data concerning the status of any request, whether it is fulfilled or even exists at all. Having created this struct, we would need to track it, hence, the reason for the mapping.
Subsequently, we introduced keyHash and linkToken into the contract. The rest was to set the gaslimit, the number of random words we are expecting, and the request confirmation.
That aside, we will also need to create state variables for our lottery contract.
Add the following code under the previous code you pasted into game.sol.
address[] public players;
uint maxPlayers;
bool public gameStarted;
uint public entryfee;
uint public gameId;
address public recentWinner;
event GameStarted(uint gameId, uint maxPlayers, uint entryfee);
event PlayerJoined(uint gameId, address player);
event GameEnded(uint gameId, address winner);
Foremost, we created an array of players and made the visibility public. Then we created unsigned integers for the maximum number of players, maxPlayers. The lottery will have to start before everyone can participate, so we created a boolean data type, gameStarted, to track the game status.
Each participant will pay an entryfee, and each game will have a unique gameId. Then we created an address data type for the recentWinner.
The events track when a game has started, ended, and whenever a player has joined. You can find a log of these events using the eth_getLogs method.
Step 3: Initializing in the Constructor
We imported some dependencies initially, which will initialize now in the constructor. First, we set some constructor arguments (e.g., subscriptionId, _linkToken), which refer to the subscription ID that our contract uses for funding and the link token address for the network we are deploying from.
We then initialize VRFConsumerBaseV2 and call the ConfirmedOwner function to set the msg.sender as the owner. After setting our COORDINATOR variable with VRFCoordinatorV2Interface, we set the s_subscriptionId. Note to be conscious of the network your deploying to and get the correct values accordingly. Lastly, we set the keyHash, the maximum gas price (in Wei) you are willing to pay for a request, the linkToken address, and our gameStarted variable set to false.
In this tutorial, we will use the Goerli testnet. More importantly, we set the commencement of the game to false until the owner starts it.
Add the following code under the previous code you pasted into game.sol.
constructor(
uint64 subscriptionId,
address _linkToken
)
VRFConsumerBaseV2(0x2Ca8E0C643bDe4C2E08ab1fA0da3401AdAD7734D)
ConfirmedOwner(msg.sender)
{
COORDINATOR = VRFCoordinatorV2Interface(
0x2Ca8E0C643bDe4C2E08ab1fA0da3401AdAD7734D
);
s_subscriptionId = subscriptionId;
keyHash = 0x79d3d8832d904592c0bf9818b621522c988bb8b0c05cdc3b15aea1b6e8db0c15; // we alread set this
linkToken = _linkToken;
gameStarted = false;
}
Step 4: The startGame Function
Add the following code under the previous code you pasted into game.sol.
function startGame(uint _maxPlayers, uint _entryfee) public {
require(!gameStarted, "The Game has started");
players = new address[](0);
maxPlayers = _maxPlayers;
gameStarted = true;
entryfee = _entryfee; // entry fee in wei(18 zeros)
gameId += 1;
emit GameStarted(gameId, maxPlayers, entryfee);
}
We created the function startGame above and made it public. Then we reset both the players array and the maximum number of players.
As the game commences, we increment the gameID by 1 and update gameStarted to true.
Step 5: The joinGame Function
Add the following code under the previous code you pasted into game.sol.
function joinGame() public payable {
require(gameStarted, "The Game has not kicked off");
require(players.length < maxPlayers, "The Game is Filled Up!");
require(
msg.value == entryfee,
"The amount should be equal to the entry fee"
);
players.push(msg.sender);
emit PlayerJoined(gameId, msg.sender);
if (players.length == maxPlayers) {
getRandomWinner();
}
}
We had to set three checks in the joinGame function above. The prospective participants cannot join if:
- The game has not started
- The maximum number of players has been met
- The participant doesn't pay the entry fee
Once the player is validated, then the participant will be added to the player's array. We then check if the number of the players is less than the maxPlayers set. Once the final player joins the game (players.length == maxPlayers), the getRandomWinner function will be called to pick a random winner.
Step 6: The pickWinner Function
Add the following code under the previous code you pasted into game.sol.
function getRandomWinner() internal returns (address) {
uint256 requestId = requestRandomWords();
uint256 winnerIndex = randomWordsNum % players.length;
recentWinner = players[winnerIndex];
(bool success, ) = recentWinner.call{value: address(this).balance}("");
require(success, "Could not send ether");
gameStarted = false;
emit GameEnded(gameId, recentWinner);
return recentWinner;
}
In the getRandomWinner function above, we assigned the requestId to requestRandomWords. Then we spinned the player array and randomWordsNum with modulo to generate randomness.
Once that is successful, we made a transfer call to the recentWinner and returned their address for public visibility.
Step 7: Set up logic for fetching randomness
You must implement these three major Chainlink functions to generate random numbers: The requestRandomWords function, the fulfillRandomWords function, and the getRequestStatus function. Let's take a moment to understand how these functions operate to generate randomness.
When we call the requestRandomWords function, the Chainlink oracle makes a request to generate a random number.
This request contains a requestId variable that relays data to the VRF coordinator, such as our subscription id, request confirmations limit, callback gas limit, and the number of random numbers we desire. Upon completion, the oracle will return an ID (e.g., requestId). The contract will also store the return value in a mapping (e.g., s_requests).
So what is the purpose of the returned requestId? It's used in the callback function fulfillRandomWords, where it takes a requestId (upon the completion of the requestRandomWords function call) and an array of randomWords (i.e., numbers). Note that in this callback function, you can implement logic to modify the returned random number or update state variables, etc.
function requestRandomWords() public onlyOwner returns (uint256 requestId) {
requestId = COORDINATOR.requestRandomWords(
keyHash,
s_subscriptionId,
requestConfirmations,
callbackGasLimit,
numWords
);
s_requests[requestId] = RequestStatus({
randomWords: new uint256[](0),
exists: true,
fulfilled: false
});
requestIds.push(requestId);
lastRequestId = requestId;
emit RequestSent(requestId, numWords);
return requestId; // requestID is a uint.
}
function fulfillRandomWords(
uint256 _requestId,
uint256[] memory _randomWords
) internal override {
require(s_requests[_requestId].exists, "request not found");
s_requests[_requestId].fulfilled = true;
s_requests[_requestId].randomWords = _randomWords;
randomWordsNum = _randomWords[0]; // Set array-index to variable, easier to play with
emit RequestFulfilled(_requestId, _randomWords);
}
// to check the request status of random number call.
function getRequestStatus(
uint256 _requestId
) external view returns (bool fulfilled, uint256[] memory randomWords) {
require(s_requests[_requestId].exists, "request not found");
RequestStatus memory request = s_requests[_requestId];
return (request.fulfilled, request.randomWords);
}
Note that you can request several random values from a single VRF request directly by updating the numWords variable in the requestRandomWords function. Additionally, depending on current testnet conditions, it might take a few minutes for the callback to return the requested random values to your contract. Navigate to vrf.chain.link to see a list of pending requests for your subscription.
At the end, the full code in game.sol should look like this:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
import "https://github.com/smartcontractkit/chainlink/blob/develop/contracts/src/v0.8/interfaces/VRFCoordinatorV2Interface.sol";
import "https://github.com/smartcontractkit/chainlink/blob/develop/contracts/src/v0.8/VRFConsumerBaseV2.sol";
import "@chainlink/contracts/src/v0.8/ConfirmedOwner.sol";
contract Game is VRFConsumerBaseV2, ConfirmedOwner {
event RequestSent(uint256 requestId, uint32 numWords);
event RequestFulfilled(uint256 requestId, uint256[] randomWords);
struct RequestStatus {
bool fulfilled; // whether the request has been successfully fulfilled
bool exists; // whether a requestId exists
uint256[] randomWords;
}
mapping(uint256 => RequestStatus)
public s_requests; /* requestId --> requestStatus */
VRFCoordinatorV2Interface COORDINATOR;
// Your subscription ID.
uint64 s_subscriptionId;
// past requests Id.
uint256[] public requestIds;
uint256 public lastRequestId;
bytes32 immutable keyHash;
address public immutable linkToken;
uint32 callbackGasLimit = 150000;
uint16 requestConfirmations = 3;
uint32 numWords = 1;
uint public randomWordsNum;
address[] public players;
uint maxPlayers;
bool public gameStarted;
uint public entryfee;
uint public gameId;
address public recentWinner;
event GameStarted(uint gameId, uint maxPlayers, uint entryfee);
event PlayerJoined(uint gameId, address player);
event GameEnded(uint gameId, address winner);
constructor(
uint64 subscriptionId,
address _linkToken
)
VRFConsumerBaseV2(0x2Ca8E0C643bDe4C2E08ab1fA0da3401AdAD7734D)
ConfirmedOwner(msg.sender)
{
COORDINATOR = VRFCoordinatorV2Interface(
0x2Ca8E0C643bDe4C2E08ab1fA0da3401AdAD7734D
);
s_subscriptionId = subscriptionId;
keyHash = 0x79d3d8832d904592c0bf9818b621522c988bb8b0c05cdc3b15aea1b6e8db0c15; // we alread set this
linkToken = _linkToken;
gameStarted = false;
}
receive() external payable {}
function startGame(uint _maxPlayers, uint _entryfee) public {
require(!gameStarted, "The Game has started");
players = new address[](0);
maxPlayers = _maxPlayers;
gameStarted = true;
entryfee = _entryfee; // entry fee in wei(18 zeros)
gameId += 1;
emit GameStarted(gameId, maxPlayers, entryfee);
}
function joinGame() public payable {
require(gameStarted, "The Game has not kicked off");
require(players.length < maxPlayers, "The Game is Filled Up!");
require(
msg.value == entryfee,
"The amount should be equal to the entry fee"
);
players.push(msg.sender);
emit PlayerJoined(gameId, msg.sender);
if (players.length == maxPlayers) {
getRandomWinner();
}
}
function getRandomWinner() internal returns (address) {
uint256 requestId = requestRandomWords();
uint256 winnerIndex = randomWordsNum % players.length;
recentWinner = players[winnerIndex];
(bool success, ) = recentWinner.call{value: address(this).balance}("");
require(success, "Could not send ether");
gameStarted = false;
emit GameEnded(gameId, recentWinner);
return recentWinner;
}
function requestRandomWords() public onlyOwner returns (uint256 requestId) {
requestId = COORDINATOR.requestRandomWords(
keyHash,
s_subscriptionId,
requestConfirmations,
callbackGasLimit,
numWords
);
s_requests[requestId] = RequestStatus({
randomWords: new uint256[](0),
exists: true,
fulfilled: false
});
requestIds.push(requestId);
lastRequestId = requestId;
emit RequestSent(requestId, numWords);
return requestId; // requestID is a uint.
}
function fulfillRandomWords(
uint256 _requestId,
uint256[] memory _randomWords
) internal override {
require(s_requests[_requestId].exists, "request not found");
s_requests[_requestId].fulfilled = true;
s_requests[_requestId].randomWords = _randomWords;
randomWordsNum = _randomWords[0]; // Set array-index to variable, easier to play with
emit RequestFulfilled(_requestId, _randomWords);
}
// to check the request status of random number call.
function getRequestStatus(
uint256 _requestId
) external view returns (bool fulfilled, uint256[] memory randomWords) {
require(s_requests[_requestId].exists, "request not found");
RequestStatus memory request = s_requests[_requestId];
return (request.fulfilled, request.randomWords);
}
}
Setting Up Your Chainlink VRF Subscription
Now that the Game contract is created, we can move on to creating a Chainlink subscription. However, before we do that, ensure you have some ETH on Goerli testnet. You can get some at the QuickNode Faucet.
Then, go to the Chainlink Subscription page to create a subscription. Connect your Metamask wallet and then click the Create Subscription button. The last step is signing the transaction in your MetaMask wallet (make sure you're on Goerli testnet).
The next stage is funding your Chainlink subscription. Click on Add Funds and add 10 LINK tokens. When you are done, your dashboard should appear like this:
Creating a Participating Contract
We will need to write and deploy a contract to act as a lottery participant. The main logic of this contract is to deposit and call the joinGame function.
Create a file called GameParticipant.sol, and then add the following code to the file:
pragma solidity 0.8.0;
contract joinGameContract {
constructor() payable {}
receive() external payable {}
function joinGame(address _addr) public payable {
(bool success, ) = _addr.call{value: address(this).balance}(
abi.encodeWithSignature("joinGame()")
);
require(success, "Tx failed send");
}
}
This contract calls the joinGame function, makes a transfer call, and contains a receive fallback function to receive ether.
Testing the Lottery Contract
At this juncture, we will kick off by deploying the lottery contract (e.g., game.sol), but before we can do that, we must first compile the smart contracts. On the Solidity Compiler tab, select the game.sol file and click the Compile button. Then, do the same for GameParticipant.sol.
Note if the Auto compile option is enabled, you don't need to compile your contracts again.
Then, once your contracts are compiled and ready to deploy, navigate to the DEPLOY & RUN TRANSACTIONS on Remix.IDE and select the Injected Provider option on the Environment dropdown. Then, select the Game contract (e.g., game.sol) in the Contract dropdown.
You must input the subscription ID found on your Chainlink Subscription dashboard into the constructor arguments on Remix.IDE, along with the LINK token address: 0x326C977E6efc84E512bB9C30f76E30c160eD06FB
Once deployed, you must add the contract address as a consumer and sign the transaction in your MetaMask wallet.
The next step is to interact with the startGame function. Set the number of players to 2 and set the entry fee to 1 wei. You will need to sign this transaction in your MetaMask wallet.
Next, we'll need to deploy the joinGameContract contract (i.e., GameParticipant.sol). Select the contract in Remix.IDE, then navigate to the DEPLOY & RUN TRANSACTIONS tab to deploy the contract.
After you sign the transaction and deploy the contract, input 1 wei in the value field on Remix.IDE, then call the joinGame function. You will need to pass in the contract address of the game.sol contract as an argument and sign the transaction in MetaMask.
After the GameParticipant contract has joined the lottery, go back to the game.sol contract and call the joinGame function, passing along 1 wei with your transaction.
Once the max players have joined, the contract will make a request to Chainlink VRF to retrieve a random number and return the winner's address. To see the winner, click the recentWinner variable to read its state.
Conclusion
Congratulations! You just learned about Chainlink VRF and how to request a random number from your smart contract. Chainlink represents a significant advancement for blockchain technology because it gives developers access to a provably fair random number generator.
The use cases of Chainlink VRF are beyond getting a random winner in a lottery, as we did in this article; you can utilize it to generate randomness in any contract you might be building in the future.
We'd love to see what you're creating! Please share your ideas with us on Discord or Twitter. If you have any feedback or questions on this guide, we'd love to hear from you!