How to Double-Check Smart Contract State Changes with Hardhat Tasks

Using Hardhat to create defined tasks so you can deploy smart contracts is a no-brainer. But this practice can be a lifesaver as it can help you verify if the way you define a Hardhat task is 100% the equivalent of the way your task is saved on the blockchain. 

Read below for a clear example of how you can do this fact-checking.

To effectively follow all the steps from this post, I advise you to use this self-explanatory Hardhat template, so you can start creating a Hardhat project.

How to Create a Task in Hardhat?

Hardhat tasks parse the value that you provide for each parameter. Then, it handles the type validation and converts the values into a specific type of your choice.

Let’s start with a simple example of creating a task:

  1. create the file my_task.ts inside the tasks/deploys
  2. add the task code
task("hello").setAction(async function ({}, { ethers }) {
     console.log("Hello World");
});
  1. import the task file to task/index.js

You can run it by writing the following command in the terminal:

npx hardhat hello

You’ll get this result:

~Documents/playground git: (main) (1.109s)
npx hardhat Hello
Hello World

What’s The Main Purpose of Building Tasks?

The most common scenario to write a task is to deploy a contract. You can find an example on tasks/deploy/greeter.ts

import type { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers";
import { task } from "hardhat/config";
import type { TaskArguments } from "hardhat/types";

import type { Greeter } from "../../types/Greeter";
import type { Greeter__factory } from "../../types/factories/Greeter__factory";

task("deploy:Greeter")
  .addParam("greeting", "Say hello, be nice")
  .setAction(async function (taskArguments: TaskArguments, { ethers }) {
    const signers: SignerWithAddress[] = await ethers.getSigners();
    const greeterFactory: Greeter__factory = <Greeter__factory>await ethers.getContractFactory("Greeter");
    const greeter: Greeter = <Greeter>await greeterFactory.connect(signers[0]).deploy(taskArguments.greeting);
    await greeter.deployed();
    console.log("Greeter deployed to: ", greeter.address);
  });

To run this task, make sure to compile first, so you can generate the TypeScript bindings for smart contracts:

npx hardhat compile

If you have trouble with this command because of the imports from types, you can try the following two options:

  1. comment the file with the imports and try again npx hardhat compile
  2. run the command below:
yarn run typechain

Also, you can try these life-hacks commands when you run npx hardhat clean to clear the cache.

Now let’s go back to the script and see each line what it does:

  1. const signers: SignerWithAddress[] = await ethers.getSigners(); -> It gets a signer for our transactions. Include a mnemonic phrase under the networks property within the hardhat.config.ts function, so it can generate multiple accounts. If you want a predefined account, you have to fill in the private key. 

Use .env file for private key, never write it in your code!

  1. const greeterFactory: Greeter__factory = <Greeter__factory>await ethers.getContractFactory(“Greeter”); -> It will create a factory object that will deploy our contract. Be careful of typos inside the getContractFactory method because it has to be identical to the name of the contract that you want.
  2. const greeter: Greeter = <Greeter>await greeterFactory.connect(signers[0]).deploy(taskArguments.greeting); -> Deploy the contract and store the contract inside a variable
  3. await greeter.deployed(); -> await for the deploy to finish
  4. console.log(“Greeter deployed to: “, greeter.address); -> you can access the address of the deployed contract

This is one way to do it, simple and straightforward. 

There’s also a more complex method to deploy a smart contract. Check the example here.

This task not only deploys, but also saves the deployed address in a file(it’s a good idea to keep them, because it’s hard to remember one) and gets the params from a file(not from CLI). Additionally, it shows you important details before the deployment, like gas, deployer address, name of the contract, etc.(all of them requiring human interaction to be executed).

Try to use both the simple and the complex versions, and choose the one that suits you best.

When it comes to Hardhat tasks, deploying is only the beginning.

Imagine you have a list of addresses, and you have to store them inside a contract in a mapping; you can make a task for that.
Or maybe your contract needs Access Control role in another contract after deploy. Just write below the deploy line and give access to it.

*Important note: as a blockchain developer, you’ll always get the amazing feeling when you make tasks that actually do something. Yet, it’s equally important to create tasks that verify if what is stored on the blockchain is what you intend to do with the initial tasks.

Here’s a clear example of how you can do just that.

Use Verify Tasks As A Safety Net

After running a task that modifies the state of a contract, always run a task that verifies that the change that happened is what you expect it to be.

Let’s say that you have a task that introduces 1,000 users in your contract with their initial balance. Make a task that verifies that all the entries are the same as the one from where you uploaded. 

I highly advise you to do that even if you think it’s redundant to make tasks that verify that the total amount inserted in the new contract is equal to the sum of the old entries.

Here’s a real-life scenario I faced and realized “the redundant task” was extremely helpful.

I had to insert data from an Excel sheet into a smart contract. I found out that Excel estimated the 9th decimal, so the sum in the Excel file was different from the one in the contract, even if the entries were the same. That may seem like a slight difference.

But when there’s users’ money at stake, there’s no room for error, and even the tiniest differences count.

Check the example below.

Firstly, we need to create a Token. It will inherit ERC20 and Ownable from Openzeppelin:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract Token is ERC20, Ownable {
    constructor(string memory _name, string memory _symbol) ERC20(_name, _symbol) {}

    function mint(address to, uint256 amount) public onlyOwner {
        _mint(to, amount);
    }

    function burn(address from, uint256 amount) public onlyOwner {
        _burn(from, amount);
    }
}

 We will create an Airdrop script that will mint our Token to the users that we have in a local csv:

task("script:Airdrop")
  .addParam("contract", "Address of Token contract")
  .setAction(async function ({ contract }, { ethers }) {
    const TokenFactory: Token__factory = (await ethers.getContractFactory("Token")) as Token__factory;
    const Token: Token = TokenFactory.attach(contract);

    // we have a csv file that contains all the addresses and their balance
    const ownersWithBalance: Map<string, BigNumber> = (await parseCsvFile()) as Map<string, BigNumber>;

    // make sure that you don't have duplicates => 2 txs with the same address as owner
    let uniqOwners: Map<string, BigNumber> = new Map<string, BigNumber>();

    for (const [owner, amount] of ownersWithBalance) {
      if (uniqOwners.get(owner) === undefined) {
        uniqOwners.set(owner, amount);
      } else {
        const prevAmount = uniqOwners.get(owner) as BigNumber;
        uniqOwners.set(owner, prevAmount);
      }
    }

    // require manual approval for starting to insert data in smart contract
    console.log("Start uploading to contract");
    const { question } = await prompt<{ question: boolean }>({
      type: "confirm",
      name: "question",
      message: "Continue?",
    });
    console.log();

    if (question === false) {
      process.exit();
    }

    for (const [owner, amount] of uniqOwners) {
      console.log(owner);
      await Token.mint(owner, amount);

      // wait for confirmation on a tx - best practice
      await waitForConfirmation(ethers);
    }
    console.log("Task Finished");
  });

 First, check the script on Airdrop. It will verify if an owner has the right balance in the contract.

task("airdrop:checkBalances")
  .addParam("contract", "Address of Token contract")
  .setAction(async function ({ contract }, { ethers }) {
    const TokenFactory: Token__factory = (await ethers.getContractFactory("Token")) as Token__factory;
    const Token: Token = TokenFactory.attach(contract);

    // we have a csv file that contains all the addresses and their balance
    const ownersWithBalance: Map<string, BigNumber> = (await parseCsvFile()) as Map<string, BigNumber>;

    // make sure that you don't have duplicates => 2 txs with the same address as owner
    let uniqOwners: Map<string, BigNumber> = new Map<string, BigNumber>();

    for (const [owner, amount] of ownersWithBalance) {
      if (uniqOwners.get(owner) === undefined) {
        uniqOwners.set(owner, amount);
      } else {
        const prevAmount = uniqOwners.get(owner) as BigNumber;
        uniqOwners.set(owner, prevAmount);
      }
    }

    for (const [owner, amount] of uniqOwners) {
      const contractAmount = await Token.balanceOf(owner);
      if (!contractAmount.eq(amount)) {
        // do not throw error, you want to see all the wrong data
        console.log(`${owner} has: ${contractAmount} instead of ${amount}`); // or print just the address and investigate later the amounts
      }
    }

    console.log("Task Finished");
  });

Check Task 2, verify if the total amount present in the contract is equal with the amount from the provided csv file:

task("airdrop:checkInsertedSupply")
  .addParam("contract", "Address of Token contract")
  .setAction(async function ({ contract }, { ethers }) {
    const TokenFactory: Token__factory = (await ethers.getContractFactory("Token")) as Token__factory;
    const Token: Token = TokenFactory.attach(contract);

    // we have a csv file that contains all the addresses and their balance
    const ownersWithBalance: Map<string, BigNumber> = (await parseCsvFile()) as Map<string, BigNumber>;

    // make sure that you don't have duplicates => 2 txs with the same address as owner
    let uniqOwners: Map<string, BigNumber> = new Map<string, BigNumber>();

    for (const [owner, amount] of ownersWithBalance) {
      if (uniqOwners.get(owner) === undefined) {
        uniqOwners.set(owner, amount);
      } else {
        const prevAmount = uniqOwners.get(owner) as BigNumber;
        uniqOwners.set(owner, prevAmount);
      }
    }
    
    // calculate the inserted amount in the contract
    let totalAmount: BigNumber = BigNumber.from(0);
    for (const [owner, amount] of uniqOwners) {
      const contractAmount = await Token.balanceOf(owner);
      totalAmount.add(contractAmount);
    }
    
    // get the total supply for additional check
    const totalSupply = await Token.totalSupply();

    // now compare if the introduced amounts are equal with off chain data
    const off_chain_total = BigNumber.from("1"); // dummy data
    if (!off_chain_total.eq(totalAmount)) {
      console.log("off-chain total: ", off_chain_total);
      console.log("inserted total: ", totalAmount);
      throw "Off-chain total amount is not equal with total inserted amount";
    }

    // we can compare also with the total supply, depending if our airdrop is the only one who changed the supply 
    if (!totalAmount.eq(totalSupply)) {
      console.log("total supply: ", totalSupply);
      console.log("inserted total: ", totalAmount);
      throw "Total supply is not equal with total inserted amount";
    }

    console.log("Task Finished");
  });

As you can see, I used the totalSupply() to check if it’s equal to ownerBalance, as it should be.

Used functions in the above examples:

const parseCsvFile = async (): Promise<Map<string, BigNumber>> => {
  let uniqOwners: Map<string, BigNumber> = new Map<string, BigNumber>();
  // here you put your custom parsing function logic
  return uniqOwners;
};


const waitForConfirmation = async (ethers: typeof Ethers & HardhatEthersHelpers) => {
  const start = await ethers.provider.getBlockNumber();
  while ((await ethers.provider.getBlockNumber()) - start < 2) {
    console.log("waiting for 5 confirmations...");
    await new Promise<void>((resolve) => setTimeout(() => resolve(), 10000));
  }
};

Conclusion

For a smart contract developer, Hardhat tasks are handy, and you should leverage their benefits as they boost your productivity by keeping the code base more organized.

As you’ve seen above, Hardhat tasks go beyond automating smart contract deployment scripts; they’re a safety net for ensuring the validity of any change you make to a smart contract.