Beginners Smart Contract Tutorial — Part 1
In this tutorial we’ll be walking through building and deploying a decentralized lottery smart contract in Solidity using Hardhat.
In this example use case, anyone can choose a number 1–10,000 and buy a ticket to enter into a weekly lottery. The ticket revenue is collected into a pot in the contract. After 7 days, the contract will allow anyone to trigger the drawing. The contract will then call the API3 QRNG for a truly random number generated by quantum mechanics. The pot will be split amongst all users that chose this winning number. If there are no winners, the pot will be rolled over to the next week. Once deployed, the lottery will continue to run and operate itself automatically without any controlling parties!
By the end of this tutorial you should be able to:
Deploy a decentralized lottery smart contract to the Goerli testnet that uses Quantum Randomness.
Who is this tutorial for?
Developers with a basic understanding of the Solidity and Javascript languages that would like to expand their knowledge of building with smart contracts using oracles.
In Part 1, we create a centralized lottery smart contract. In Part 2, we decentralize our lottery by integrating the API3 QRNG.
Setup
Create a folder and open it up in your preferred IDE. We prefer Visual Studio Code with the Hardhat extension.
1. Initialize a Node.js project
Create a folder for your project and open it in VSCode. In a terminal, initialize a project by running the following command:
npm init -y
2. Install Hardhat
Hardhat is a npm library with a built-in local Ethereum node that allows you to develop smart contracts. Because it will only be used for development purposes, we can install it as a development dependency:
npm install -D hardhat
3. Initialize the Hardhat project
We’ll use the Hardhat CLI to create a boilerplate Web3 project:
npx hardhat
Follow the prompts to Create a JavaScript project
and choose the default options for the rest.
When the CLI is done creating the project, you should see a few new files and directories inside of your project. Boilerplate contracts are located in the contracts
folder. Tests for that contract are located in the tests
folder. We’ll be deleting these files in the next steps so now would be a good time to look through them.
Run the test command to see the boilerplate contract in action.
npx hardhat test
When we run npx hardhat test
, Hardhat spins up a local node and tests against it before shutting down. This makes it fast and free to execute our contracts.
Writing the Smart Contract
The complete contract code can be found in the Part1 branch
1. In the contracts
folder, delete the Lock.sol
file and create a file named Lottery.sol
2. Set the solidity version, and start with an empty contract
pragma solidity ^0.8.9;contract Lottery {}
3. Add global variables to the contract
contract Lottery {
uint256 public pot = 0; // total amount of ether in the pot
uint256 public ticketPrice = 0.0001 ether; // price of a ticket
uint256 public week = 1; // current week counter
uint256 public endTime; // unix datetime lottery is closable
uint256 public constant MAX_NUMBER = 10000; // max guess
}
4. Underneath the global variables, add our error handling
error EndTimeReached(uint256 lotteryEndTime);
5. Underneath the errors, add the mappings for tickets and winning numbers
The tickets
mapping stores the list of addresses that guessed a specified number during a specified week. The winningNumber
mapping stores each weeks winning number.
mapping(uint256 => mapping(uint256 => address[])) public tickets;
mapping(uint256 => uint256) public winningNumber;
Mappings can be accessed throughout our code by using winningNumber[weekNumber]
for example.
6. Underneath the mappings, add the constructor function
When deploying the contract, we’ll need to pass in a unix timestamp of when the lottery will end. We store the _endTime
value in our endTime
global variable from step 3.
constructor(uint256 _endTime) {
endTime = _endTime;
}
7. Underneath the constructor function, add a function to buy a ticket
Users can call this function with a number 1–10,000 and a value of 0.001 ether to buy a lottery ticket.
function enter(uint256 _number) external payable {
require(_number <= MAX_NUMBER, "Number must be 1-MAX_NUMBER");
if (block.timestamp >= endTime) revert EndTimeReached(endTime);
require(msg.value == ticketPrice, "Price is 0.0001 ether");
tickets[week][_number].push(msg.sender);
pot += ticketPrice;
}
We make the function external
so that it can only be called from an external user. We also make it payable
because users will have to send funds to purchase the ticket.
On line 2 we add a require
statement to prevent users passing in a guess higher than the maximum allowed. Line 3 we throw an error in the case that the current time has passed the endTime
. Line 4 we ensure the ticket price was sent in the transaction.
On line 5 we add the user’s address (msg.sender
) to our tickets
mapping from step 5, then add the revenue from the ticket sales to the pot
.
8. Create a function to mock picking the winners
Before we decentralize our lottery, let’s mock the random number generation so that we can test the contract’s functionality. We’ll be decentralizing this function in Part 2 of this tutorial by using the API3 QRNG.
function closeWeek(uint256 _randomNumber) external {
require(block.timestamp > endTime, "Lottery has not ended");
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}("");
}
}
}
Since we’re just mocking randomness in this step, we’ll make our function take a _randomNumber
argument and make it external. In line 3 we store the “random number” in our winningNumber
mapping. In line 4 we get all of the addresses that chose the winning number and store them in memory
instead of storage
since we won’t be changing any of the data, just referencing it. Next, we increment the week
counter and endTime
.
Then, unless there are no winners this week, divide the pot amongst the winners. Line 9 we set the pot back to 0 ether before we pay out winners on line 11.
9. Create read-only function
This function will return the list of addresses that chose the given number for the given week. This will make our lives easier during the testing steps.
function getEntriesForNumber(uint256 _number, uint256 _week)
public view returns (address[] memory) {
return tickets[_week][_number];
}
We mark the function as public
so that it can be used externally and internally if necessary. We also mark it as view
because no data should be changed when calling this function.
10. Create receive
function
The receive function will be called if funds are sent to the contract. In this case, we need to add these funds to the pot.
receive() external payable {
pot += msg.value;
}
Testing the contract
If our contract is used in production, users’ real funds will be at stake. That makes thorough testing extremely important in smart contract development.
If you haven’t worked with unit tests before, I recommend you learn the basics. We’ll mainly be interacting with our contract through testing and scripts.
1. In the test folder, delete the Lock.js
file and create a file called Lottery.js
2. Import npm libraries
const { expect } = require("chai");
const { ethers } = require("hardhat");
We’ll be using the ethers
library inside of Hardhat which includes some extra capabilities.
3. Add tests
We’ll start with a simple deployment test to be sure that the contract is deploying correctly.
describe("Lottery", function () {
let lotteryContract, accounts, nextWeek; it("Deploys", async function () {
const Lottery = await ethers.getContractFactory("Lottery");
accounts = await ethers.getSigners();
nextWeek = Math.floor(Date.now() / 1000) + 604800;
lotteryContract = await Lottery.deploy(nextWeek);
expect(await lotteryContract.deployed()).to.be.ok;
});
});
On line 2, we create empty global variables to store a few values that we’ll be using in multiple tests. In our Deploys
test, we’ll get our Lottery contract factory that we’ll use to deploy new instances of our contract. We get all of our signers, or wallets that are connected to Hardhat, and store them globally as accounts
. We also store nextWeek
globally because we’ll use it later.
On line 7 we deploy the contract using .deploy()
from our contract factory. Our constructor takes in 1 argument, endTime
, which we’ll pass into .deploy()
.
We use expect().to.be.okay
to validate the boolean returned from .deployed()
is truthy.
We can use npx hardhat test
to run the test.
Let’s add a few more tests but feel free to add any/all of the relevant tests from the completed test file.
Run npx hardhat test
to try it out.
Conclusion
All of the completed code for Part 1 can be found in the Part 1 Branch of the repo.
In Part 1 of this tutorial we learned how to build and test a lottery smart contract using Hardhat. The problem is, our closeWeek
function is not secure, as it is public and can be called by anyone accessing the smart contract after the week's lottery ends. In its current state, anyone could enter the lottery and then pass their number into the closeWeek
function to steal the pot. Because a decentralized online gambling application is only as feasible as the degree to which it is fair, secure, and unexploitable, it requires a source of random number generation that is unbiased and tamper-proof.
In Part 2, we’ll be decentralizing our lottery contract and addressing the security concerns using the API3 QRNG. Any participant will still be able to call the closeWeek
function, but will not be able to provide a winning number. Instead, our contract will call the API3 QRNG to generate a truly random number that will be used to determine the winner(s). Once deployed, the lottery will continue to run and operate itself automatically without any controlling parties!