A Beginner's Guide to Testing and Interacting with a Deployed Smart Contract on Rootstock

YoungAncientYoungAncient
12 min read

Interacting with smart contracts already deployed on blockchains is a skill pivotal for every blockchain developer. Ethers is a very cool library that makes interacting with blockchain seamless for developers.

In this article, we will do the following:

  • Deploy a smart contract on Rootstock testnet using hardhat

  • Interact with the smart contract using ethers js

Glossary

EVM: This stands for Ethereum virtual machine.

EVM-compatible chains: These blockchains support the Ethereum Virtual Machine (EVM), allowing them to execute Ethereum smart contracts with little or no modification.

Transaction: This is any action on the blockchain that causes a change in the state of the blockchain.

Solidity: A turning complete programming language used to program smart contracts on EVM chains.

Deploy a smart contract on Rootstock Testnet

A smart contract is a self-executing program that encodes part or the whole of an agreement between two or more parties. Smart contracts deployed on EVM-compatible chains are written majorly in Solidity and Vyper.

Our sample contract is the DummyToken contract. It sets its owner on deployment, allows only the owner to mint to any address stated, and allows addresses to transfer Dummy.

// SPDX-License-Identifier: SEE LICENSE IN LICENSE
pragma solidity 0.8.20;

contract DummyToken {
    mapping (address => uint) public balances;
    address public owner;

    constructor() {
        owner = msg.sender;
    }
    // A modifier restricting a function access to just the owner
    modifier onlyOwner() {
        require(msg.sender == owner, "Not owner");
        _;
    }
    // Only the owner can mint
    function mintDummy(uint amount, address to) external onlyOwner {
        // cannot mint to zero address
        require(to != address(0), "Cannot mint to Zero address");
        balances[to] += amount;
    }
    // Allow transfer of dummy between addresses
    function transferDummy(uint amount, address to) external {
        require(balances[msg.sender] > amount, "Insufficient balance");
        require(to != msg.sender, "Invalid request");
        balances[msg.sender] -= amount;
        balances[to] += amount;
    }
}

Next, we write tests.

Testing a smart contract is paramount to confirming that its functionalities are intact. Other perks include security advantages.

Test scripts are usually found in the test/ directory.

Usually, we start by importing the needed modules.

import {
  loadFixture,
} from "@nomicfoundation/hardhat-toolbox/network-helpers";
import { expect } from "chai";
import hre, { ethers } from "hardhat";

loadFixture is used to reuse the same setup in every test by running the setup once, taking a snapshot of the state, and resetting the Hardhat Network to that snapshot before each test.

expect is used for assertions in tests. It allows you to check conditions, such as verifying contract behavior, returned values, or emitted events.

Next, we define the test scope

