Beginners Smart Contract Tutorial — Part 2
In Part 1 we created the functionality for our lottery dApp. In Part 2, we’ll be integrating the API3 QRNG into our contract and deploying it onto the Ethereum Goerli public testnet. Alternatively, you may use another supported chain in place of Goerli by following the same steps and substituting accordingly.
We’ll be using the free API3 QRNG to get truly random numbers into our contract. API3 QRNG uses Airnode to allow ANU to provide their service to blockchain and Web3 use cases without needing any 3rd party intermediaries.
Forking
As mentioned in Part 1, Hardhat is an EVM development environment that allows us to deploy smart contracts to EVM networks, and in this case, spin up a locally running blockchain instance for testing our contract. We can do this by configuring Hardhat to “fork” the Ethereum Goerli testnet, which will copy the state of the public network locally. We’ll need a RPC, or an endpoint that connects Hardhat to the blockchain. We’ll be using Alchemy below as our RPC provider.
1. DotEnv
We’re going to use sensitive credentials in the next steps. We’ll be using the DotEnv package to store those credentials as environment variables separately from our application code:
npm install dotenv
Next, make a .env
file at the root of your project.
Make an Alchemy account. Click the “CREATE APP” button and select the Goerli testnet from the dropdown menu, as the rest of the available testnets are deprecated or will soon be deprecated. Insert your newly-generated Goerli RPC endpoint URL to your .env
file:
RPC_URL="{PUT RPC URL HERE}"
Then add the following to the top of your hardhat.config.js
file to make values in the .env
file accessible in our code:
require("dotenv").config();
2. Configure Hardhat to use forking
By adding the following to our module.exports
in the hardhat.config.js
file, we tell Hardhat to make a copy of the Goerli network for use in local testing:
module.exports = {
solidity: "0.8.9",
networks: {
hardhat: { // Hardhat local network
chainId: 5, // Force the ChainID to be 5 (Goerli) in testing
forking: { // Configure the forking behavior
url: process.env.RPC_URL, // Using the RPC_URL from the .env
},
},
},
};
Turn contract into an Airnode Requester
As a requester, our Lottery.sol
contract will be able to make requests to an Airnode, specifically the ANU QRNG Airnode, using the Request-Response Protocol (RRP). It may be helpful to take a little time familiarize yourself if you haven't already.
1. Install dependencies
npm install @api3/airnode-protocol
2. Import the Airnode Requester into contract
At the top of Lottery.sol
, below the solidity version, import the Airnode RRP Contract from the npm registry:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.9;import "@api3/airnode-protocol/contracts/rrp/requesters/RrpRequesterV0.sol";contract Lottery is RrpRequesterV0{
We need to set the public address of the Airnode RRP contract on the chain that we’re using. We can do this in the constructor by making the RRP address an argument:
constructor(uint256 _endTime, address _airnodeRrpAddress)
RrpRequesterV0(_airnodeRrpAddress)
{
endTime = _endTime;
}
3. Test
At the top of the test/Lottery.js
file, import the Airnode protocol package:
const airnodeProtocol = require("@api3/airnode-protocol");
We need to pass the address of the RRP contract into the constructor. We can do that by adding the following to our “Deploys” test:
it("Deploys", async function () {
const Lottery = await ethers.getContractFactory("Lottery");
accounts = await ethers.getSigners();
nextWeek = Math.floor(Date.now() / 1000) + 604800; // Get the chainId we are using in Hardhat
let { chainId } = await ethers.provider.getNetwork(); // Get the AirnodeRrp address for the chainId
const rrpAddress = airnodeProtocol.AirnodeRrpAddresses[chainId]; lotteryContract = await Lottery.deploy(nextWeek, rrpAddress);
expect(await lotteryContract.deployed()).to.be.ok;
});
Run npx hardhat test
to try it out.
Set up Airnode
1. Parameters
Let’s add our Airnode Parameters to our contract. In Lottery.sol
, add the following to the global variables:
// ANU's Airnode address
address public constant airnodeAddress = 0x9d3C147cA16DB954873A498e0af5852AB39139f2; // ANU's uint256 endpointId
bytes32 public constant endpointId = 0xfb6d017bb87991b7495f563db3c8cf59ff87b09781947bb1e417006ad7f55a78; // We'll store the sponsor wallet here later
address payable public sponsorWallet;
The airnodeAddress
and endpointID
of a particular Airnode can be found in the documentation of the API provider, which in this case is API3 QRNG. We’ll set them as constant
since they shouldn’t change.
2. Set the sponsor wallet
To pay for the fulfillment of Airnode requests, normally we’ll need to sponsor the requester, our Lottery.sol
contract. In this case, if we use the contract address itself as the sponsor, it automatically sponsors itself.
We’ll need to make our contract “Ownable”. That will allow us to restrict access for setting the sponsorWallet
to the contract owner (the wallet/account that deployed the contract).
First, import the Ownable
contract at the top of Lottery.sol
:
import "@api3/airnode-protocol/contracts/rrp/requesters/RrpRequesterV0.sol";
import "@openzeppelin/contracts/access/Ownable.sol";contract Lottery is RrpRequesterV0, Ownable {
Then we can make our setSponsorWallet
function and attach the onlyOwner
modifier to restrict access:
function setSponsorWallet(address payable _sponsorWallet)
external onlyOwner {
sponsorWallet = _sponsorWallet;
}
3. Test
We’ll be deriving our sponsorWallet
used for funding Airnode transactions using functions from the @api3/airnode-admin
package/CLI tool.
npm install @api3/airnode-admin
Then we can import it into our tests/Lottery.js
file:
const airnodeAdmin = require("@api3/airnode-admin");
We’ll hardcode the ANU QRNG’s Xpub and Airnode Address to derive our sponsorWalletAddress
. Add the following test underneath the "Deploys" test:
it("Sets sponsor wallet", async function () {
const anuXpub = "xpub6DXSDTZBd4aPVXnv6Q3SmnGUweFv6j24SK7
7W4qrSFuhGgi666awUiXakjXruUSCDQhhctVG7AQt67gMdaRAsDnDXv23bBRKsMWvRzo6kbf"
const anuAirnodeAddress =
"0x9d3C147cA16DB954873A498e0af5852AB39139f2"
const sponsorWalletAddress = await
airnodeAdmin.deriveSponsorWalletAddress(
anuXpub,
anuAirnodeAddress,
lotteryContract.address // used as the sponsor
);
await expect(lotteryContract.connect(accounts[1])
.setSponsorWallet(sponsorWalletAddress)).to.be.reverted;
await lotteryContract.setSponsorWallet(sponsorWalletAddress); expect(await lotteryContract.sponsorWallet())
.to.equal(sponsorWalletAddress);
});
run npx hardhat test
to test your code.
Write request function
In the Lottery.sol
contract, add the following function:
function getWinningNumber() external payable {
// require(block.timestamp > endTime, "Lottery has not ended");
require(msg.value >= 0.01 ether, "Please top up sponsor wallet");
bytes32 requestId = airnodeRrp.makeFullRequest(
airnodeAddress,
endpointId,
address(this), // Sponsor
sponsorWallet,
address(this), // Return the response to this contract
this.closeWeek.selector, // Fulfill function
"" // No params
);
pendingRequestIds[requestId] = true;
emit RequestedRandomNumber(requestId);
sponsorWallet.call{value: msg.value}("");
}
We’ll leave line 2 commented out for ease of testing. Line 3 we require users send some extra gas to the sponsor wallet so that Airnode has the gas to call our fulfill function, in this case closeWeek
.
We put our request params into the makeFullRequest
function and receive a requestId
. In line 13, we map the request ID to a boolean signifying the status of the request. Then we emit an event for a request being made. Finally, we send the funds included in the transaction to the sponsor wallet.
1. Map pending request IDs
In line 13 we are storing the requestId
in a mapping. This will allow us to check whether or not the request is pending. Let's add the following under our mappings:
mapping (bytes32 => bool) public pendingRequestIds;
2. Create event
In line 14 we emit an event that the request has been made and a request ID has been generated. Solidity events are logged as transactions to the EVM. We need to describe our event at the top of our contract:
contract Lottery is RrpRequesterV0, Ownable {
event RequestedRandomNumber(bytes32 indexed requestId);
Rewrite fulfill function
Let’s overwrite the closeWeek
function:
function closeWeek(bytes32 requestId, bytes calldata data)
external
onlyAirnodeRrp // Only AirnodeRrp can call this function
{ // If valid request, delete the request Id from our mapping
require(pendingRequestIds[requestId], "No such request made");
delete pendingRequestIds[requestId]; // Decode data returned by Airnode
uint256 _randomNumber = abi.decode(data, (uint256)) % MAX_NUMBER; // Emit an event that the response has been received
emit ReceivedRandomNumber(requestId, _randomNumber); // Prevent duplicate closings. If someone closed it first it
// will increment the end time and throw.
// require(block.timestamp > endTime, "Lottery is open"); // The rest we can leave unchanged
winningNumber[week] = _randomNumber;
address[] memory winners = tickets[week][_randomNumber];
week++;
endTime += 7 days;
if (winners.length > 0) {
uint256 earnings = pot / winners.length;
pot = 0;
for (uint256 i = 0; i < winners.length; i++) {
payable(winners[i]).call{value: earnings}("");
}
}
}
In the first line set the function to take in the request ID and the payload (data
) as arguments. On line 3, we add a modifier to restrict this function to only be accessible by Airnode RRP. On line 6 and 7 we handle the request ID. If the request ID is not in the pendingRequestIds
mapping, we throw an error, otherwise we delete the request ID from the pendingRequestIds
mapping.
On line 9 we decode and typecast the random number from the payload. We don’t need to import anything to use abi.decode()
. Then we use the modulus operator (%
) to ensure that the random number is between 0 and MAX_NUMBER
.
Line 14 will prevent duplicate requests from being fulfilled. If more than 1 request is made at the same time, the first one to be fulfilled will increment endTime
and the rest will revert. We’ll leave it commented out, for now, to make testing easy.
In line 11 we emit an event that the random number has been received. We need to describe our event at the top of our contract under our other event:
event ReceivedRandomNumber(bytes32 indexed requestId, uint256 randomNumber);
Hardhat-Deploy
We’ll be using Hardhat-Deploy to deploy and manage our contracts on different chains. First let’s install the hardhat-deploy
package:
1. Install
npm install -D hardhat-deploy
Then at the top of your hardhat.config.js
file add the following:
require("hardhat-deploy");
Now we can create a folder named deploy
in our root to house all of our deployment scripts. Hardhat-Deploy will run all of our deployment scripts in order each time we run npx hardhat deploy
.
2. Write deploy script
In our deploy
folder, create a file named 1_deploy.js
. We'll be using Hardhat and the Airnode Protocol package so let’s import them at the top:
const hre = require("hardhat");
const airnodeProtocol = require("@api3/airnode-protocol");
Hardhat deploy scripts should be done through a module.exports
function. We’ll use the Airnode Protocol package to retrieve the RRP Contract address needed as an argument to deploy our lottery contract. We'll use hre.getChainId()
, a function included in Hardhat-Deploy, to get the chain ID, which we'd set to 5 in hardhat.config.js
.
Finally, we’ll deploy the contract using hre.deployments
. We pass in our arguments, a "from" address, and set logging to true
.
module.exports = async () => {
const airnodeRrpAddress =
airnodeProtocol.AirnodeRrpAddresses[await hre.getChainId()];
nextWeek = Math.floor(Date.now() / 1000) + 9000; const lotteryContract = await hre.deployments.deploy("Lottery", {
args: [nextWeek, airnodeRrpAddress], // Constructor arguments
from: (await getUnnamedAccounts())[0],
log: true,
});
console.log(`Deployed Contract at ${lotteryContract.address}`);
};
Finally, let’s name our script at the bottom of our 1_deploy.js
file:
module.exports.tags = ["deploy"];
3. Test locally
Let’s try it out! We should test on a local blockchain first to make things easy. First let’s start up a local blockchain. We use the --no-deploy
flag to prevent Hardhat-Deploy from running the deployment scripts each time you spin up a local node:
npx hardhat node --no-deploy
Then, in a separate terminal, we can deploy to our chain (localhost), specified by the --network
parameter:
npx hardhat --network localhost deploy
If everything worked well, we should see a message in the console that says our contract address. We can also check the terminal running the chain for more detailed logging.
Be sure to leave your blockchain running, as we’ll be using it throughout the rest of this tutorial.
4. Set sponsor wallet on deployment
We can couple another script with our deployment script so that the setSponsorWallet
function is called after each deployment. We’ll start by creating a file in the deploy
folder called 2_set_sponsorWallet.js
.
We’ll be using the Airnode Admin package in this script. Let’s import them at the top:
const hre = require("hardhat");
const airnodeAdmin = require("@api3/airnode-admin");
Now let’s make our module.exports
function that sets the sponsor wallet. First we'll use Ethers to get a wallet. We can use hre.deployments.get
to retrieve past deployments thanks to Hardhat-Deploy. Next, we instantiate our deployed contract within our script. Our deployed contract is ready to be interacted with!
Let’s derive our sponsor wallet so that we can pass it into our setSponsorWallet
function. We’ll follow the same steps we used to set the sponsor wallet in our tests earlier. We’ll hardcode the xpub and ANU Airnode address in lines 11 and 12.
Now let’s add a Hardhat-Deploy tag to our script so that it runs after each deployment:
module.exports.tags = ["setup"];
Let’s try it out!
npx hardhat --network localhost deploy
Live testing!
In this step, we’ll be testing our contract by deploying it to a live testnet blockchain, allowing others to interact with our contract. This will allow our random number requests to be answered by the ANU QRNG Airnode.
1. Enter script
We need to write a script that will connect to our deployed contract and enter the lottery. A lot of the code in these steps will be similar to the tests we wrote earlier. We’ll start by creating a file in the scripts
folder named enter.js
. If you look inside of the boilerplate deploy.js
file, you'll see Hardhat recommends a format for scripts:
const hre = require("hardhat"); async function main() {
// Your script logic here...
}main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
Inside the main
funtion, we can put our "enter" script:
const [account] = await hre.ethers.getSigners();// Get "Lottery" contract deployed via Hardhat-Deploy and connect
// the account to it
const Lottery = await hre.deployments.get("Lottery");
const lotteryContract = new hre.ethers.Contract(
Lottery.address,
Lottery.abi,
account
);const ticketPrice = await lotteryContract.ticketPrice();
const guess = 55; // The number we choose for our lottery entryconst tx = await lotteryContract.enter(
guess,
{ value: ticketPrice } // Include the ticket price
);await tx.wait(); // Wait for the transaction to be mined
const entries = await lotteryContract.getEntriesForNumber(guess, 1);
console.log(`Guesses for ${guess}: ${entries}`);
We can try it out by running the script against our local deployment:
npx hardhat --network localhost run scripts/enter.js
2. Close Lottery script
Next, we need a way for people to trigger Airnode for a random number when the lottery is closable. We’ll start by creating a file in the scripts
folder named close.js
and adding the boilerplate script code from the last step to it.
In our main
function, we’ll instantiate our contract again. We’ll call the getWinningNumber
function in our contract to trigger a random number request. This function emits an event that we can listen to for our request ID which we’ll use to listen for a response.
When we do hear a response, we can call winningNumber(1)
to retrieve the winning random number for week 1!
const Lottery = await hre.deployments.get("Lottery");
const lotteryContract = new hre.ethers.Contract(
Lottery.address,
Lottery.abi,
(await hre.ethers.getSigners())[0]
);console.log("Making request for random number...");// Call function and use the tx receipt to parse the requestId
const receipt = await lotteryContract.getWinningNumber({
value: ethers.utils.parseEther("0.01"),
});// Retrieve request ID from receipt
const requestId = await new Promise((resolve) =>
hre.ethers.provider.once(receipt.hash, (tx) => {
const log = tx.logs.find((log) => log.address ===
lotteryContract.address);
const parsedLog = lotteryContract.interface.parseLog(log);
resolve(parsedLog.args.requestId);
})
);console.log(`Request made! Request ID: ${requestId}`);// Wait for the fulfillment transaction to be confirmed and read
// the logs to get the random number
await new Promise((resolve) =>
hre.ethers.provider.once(
lotteryContract.filters.ReceivedRandomNumber(requestId, null),
resolve
)
);const winningNumber = await lotteryContract.winningNumber(1);
console.log("Fulfillment is confirmed!");
console.log(winningNumber.toString());
If we test this against our local chain, we should receive a request ID but no response. That’s because the ANU Airnode can’t access requests on our local chain.
npx hardhat --network localhost run scripts/close.js
You can kill the request process after the request ID is printed.
3. Set up Goerli
In this next step, we’ll be pointing Hardhat towards the Goerli testnet, which will provide a shared staging environment that mimics mainnet without using real money. This means we’ll need a wallet with some Goerli ETH funds on it. Even if you have a wallet, it is highly recommended that you create a new wallet for testing purposes.
⚠️ Never use a wallet with real funds on it for development! ⚠️
First, let’s generate the wallet. We’ll use the Airnode Admin CLI to generate a mnemonic, but feel free to create a wallet in any way you see fit.
npx @api3/airnode-admin generate-mnemonic# Output
This mnemonic is created locally on your machine using "ethers.Wallet.createRandom" under the hood.
Make sure to back it up securely, e.g., by writing it down on a piece of paper:genius session popular ... # Our mnemonic# The public address to our wallet
The Airnode address for this mnemonic is: 0x1a942424D880... # The Xpub of our wallet
The Airnode xpub for this mnemonic is: xpub6BmYykrmWHAhSFk...
We’ll be using the mnemonic and Airnode address (public address). Let’s add our mnemonic to the .env
file so that we can use it safely:
MNEMONIC="genius session popular ..."
Next, we’ll configure Hardhat to use the Goerli network and our mnemonic. Inside the networks
object in our hardhat.config.js
file, modify the module.exports
to add a network entry:
module.exports = {
solidity: "0.8.9",
networks: {
hardhat: {
chainId: 5,
forking: {
url: process.env.RPC_URL,
}
},
goerli: {
url: process.env.RPC_URL, // Reuse our Goerli RPC URL
accounts: { mnemonic: process.env.MNEMONIC } // Our mnemonic
}
}
};
Now we can run all of our commands with the added --network goerli
flag without needing to change any code!
4. Get Goerli ETH
If you attempted to run any commands against Goerli, chances are that they failed. That’s because we are using our newly generated wallet that doesn’t have the funds to pay for the transaction. We can get some free Goerli ETH for testing by using a Goerli faucet. This faucet requires an Alchemy account, and this faucet requires a Twitter or Facebook account.
We’ll paste the public address (not mnemonic!) from our wallet generation step into either or both faucets:
We can test our accounts in Hardhat by using tasks. Inside of the hardhat.config.js
file, underneath our imports and above our exports, add the following:
task(
"balance",
"Prints the balance of the first account",
async (taskArgs, hre) => {
const [account] = await hre.ethers.getSigners();
const balance = await account.getBalance();
console.log(
`${account.address}: (${hre.ethers.utils.formatEther(balance)} ETH)`);
}
);
Now we can run the balance
task and see the balance of our account:
npx hardhat --network goerli balance
If you followed the faucet steps correctly (and the faucet is currently operating), you should see the balance of our account is greater than 0 ETH. If not, you may need to wait a little bit longer or try a different faucet.
0x0EDA9399c969...: (0.5 ETH)
5. Use Lottery contract on public chain
We have everything configured to deploy onto a public chain. Let’s start with the deployment command:
npx hardhat --network goerli deploy
Keep in mind things will move much slower on the Goerli network.
Next we’ll enter our lottery:
npx hardhat --network goerli run ./scripts/enter.js
And finally, close our lottery:
npx hardhat --network goerli run ./scripts/close.js
Conclusion
This is the end of the tutorial, I hope you learned something! The complete code can be found in the tutorial repo (Feel free to drop a ⭐️). If you have any questions, please feel free to join the API3 Discord.