In this tutorial, we’ll create a simple escrow smart contract, test it, and deploy it on a Testnet using Hardhat.
If you’re relatively new to the blockchain, no worries. First, we’ll review some of the fundamentals of Solidity and Hardhat before programming our smart contract step-by-step. At the end of this tutorial, you should be able to recreate an escrow smart contract with Solidity and Hardhat. Let’s get started!
A smart contract is a simple program that executes transactions on a blockchain by following predefined rules set by the author. Ethereum’s smart contracts use a specified programming language, Solidity. Solidity is an object-oriented programming language built solely for running smart contracts on the Ethereum Virtual Machine (EVM), with syntax similar to other programming languages C++, Python, and JavaScript.
Solidity compiles your smart contract into a sequence of bytecodes before deploying it in the Ethereum Virtual Machine. Each smart contract has its address. To call a specific function, you need an Application Binary Interface (ABI) to specify the function you want to execute and return a format you’re expecting.
Creating smart contracts requires a development environment for testing and deploying the contract on the Testnet. There are a lot of alternatives to choose from, like Truffle and its Ganache suite or Remix, the Solidity IDE. But there is a third alternative, Hardhat.
Hardhat is a Solidity development environment built using Node.js. It first released its Beta version in 2019 and has grown ever since. With Hardhat, developers don’t need to leave the JavaScript and Node.js environment to develop smart contracts, like with Truffle.
Testing smart contracts built with Hardhat is also easy since Hardhat has a plug-and-play environment and doesn’t require you to set up a personal Ethereum network to test your smart contracts. To connect to the smart contract, you use Ethers.js, and to test them, you can use well known JavaScript testing libraries like Chai.
Installing Hardhat is simple. You’ll need to install npm and Node.js v12. Assuming you use Linux, you need to run the following commands:
sudo apt update curl -sL https://deb.nodesource.com/setup_12.x | sudo bash - sudo apt install nodejs
Then, to install npm, run the code below:
sudo apt install npm
After installing npm, you can install Hardhat. Hardhat is based on Node.js and can only be installed using npm. Create a new directory and initiate your Node.js project:
mkdir hardhat-example cd hardhat-example npm init -y
Then, install Hardhat as a dev dependency:
npm i --save-dev hardhat
To initiate a Hardhat project, you’ll need a hardhat.config.js
file. You can autogenerate it using the command below:
npx hardhat
Create an empty hardhat.config.js
. Your Hardhat environment is almost ready. You only need to install the other dependencies:
npm i --save-dev @nomiclabs/hardhat-ethers ethers chai
Hardhat uses Ethers.js to connect to the smart contract and Chai as the assertion library. Open your hardhat.config.js
and add the code below:
require("@nomiclabs/hardhat-ethers"); module.exports = { solidity: "0.8.4" };
And voila! You’ve created your Solidity development environment. The smart contract in this tutorial will use Solidity version 0.8.4.
In this example, you’ll make a simple escrow smart contract, similar to Tornado Cash. Each user who executes the smart contract will deposit a number of tokens to the smart contract, and the smart contract will return a hash. You can use the hash to withdraw the tokens into a different account.
The tutorial will use Open Zeppelin smart contracts. Open Zeppelin provides a library of secure smart contracts vetted by the community. To use Open Zeppelin smart contracts, install their library in your project with npm:
npm i -S @openzeppelin/contracts
Open Zeppelin has implementation standards for both the ERC20 and ERC721 tokens. In this example, you’ll use the ERC20 standard.
Your smart contract will use the DAI cryptocurrency, but you need to create a mocked DAI token to test your local node. We’ll create the smart contract template for the token and escrow smart contract.
First, make a new contracts directory and create a file named MockDaiToken.sol
:
//SPDX-License-Identifier: Unlicense pragma solidity ^0.8.0; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; contract MockDaiToken is ERC20 { constructor() ERC20("MockDaiToken", "DAI") { _mint(msg.sender, 100000000 * 10 ** decimals()); } }
Then, create another file named Escrow.sol
:
//SPDX-License-Identifier: Unlicense pragma solidity ^0.8.0; import "hardhat/console.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; contract Escrow { IERC20 public _token; constructor(address ERC20Address) { _token = IERC20(ERC20Address); } }
You can only use the MockDaiToken in local environments and testing environments. When testing on the Testnet, make sure you use the actual DAI token. The pragma Solidity version will be for Solidity versions 0.8.0
and up.
Depositing your tokens into an escrow smart contract is simple. You’ll simply transfer your funds from your wallet to the smart contract’s wallet.
Every smart contract has a wallet where you can store your funds. But, you also need to map every deposit with a unique hash. You can store the map using the mapping
type and add a deposit_count
to count how many deposits you’ve entered using the uint
type:
uint deposit_count; mapping(bytes32 => uint256) balances;
The deposit function will have two parameters, a transaction hash and transaction amount. The transaction hash will be generated from outside the function and inserted into the mapping along with the deposit amount. Because you will receive two parameters, you’ll have to validate them to ensure users don’t insert malicious inputs.
You can use the require
method to validate these three conditions:
function depositEscrow(bytes32 trx_hash, uint amount) external { // Transaction hash cannot be empty require(trx_hash[0] != 0, "Transaction hash cannot be empty!"); // Escrow amount cannot be equal to 0 require(amount != 0, "Escrow amount cannot be equal to 0."); // Transaction hash is already in use require(balances[trx_hash] == 0, "Unique hash conflict, hash is already in use."); // Transfer ERC20 token from sender to this contract require(_token.transferFrom(msg.sender, address(this), amount), "Transfer to escrow failed!"); balances[trx_hash] = amount; deposit_count++; }
After the inputs are successfully validated, insert them into the mapping
and increment the deposit count. Next, create a view
function that generates a unique hash based on the sender’s address, deposit amount, and the existing number of deposits:
function getHash(uint amount) public view returns(bytes32 result){ return keccak256(abi.encodePacked(msg.sender, deposit_count, amount)); }
Creating a view
function and calling it externally rather than internally within the deposit function will reduce the number of gas fees your function will need to consume. Unlike the deposit function, view
functions essentially just read the blockchain in its current state without changing it.
To withdraw your funds from the escrow, you need to create a separate function that accepts the transaction hash parameter. The function assumes that you will withdraw the entirety of the deposited escrow and cannot be used for a partial withdrawal. You’ll need to validate two conditions:
After which, you can transfer the funds to the sender’s address and set the mapped balance to zero:
function withdrawalEscrow(bytes32 trx_hash) external { // Transaction hash cannot be empty require(trx_hash[0] != 0, "Transaction hash cannot be empty!"); // Check if trx_hash exists in balances require(balances[trx_hash] != 0, "Escrow with transaction hash doesn't exist."); // Transfer escrow to sender require(_token.transfer(msg.sender, balances[trx_hash]), "Escrow retrieval failed!"); // If all is done, status is amounted to 0 balances[trx_hash] = 0; }
If you’ve followed the tutorial correctly, your smart contract will look like the following:
//SPDX-License-Identifier: Unlicense pragma solidity ^0.8.0; import "hardhat/console.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; contract Escrow { IERC20 public _token; constructor(address ERC20Address) { deposit_count = 0; _token = IERC20(ERC20Address); } uint deposit_count; mapping(bytes32 => uint256) balances; function getHash(uint amount) public view returns(bytes32 result){ return keccak256(abi.encodePacked(msg.sender, deposit_count, amount)); } function withdrawalEscrow(bytes32 trx_hash) external { // Transaction hash cannot be empty require(trx_hash[0] != 0, "Transaction hash cannot be empty!"); // Check if trx_hash exists in balances require(balances[trx_hash] != 0, "Escrow with transaction hash doesn't exist."); // Transfer escrow to sender require(_token.transfer(msg.sender, balances[trx_hash]), "Escrow retrieval failed!"); // If all is done, status is amounted to 0 balances[trx_hash] = 0; } function depositEscrow(bytes32 trx_hash, uint amount) external { // Transaction hash cannot be empty require(trx_hash[0] != 0, "Transaction hash cannot be empty!"); // Escrow amount cannot be equal to 0 require(amount != 0, "Escrow amount cannot be equal to 0."); // Transaction hash is already in use require(balances[trx_hash] == 0, "Unique hash conflict, hash is already in use."); // Transfer ERC20 token from sender to this contract require(_token.transferFrom(msg.sender, address(this), amount), "Transfer to escrow failed!"); balances[trx_hash] = amount; deposit_count++; } }
Next, you’ll need to test your smart contract using Chai.
One of the biggest advantages of using Hardhat is how easy the testing suite is. If you’re already familiar with JavaScript tests, you can quickly adapt to Hardhat’s testing, especially if you use Chai regularly.
Before starting the tests and deploying the escrow smart contract, you need to initiate the MockDaiToken smart contract. The escrow smart contract has a dependency on the ERC20 token address:
const { expect } = require('chai'); describe('Escrow', function () { let contract; let erc20; let happyPathAccount; let unhappyPathAccount; const amount = ethers.utils.parseUnits("10.0"); before(async function () { /** * Deploy ERC20 token * */ const ERC20Contract = await ethers.getContractFactory("MockDaiToken"); erc20 = await ERC20Contract.deploy(); await erc20.deployed() /** * Get test accounts * */ const accounts = await hre.ethers.getSigners(); deployer = accounts[0]; happyPathAccount = accounts[1]; unhappyPathAccount = accounts[2]; /** * Transfer some ERC20s to happyPathAccount * */ const transferTx = await erc20.transfer(happyPathAccount.address, "80000000000000000000"); await transferTx.wait(); /** * Deploy Escrow Contract * * - Add ERC20 address to the constructor * - Add escrow admin wallet address to the constructor * */ const EscrowContract = await ethers.getContractFactory("Escrow"); contract = await EscrowContract.deploy(erc20.address); await contract.deployed(); /** * Seed ERC20 allowance * */ const erc20WithSigner = erc20.connect(happyPathAccount); const approveTx = await erc20WithSigner.approve(contract.address, "90000000000000000000"); await approveTx.wait(); }); })
In software testing, there is something called happy path and unhappy path. The happy path is when you test the successful scenarios of the software, while the unhappy path is when you test each exception that can arise from the software.
There will be two functions that need to be tested, withdraw escrow and deposit escrow. Each function will have one happy path when the transaction succeeds. However, a good rule of thumb to determine the number of unhappy paths is to count the number of validations your parameter has to pass.
There will be two validations in the case of the withdrawal escrow function. Thus, it has two unhappy paths:
For the deposit escrow function, there will be three validations. But, depositing requires you to use a number of your tokens, and there is a possibility that you input more tokens in the amount parameter than you have. Therefore, you have to add one more validation test so the function has four unhappy paths:
You can write your unit tests after defining the happy and unhappy paths. First, write the happy path, which will be the easiest.
In the configuration stage, you already defined an account for happy path and unhappy path tests, you can use them accordingly:
it("Happy Path: depositEscrow", async function () { const contractWithSigner = contract.connect(happyPathAccount); const trxHash = await contract.getHash(amount); const depositEscrowTx = await contractWithSigner.depositEscrow(trxHash, amount); await depositEscrowTx.wait(); expect( (await erc20.balanceOf(happyPathAccount.address)).toString() ).to.equal("70000000000000000000"); });
The tests will use Ethers.js to interface with the smart contract and use Chai as an assertion library.
Next, increase your tests’ coverage by implementing the unhappy path. You need to test four unhappy paths:
it("Unhappy Path: depositEscrow - Transaction hash cannot be empty!", async function () { const contractWithSigner = contract.connect(unhappyPathAccount); let err = ""; try { await contractWithSigner.depositEscrow(ethers.constants.HashZero, amount) } catch(e) { err = e.message; } expect(err).to.equal("VM Exception while processing transaction: reverted with reason string 'Transaction hash cannot be empty!'"); });
To simulate an empty hash, you can use ethers.constants.HashZero
:
it("Unhappy Path: depositEscrow - Escrow amount cannot be equal to 0.", async function () { const contractWithSigner = contract.connect(unhappyPathAccount); const trxHash = await contract.getHash(amount); let err = ""; try { await contractWithSigner.depositEscrow(trxHash, ethers.utils.parseUnits("0")) } catch(e) { err = e.message; } expect(err).to.equal("VM Exception while processing transaction: reverted with reason string 'Escrow amount cannot be equal to 0.'"); });
Simulating a zero amount equates to ethers.utils.parseUnits("0")
:
it("Unhappy Path: depositEscrow - Unique hash conflict, hash is already in use.", async function () { const contractWithSigner = contract.connect(happyPathAccount); const trxHash = await contract.getHash(amount); const depositEscrowTx = await contractWithSigner.depositEscrow(trxHash, amount); await depositEscrowTx.wait(); expect( (await erc20.balanceOf(happyPathAccount.address)).toString() ).to.equal("60000000000000000000"); let err = ""; try { await contractWithSigner.depositEscrow(trxHash, amount) } catch(e) { err = e.message; } expect(err).to.equal("VM Exception while processing transaction: reverted with reason string 'Unique hash conflict, the hash is already in use.'"); });
In the next test, you can use the happyPathAccount
because you will be simulating if a transaction hash already exists inside the mapping:
it("Unhappy Path: depositEscrow - ERC20: insufficient allowance", async function () { const contractWithSigner = contract.connect(unhappyPathAccount); const trxHash = await contract.getHash(amount); let err = ""; try { await contractWithSigner.depositEscrow(trxHash, amount) } catch(e) { err = e.message; } expect(err).to.equal("VM Exception while processing transaction: reverted with reason string 'ERC20: insufficient allowance'"); });
Finally, even if everything passes, you still need to have a sufficient amount of allowance.
Now, we’ll repeat it with the withdrawal function.
Before you can test the happy path of the withdrawal function, you need to call the deposit function too:
it("Happy Path: withdrawalEscrow", async function () { const contractWithSigner = contract.connect(happyPathAccount); const trxHash = await contract.getHash(amount); const submitEscrowTx = await contractWithSigner.submitEscrow(trxHash, amount); await submitEscrowTx.wait(); expect( (await erc20.balanceOf(happyPathAccount.address)).toString() ).to.equal("50000000000000000000"); const withdrawalEscrowTx = await contractWithSigner.withdrawalEscrow(trxHash); await withdrawalEscrowTx.wait(); expect( (await erc20.balanceOf(happyPathAccount.address)).toString() ).to.equal("60000000000000000000"); });
You need to test two unhappy paths for the withdrawal function:
it("Unhappy Path: withdrawalEscrow - Transaction hash cannot be empty!", async function () { const contractWithSigner = contract.connect(unhappyPathAccount); let err = ""; try { await contractWithSigner.withdrawalEscrow(ethers.constants.HashZero) } catch(e) { err = e.message; } expect(err).to.equal("VM Exception while processing transaction: reverted with reason string 'Transaction hash cannot be empty!'"); }); it("Unhappy Path: withdrawalEscrow - Escrow with transaction hash doesn't exist.", async function () { const contractWithSigner = contract.connect(happyPathAccount); const trxHash = await contract.getHash(ethers.utils.parseUnits("1.0")); let err = ""; try { await contractWithSigner.withdrawalEscrow(trxHash); } catch(e) { err = e.message; } expect(err).to.equal("VM Exception while processing transaction: reverted with reason string 'Escrow with transaction hash doesn't exist.'"); });
Hardhat gives you a straightforward interface that you can use to deploy your smart contracts.
You can deploy your smart contract to any Ethereum Testnet, including the Ropsten, Kovan, Goerli, and Rinkeby Testnets. Each Testnet has a different RPC connection, and you wouldn’t want to hardcode them one by one.
You can add the connection details inside the hardhat.config.js
:
require("@nomiclabs/hardhat-ethers"); require('dotenv').config(); /** * @type import('hardhat/config').HardhatUserConfig */ module.exports = { solidity: "0.8.4", networks: { rinkeby: { url: process.env.RINKEBY_RPC_URL, chainId: 4, accounts: [process.env.RINKEBY_DEPLOYER_PRIVATE_KEY] }, } };
Ideally, you want to contain the RPC URL and the deployer private keys inside your environment variable. You can create a new Ethereum wallet with private keys.
In this example, you’ll deploy your smart contract in your local Testnet and the Rinkeby Testnet.
To access the environment variables in JavaScript, you can use the dotenv
npm package to use a .env
file instead of hardcoding them. Install dotenv with the command below:
npm i -S dotenv
dotenv
is installed as a dependency and not as a dev dependency because you will use it outside the dev environments.
First, you need to define your deployment stage. Create a new .maintain
directory and make a new deployment.js
file:
const hre = require('hardhat'); require('dotenv').config(); async function main() { // Insert your deployment script here } // We recommend this pattern to be able to use // async/await everywhere and properly handle errors. main() .then(() => process.exit(0)) .catch((error) => { console.error(error); process.exit(1); });
You need to get the Hardhat parameters before deployment:
const networkName = hre.network.name; const networkUrl = hre.network.config.url; console.log('Deploying to network', networkName, networkUrl);
After retrieving your targeted network name and RPC URL, continue to deploy your smart contracts.
The MockDaiToken will only be deployed if you are deploying to a local Testnet. Otherwise, you want to use the actual DAI token:
let DAITokenAddress = process.env[`${networkName.toUpperCase()}_NETWORK_DAI_TOKEN_ADDRESS`]; // If deploying to localhost, (for dev/testing purposes) need to deploy own ERC20 if (networkName == 'localhost') { const ERC20Contract = await hre.ethers.getContractFactory("MockDaiToken"); erc20 = await ERC20Contract.deploy(); await erc20.deployed() DAITokenAddress = erc20.address }
Next, deploy your escrow smart contract. The escrow smart contract accepts an ERC20 token address in its constructor. You’ll need to supply the DAITokenAddress for the target network:
const EscrowContract = await hre.ethers.getContractFactory("Escrow"); const escrowContract = await EscrowContract.deploy(DAITokenAddress) await escrowContract.deployed(); console.log('Contracts deployed!'); if (networkName == 'localhost') { console.log('Deployed ERC20 contract address', erc20.address) } console.log('Deployed Escrow Contract address', escrowContract.address);
Your deployment script is finished! You can deploy your escrow smart contract.
You can easily start a local Ethereum network by running the following code:
npx hardhat node
The command above will start a new Ethereum RPC server locally on port 8545
. You can create a frontend app and connect to your local RPC server using Metamask.
Deploying your smart contracts locally or on a Testnet like Rinkeby is very similar. You can define which network you want to deploy your smart contract to using the --network
flag. If you want to deploy to the local network, the command is below:
npx hardhat run .maintain/deployment.js --network localhost
Otherwise, if you want to deploy on the Rinkeby Testnet:
npx hardhat run .maintain/deployment.js --network rinkeby
If everything is successful, it will return something like this:
> [email protected] deploy:rinkeby /home/user/hardhat > npx hardhat run --network rinkeby .maintain/deployment.js Deploying to network rinkeby https://rinkeby.infura.io/v3/3656fc820asc4e72bf3jdk1948480640 Contracts deployed! Deployed Escrow Contract address 0xF6C2123ba061eE544A6363836155FD6af86D7C08
Congratulations, you have deployed your escrow smart contract!
In this article, you learned how to use Hardhat to develop, test, and deploy an Ethereum smart contract. Next, you can go even deeper by learning to connect your frontend applications to the smart contract from the browser.
I hope you enjoyed this article! Be sure to leave a comment if you have any questions. Happy coding!
LogRocket is like a DVR for web and mobile apps, recording everything that happens in your web app or site. Instead of guessing why problems happen, you can aggregate and report on key frontend performance metrics, replay user sessions along with application state, log network requests, and automatically surface all errors.
Modernize how you debug web and mobile apps — Start monitoring for free.
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowJavaScript’s Date API has many limitations. Explore alternative libraries like Moment.js, date-fns, and the new Temporal API.
Explore use cases for using npm vs. npx such as long-term dependency management or temporary tasks and running packages on the fly.
Validating and auditing AI-generated code reduces code errors and ensures that code is compliant.
Build a real-time image background remover in Vue using Transformers.js and WebGPU for client-side processing with privacy and efficiency.