describe("Dummy Token", function () {
/// other code in here
}

We define a function that deploys the token contract using the factory artifact.

  async function deployDummyToken() {
    // Contracts are deployed using the first signer/account by default
    const [owner, account1, account2] = await hre.ethers.getSigners();
    // this gets the factory contract
    const DummyToken = await hre.ethers.getContractFactory("DummyToken");
    // this deploys the contract
    const dummyToken = await DummyToken.deploy();
    // returns entities needed in our test cases
    return { dummyToken, owner, account1, account2 };
  }

A proper test suite is shown below. The it keyword defines the test case. We then used LoadFixture to call the deployDummyToken function, which returns addresses and the token contract.

This test is a deployment test that checks if the owner variable is correctly set on deployment.

describe("Deployment", function () {
    it("Should set owner", async function () {
      const { owner, account1, account2, dummyToken } = await loadFixture(
        deployDummyToken
      );
       // expect use to check if the owner of the contract is correctly set
      expect(await dummyToken.owner()).to.equal(owner);
    });
  });

The next test suite is the ‘functionality’ suite.

The test case below checks if only the owner can mint the token.

Firstly, we get the needed entities from deployDummyToken using loadFixture. We still use the expect keyword but now with the suffix .to.be.revertedWith(“Not owner) , which expects the asynchronous call within the expect block to fail and revert with the message “Not owner”. This revert message is coming from the contract.

    it("Only owner can mint", async function () {
      const { owner, account1, account2, dummyToken } = await loadFixture(
        deployDummyToken
      );
      const amount = ethers.parseUnits("100", 18);
      await expect(
        dummyToken.connect(account1).mintDummy(amount, owner)
      ).to.be.revertedWith("Not owner");
    });

This is what the full test script looks like

import { loadFixture } from "@nomicfoundation/hardhat-toolbox/network-helpers";
import { expect } from "chai";
import hre, { ethers } from "hardhat";

describe("Dummy Token", function () {
  async function deployDummyToken() {
    // Contracts are deployed using the first signer/account by default
    // the first signer is owner
    const [owner, account1, account2] = await hre.ethers.getSigners();

    const DummyToken = await hre.ethers.getContractFactory("DummyToken");
    const dummyToken = await DummyToken.deploy();

    return { dummyToken, owner, account1, account2 };
  }

  describe("Deployment", function () {
    it("Should set owner", async function () {
      const { owner, account1, account2, dummyToken } = await loadFixture(
        deployDummyToken
      );

      expect(await dummyToken.owner()).to.equal(owner);
    });
  });
  describe("Functionality", function () {
    it("Minting should work", async function () {
      const { owner, account1, account2, dummyToken } = await loadFixture(
        deployDummyToken
      );
      const amount = ethers.parseUnits("100", 18);
      await dummyToken.mintDummy(amount, account1);

      expect(await dummyToken.balances(account1)).to.equal(amount);
    });

    it("Only owner can mint", async function () {
      const { owner, account1, account2, dummyToken } = await loadFixture(
        deployDummyToken
      );
      const amount = ethers.parseUnits("100", 18);
      await expect(
        dummyToken.connect(account1).mintDummy(amount, owner)
      ).to.be.revertedWith("Not owner");
    });

    it("Owner cannot mint to zero address", async function () {
      const { owner, account1, account2, dummyToken } = await loadFixture(
        deployDummyToken
      );
      const amount = ethers.parseUnits("100", 18);
      await expect(
        dummyToken.mintDummy(amount, ethers.ZeroAddress)
      ).to.be.revertedWith("Cannot mint to Zero address");
    });

    it("Owner cannot mint zero tokens", async function () {
      const { owner, account1, account2, dummyToken } = await loadFixture(
        deployDummyToken
      );
      const amount = ethers.parseUnits("0", 18);
      await expect(
        dummyToken.mintDummy(amount, account1)
      ).to.be.revertedWith("Cannot mint zero tokens");
    });
    it("Owner cannot mint beyond max supply", async function () {
      const { owner, account1, account2, dummyToken } = await loadFixture(
        deployDummyToken
      );
      const amount = ethers.parseUnits("2000000", 18);
      await expect(
        dummyToken.mintDummy(amount, account1)
      ).to.be.revertedWith("Cannot exceed MAX_SUPPLY");
    });

    it("Token Transfer should work", async function () {
      const { owner, account1, account2, dummyToken } = await loadFixture(
        deployDummyToken
      );
      const amount = ethers.parseUnits("1000", 18);
      await dummyToken.mintDummy(amount, owner);

      const transferAmount = ethers.parseUnits("100", 18);
      await dummyToken.transferDummy(transferAmount, account1);
      expect(await dummyToken.balances(account1)).to.equal(transferAmount);
      expect(await dummyToken.balances(owner)).to.equal(
        amount - transferAmount
      );
    });

    it("Token Transfer should fail if to is msg.sender", async function () {
      const { owner, account1, account2, dummyToken } = await loadFixture(
        deployDummyToken
      );
      const amount = ethers.parseUnits("1000", 18);
      await dummyToken.mintDummy(amount, account1);

      const transferAmount = ethers.parseUnits("100", 18);

      await expect(
        dummyToken.connect(account1).transferDummy(transferAmount, account1)
      ).to.be.revertedWith("Invalid request");
    });

    it("Token Transfer should fail if transfer amount is Zero", async function () {
      const { owner, account1, account2, dummyToken } = await loadFixture(
        deployDummyToken
      );
      const amount = ethers.parseUnits("1000", 18);
      await dummyToken.mintDummy(amount, account1);

      const transferAmount = ethers.parseUnits("0", 18);

      await expect(
        dummyToken.connect(account1).transferDummy(transferAmount, account2)
      ).to.be.revertedWith("Cannot transfer zero");
    });

    it("Token transfer should fail if sender does not have enough balance", async function () {
      const { owner, account1, account2, dummyToken } = await loadFixture(
        deployDummyToken
      );
      const amount = ethers.parseUnits("100", 18);
      await dummyToken.mintDummy(amount, account1);

      const transferAmount = ethers.parseUnits("200", 18); // More than balance

      await expect(
        dummyToken.connect(account1).transferDummy(transferAmount, account2)
      ).to.be.revertedWith("Insufficient balance");
    });
  });
});

The test above validates the functionality of the smart contract.

Writing tests for a smart contract involves understanding the contract and thinking about edge cases. With a better understanding, a developer can make better assertions and then better and safer tests.

To run the tests, you can use the command npx hardhat test , which compiles the smart contract and runs the test based on the artifacts built.

Deploy Smart contract Rootstock Testnet

We are super pumped to push our smart contract to testnet via deployment.

Step 1: Create a file called token.ts in the ignitions/modules folder. Hardhat uses this script to deploy a smart contract. An alternative way to deploy a contract is by writing the deployment script manually in a separate directory called scripts/. However, a simpler approach is to use the ignition folder.

Why Hardhat Ignition?

  • Instead of writing multiple scripts to handle deployments in different environments, it helps define everything in a structured format.

  • Traditional scripts require explicit handling of deployments, gas estimation, and verification—ignition abstracts much of this.

This does not mean traditional scripting is useless; instead, Ignition simplifies the process. As a beginner, Ignition will provide a better experience than writing scripts from scratch. However, experts may often prefer low-level control over deployments, making scripting a no-brainer in such cases.

Dive in!

At the root level of the hardhat project, there is a directory named ignition It contains two sub-directories: deployments and modules. The deployments/ directory keeps the artifacts for each distinct smart contract deployment made. On the other hand, the modules/ directory is where we write the deployment scripts.

A sample deployment script looks like this:

import { buildModule } from "@nomicfoundation/hardhat-ignition/modules";

const DummyTokenModule = buildModule("DummyTokenModule", (m) => {

  const dummyToken = m.contract("DummyToken");

  return { dummyToken };
});

export default DummyTokenModule;

Step 2: Get the Alchemy API URL for Rootstock from the dashboard. Ensure you select testnet, not mainnet.

TESTNET_ALCHEMY_KEY=<ALCHEMY_KEY>
PRIVATE_KEY=<DEPLOYER_ACCOUNT_PRIVATE_KEY>
ROOTSTOCK_TESTNET_RPC_URL=https://rootstock-testnet.g.alchemy.com/v2/<ALCHEMY_KEY>

The above represents what the deployment .env file would look like.

Step 3: Edit the hardhat.config.ts file.

import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";

require("dotenv").config();

const config: HardhatUserConfig = {
  solidity: "0.8.20",
  networks: {
    // for testnet
    rootstock: {
      url: process.env.ROOTSTOCK_TESTNET_RPC_URL!,
      accounts: [process.env.PRIVATE_KEY!],
    },
  },
  etherscan: {
    // Use "123" as a placeholder, because Blockscout doesn't need a real API key, and Hardhat will complain if this property isn't set.
    apiKey: {
      rootstock: '123',
    },
    customChains: [
      {
        network: "rootstock",
        chainId: 31,
        urls: {
          apiURL: "https://rootstock-testnet.blockscout.com/api/",
          browserURL: "https://rootstock-testnet.blockscout.com/",
        }
      },
    ],
  },
  sourcify: {
    enabled: false,
  },
};

export default config;

Step 4: Run the deployment command in the terminal.

npx hardhat ignition deploy ignition/modules/dummyToken.ts --network rootstock --deployment-id dummy-deployment

Let’s break this long chain of commands into chunks:

npx hardhat ignition deploy ignition/modules/dummyToken.ts - Invokes the deployment script in the ignitions directory based on the path specified.

--network rootstock - Specifies the network on which the contract would be deployed.

--deployment-id dummy-deployment - This gives the deployment artifact a unique identifier that could be used later for contract verification. If not specified, Hardhat by default gives an identifier like “chain-xxx”.

Step 5: Run the verify command

npx hardhat ignition verify dummy-deployment

Since we gave the deployment artifact a deployment ID, we pass it in when verifying instead of directly using the contract address and other parameters. This is the easiest way to verify a contract with Hardhat.

Check out the deployed and verified contract

For a more detailed hardhat deployment guide, check the docs.

Interact with the deployed smart contract

Set up a script file in scripts/ directly at the root of the hardhat project. Below is a sample template for an interaction script.

import { ethers } from "hardhat";

async function main() {

    // code come here
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

There are 2 types of interactions: Read calls and Write calls.

Read Calls: These calls do not involve signing transactions. Examples are: Getting historic or current data from the chain like balance and retrieving data from smart contracts.

Write Calls: These calls involve signing transactions because they change the state of the blockchain. Transferring a toke, minting a token, casting a vote, and many more.

Make a Read call to the contract to get the owner’s address.

Define a variable representing the contract address, then use ethers.getContractAt passing in the contract name and address to create the contract object. One could also use the contract interface instead of the contract name.

dummyTokenContract.owner() makes a read call to the smart contract owner function. Remember, on the smart contract, there was no “owner” function but a public variable called “owner”. By default, solidity creates a getter function for all publicly declared variables, hence, we can access the owner through the function .owner() .

import { ethers } from "hardhat";

async function main() {
    const DUMMYTOKENADDRESS = "0xd5b0e6f6F18f43e25B40dFB5c55Cc8888697fF39";
    const dummyTokenContract = await ethers.getContractAt("DummyToken",DUMMYTOKENADDRESS);
    const owner = await dummyTokenContract.owner();

    console.log(`owner -> ${owner}`);
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

To run interactions, we use the command

npx hardhat run scripts/interactions.ts --network rootstock

where scripts/interactions.ts is the path to the actual script’s location.

Make a write call to the contract to mint and transfer tokens

The contract address and the contract object remain the same.

From the contract, the mint function requires 2 parameters: amount and address.

Firstly, we define the receiver’s address as a string, and then the amount using ethers.parseUnits. This function in ethers takes a string and pads it with an N-number of zeros as specified by the user. Since we passed in 18, it pads 1000 to 1000000000000000000000.

const tx = await dummyTokenContract.mintDummy(amount, receiverAddress); tx.wait();

tx is a promise and tx.wait() ensures that the transaction is completed before proceeding to the next line of code.

Print the receiver's balance before and after the transaction to confirm the state changes.

import { ethers } from "hardhat";

async function main() {
  const DUMMYTOKENADDRESS = "0xd5b0e6f6F18f43e25B40dFB5c55Cc8888697fF39";
  const dummyTokenContract = await ethers.getContractAt(
    "DummyToken",
    DUMMYTOKENADDRESS
  );

  const receiverAddress = "0x9E8882E178BD006Ef75F6b7D3C9A9EE129eb2CA8";
  const balanceBefore = await dummyTokenContract.balances(receiverAddress);
  console.log(`receiver balance before -> ${balanceBefore}`);

  const amount = ethers.parseUnits("1000", 18);
  const tx = await dummyTokenContract.mintDummy(amount, receiverAddress);
  tx.wait();

  const balanceAfter = await dummyTokenContract.balances(receiverAddress);
  console.log(`receiver balance after -> ${balanceAfter}`);
}

// We recommend this pattern to be able to use async/await everywhere
// and properly handle errors.
main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

The Write calls are recorded as transactions and can be seen on Rootstock Block Explorer.

Alternative Approach to Scripting

Hardhat has a cool feature known as the Console. It is an interactive REPL (Read-Eval-Print Loop) for Hardhat that lets you interact with your smart contracts directly from the command line. It lets you quickly test, debug, and call functions on deployed contracts without writing a script. The Console is an interactive JavaScript environment.

This is a no-brainer if you intend to interact with or troubleshoot a deployed smart contract.

Dive in!

npx hardhat console

This command starts the Hardhat Console in the default hardhat network. We can specify the network on which we want it to run.

Run the same scripting logic used earlier, but now in shorter blocks.

  • Define variables for the deployed contract address, the receiver’s address, and the contract object.

  • Make a write call to the contract. An example here is calling the mint function.

    It can be seen that the receiver’s balance increased on minting.

The Hardhat Console is useful because it saves time and can be used for live debugging.

Conclusion

Exploring tests and interactions using Hardhat and Ethers.js has been an exciting journey. I hope this helps a newbie navigate the complexities of Hardhat testing and scripting.

To further enhance your understanding and apply these concepts in practice, consider exploring Rootstock’s documentation to start building, checking out the developer resources for extra support, and joining the Rootstock community to stay connected with other builders. Happy coding!

10
Subscribe to my newsletter

Read articles from YoungAncient directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

YoungAncient
YoungAncient

I love building powerful, friendly, and highly interactive user interfaces. I also love problem-solving and for me it is purpose. Nothing gives me joy more than building products/projects that help solve real problems and add meaningful value to businesses, people, and the world.