Agustinus Theodorus I'm a software architect, automation enthusiast, and an avid researcher. I have experience in architecting robust automated systems, and my solutions have been published by Microsoft and IEEE, among others.

How to develop Solidity smart contracts using Hardhat

12 min read 3446

Solidity Smart Contracts Hardhat

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!

Table of contents

What is Solidity?

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.

What is 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

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.

Programming your first Solidity smart contract

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.

Bootstrapping your smart contract for development

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.


More great articles from LogRocket:


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.

Escrow deposit function

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:

  1. Validate that the transaction hash is not empty
  2. Validate if the escrow amount is not equal to zero
  3. Validate if the transaction hash is not conflicting and isn’t used already
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.

Withdrawing from the escrow

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:

  1. Validate if the transaction hash is not empty
  2. Validate if the mapping for the transaction hash exists

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;
}

Final escrow smart contract file

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.

Testing your smart contract using Hardhat

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.

Configuring your tests

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();
  });
})

Planning the happy and unhappy tests

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:

  1. Validating if transaction hash is empty.
  2. Validating if transaction hash exists in the mapping.

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:

  1. Validating if transaction hash is empty.
  2. Validating if the deposit amount submitted is not zero.
  3. Validating if the transaction hash does not exist in the mapping.
  4. Validating if the sender has enough funds to deposit.

Testing the deposit function

You can write your unit tests after defining the happy and unhappy paths. First, write the happy path, which will be the easiest.

Happy path

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.

Unhappy path

Next, increase your tests’ coverage by implementing the unhappy path. You need to test four unhappy paths:

    1. Validating if transaction hash is empty
    2. Validating if the deposit amount submitted is not zero
    3. Validating if the transaction hash does not exist in the mapping
    4. Validating if the sender has enough funds to deposit
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.

Testing the withdrawal function

Now, we’ll repeat it with the withdrawal function.

Happy path

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");
});

Unhappy path

You need to test two unhappy paths for the withdrawal function:

  1. Validating if the transaction hash is empty
  2. Validating if the transaction hash exists in the mapping

 

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.'");
});

Deploying your smart contract to Testnet

Hardhat gives you a straightforward interface that you can use to deploy your smart contracts.

Configuring your Hardhat deployments

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.

Creating your deployment script

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.

Running a local Ethereum network using Hardhat

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 contract

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!

Conclusion

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!

Join organizations like Bitso and Coinsquare who use LogRocket to proactively monitor their Web3 apps

Client-side issues that impact users’ ability to activate and transact in your apps can drastically affect your bottom line. If you’re interested in monitoring UX issues, automatically surfacing JavaScript errors, and tracking slow network requests and component load time, try LogRocket.https://logrocket.com/signup/

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 — .

Agustinus Theodorus I'm a software architect, automation enthusiast, and an avid researcher. I have experience in architecting robust automated systems, and my solutions have been published by Microsoft and IEEE, among others.

Leave a Reply