How to Build a Token Minting and Transfer dApp on Rootstock With Proper Error Handling Using Reown Appkit and Ethers

YoungAncientYoungAncient
39 min read

Hello world! Building a dApp is a vast and interesting topic. It involves smart contracts, frontend libraries, and Web3 integrations. This article will focus on frontend libraries and, most importantly, Web3 integrations! You need some prerequisite knowledge of smart contracts and React.

The journey to building complex dApps is rough, but here, the approach is incremental, breaking down everything you need to know to construct simple dApps and then the complexity increases.

In this article, we will do the following:

  • Deploy a special kind of token contract on the Rootstock testnet

  • Set up a template for the dApp using React and custom React hooks

  • Connect Wallet using the Reown Appkit SDK

  • Integrate the contract to the frontend using Ethers.js V6

  • Explore Error handling using ethers-decode-error .

Introduction

A dApp is short for decentralized application. Usually, for centralized apps, there’s a frontend application that connects to a backend server that reads and writes to a database. In the case of a dApp, the frontend application connects to the smart contract deployed on the blockchain. Users' interactions with the frontend app invoke functions on the smart contract, which read or write to the blockchain.

Why Rootstock?

Rootstock is a sidechain to Bitcoin. It aims to bring Ethereum-compatible smart contracts to the Bitcoin ecosystem. Compared to Ethereum, Rootstock offers several benefits for developers building on it. Here are a few:

  1. Bitcoin Security: Building dApps on Rootstock means deploying your project on a blockchain with a Bitcoin security layer.

  2. Lower Latency: Transactions are faster, and block finality is faster compared to Ethereum.

  3. Lower Gas Fee: Gas fees are relatively low. This means users pay very low transaction fees.

  4. Stable Gas Price: The native token of Rootstock is RBTC, which is a Bitcoin-pegged token. This means fewer fluctuations in gas prices, unlike Ethereum.

Prerequisites

Before beginning development, here are a few requirements to be met:

  1. Basic knowledge of solidity and smart contracts:

  2. Basic knowledge of JavaScript/TypeScript

  3. Basic knowledge of React and React hooks:

    • This article uses custom hooks to abstract and simplify contract integration logic.
  4. Fund Your Wallet with RBTC (Rootstock Bitcoin) via Faucet

    • Since Rootstock (RSK) uses RBTC (Rootstock Bitcoin) as its native currency for transaction fees, you will need some RBTC in your wallet.

    • Obtain TRBTC (Testnet RBTC) from a Rootstock Testnet Faucet to cover gas fees for deploying and interacting with smart contracts

    • Example faucet: Rootstock Testnet Faucet (Ensure you select the Testnet option).

  5. Ensure Your Wallet is Connected to the Rootstock Testnet: To interact with dApps on Rootstock in a testing environment, you need to connect your wallet to the Rootstock Testnet. This network mirrors the Rootstock mainnet but uses test tokens (tRBTC) instead of real BTC, allowing developers to test without financial risk.

    Configure MetaMask, Rabby wallet, or your preferred wallet to support the Rootstock Testnet network.

    Add Rootstock Testnet manually or via Chainlist. Add manually in MetaMask by using the following network details:

After adding the network, switch to the Rootstock Testnet and make sure your wallet has funds before continuing.

Deploying the token contract on the Rootstock testnet

Clone the Rootstock-hardhat-starterkit to get started.

Once cloning is completed, run the command npm install. This installs all the necessary packages in the package.json file.

This setup includes many useful tools such as tasks, deployment scripts, and more.

  • contracts/ - Where your smart contracts live

  • deploy/ - For deployment and interaction scripts

  • test/ - For your test files

  • hardhat.config.js - The configuration file

Create a .env file which follows the structure in the .env.example file:

RSK_MAINNET_RPC_URL='hello'
RSK_TESTNET_RPC_URL='RPC_URL'
WALLET_PRIVATE_KEY='YOUR_PRIVATE_KEY'

Create a token.sol file within the contracts/ folder.

Here is the token contract:

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

contract FreeToken {
    mapping(address => uint) public balances;
    address public owner;
    string internal name;
    string internal symbol;
    uint256 internal currentSupply;

    uint256 constant MAX_SUPPLY = 1_000_000 * 1e18;

    constructor(string _name, string _symbol) {
        owner = msg.sender;
        name = _name;
        symbol = _symbol;
    }

    // Anyone can mint
    function mintToken(uint amount, address to) external {
        // cannot mint to zero address
        require(to != address(0), "Cannot mint to Zero address");
        // cannot mint 0 tokens
        require(amount > 0, "Cannot mint 0 tokens");
        // cannot mint above max supply
        require(
            amount + currentSupply <= MAX_SUPPLY,
            "Cannot exceed max_supply"
        );
        currentSupply += amount;
        balances[to] += amount;
    }

    // Allow transfer of dummy between addresses
    function transferToken(uint amount, address to) external {
        require(amount > 0, "Cannot transfer 0 tokens");
        require(balances[msg.sender] > amount, "Insufficient balance");
        require(to != msg.sender, "Invalid request");
        balances[msg.sender] -= amount;
        balances[to] += amount;
    }

    // get token detail
    function getTokenDetail()
        external
        view
        returns (
            string memory _name,
            string memory _symbol,
            uint256 _currentSupply,
            uint256 _maxSupply
        )
    {
        return (name, symbol, currentSupply, MAX_SUPPLY);
    }
}

The program above is a smart contract with the following context:

  • Any address can mint the token

  • There is a maximum amount of tokens capping the mints

  • Zero tokens cannot be minted nor transferred

  • Regular string errors are used instead of custom errors for simplicity

  • There are 3 functions; mintToken and transferToken are write functions, while getTokenDetail is a read function.

Next, we deploy the contract on the Rootstock testnet.

Create a script DeployToken.ts in deploy/. The deployment script:

import { DeployFunction } from "hardhat-deploy/types"
import { HardhatRuntimeEnvironment } from "hardhat/types"

const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) {
    const { deployer, owner } = await hre.getNamedAccounts()

    await hre.deployments.deploy("FreeToken", {
        from: deployer,
        args: ["FreeToken", "FTK"],
        log: true,
    })
}
export default func
func.tags = ["token"]

Based on the hardhat.config file that comes with the default setup, there are two networks: rskMainnet and rskTestnet.

         rskMainnet: {
            url: RSK_MAINNET_RPC_URL,
            chainId: 30,
            gasPrice: 60000000,
            accounts: [WALLET_PRIVATE_KEY]
        },
        rskTestnet: {
            url: RSK_TESTNET_RPC_URL,
            chainId: 31,
            gasPrice: 60000000,
            accounts: [WALLET_PRIVATE_KEY]
        },

Change rskTestnet to rootstock since we wont be dealing with the mainnet.

Then run the command:

npx hardhat deploy --network rootstock --tags token

the flag tags references the tags specified in the deployment script: func.tags = ["token"]

Here’s the deployed contract verified on Rootstock testnet, with the address 0xCe93e2077F9b11F04810E62Ce1bE9e86dB4d8597 .

Interact with the deployed contract

When using Ethers.js for frontend integrations, interacting with the contract first via scripting is a good start, especially for beginners and developers integrating complex smart contracts.

In this step, an interaction script would be written, but check out this article for a more comprehensive guide.

We will use ABI to create the contract object, which will be used to call functions on the contract.

Ethers.js is a cool JavaScript library for interacting with the blockchains, mainly EVM-compatible chains like Rootstock. This article will be using version 6. Ethers provides a complete, lightweight, and user-friendly toolkit for:

  • Reading blockchain data

  • Sending transactions (e.g., token transfers, contract calls)

  • Signing messages and transactions

  • Interacting with smart contracts via ABIs

  • Converting and formatting data (e.g formatUnits, parseUnits)

What is an ABI?

ABI stands for Application Binary Interface. It provides a standardized way to interact with smart contracts. In web2, for example, the backend is a server connected to a database. To interact with the server, the frontend uses API (application program interface), which is the standard interface used to communicate with the server. ABI in the context of smart contracts is synonymous to the API of a backend server.

There are different kinds of ABIs namely:

  • JSON ABI: This is the most common format of ABI used. It’s a JSON array describing every entity in the smart contract like functions, events, the constructor and even custom errors. This can be used by any tool, not limited to hardhat, wagmi, ethers etc. This can be gotten from the artifacts of the deployed contract.

    An example is :

      [
        {
          "type": "function",
          "name": "balanceOf",
          "inputs": [{ "name": "account", "type": "address" }],
          "outputs": [{ "name": "", "type": "uint256" }],
          "stateMutability": "view"
        }
      ]
    
  • Human-Readable ABI: This is a simplified, developer-friendly string version of the ABI. It’s my favourite flavour of ABI representation because of the simplicity and ease of reading and understanding. Although, it can only be used in ethers. Getting the human-readable ABI can be tasking especially for smart contracts spanning multiple files.

    • Manually writing: Since it’s human-readable, it can be manually typed by analyzing the given contract.

    • AI: Copy the smart contract code, paste in an AI tool and prompt it to generate the human-readable ABI on the contract. After generation, cross-check to confirm. The demerits of this is that the code could be very long , and the AI could make mistakes and even go out of the context if not prompted properly.

    • Scripting: The ABI can be generated by writing scriptings that use Ethers.Interface method passing in the JSON ABI format. The demerit of this is scripting can be hard and takes time.

Generating the Human-Readable ABI is a hassle especially for very broad smart contracts, hence it is only recommended for small sized contracts like the one deployed earlier.

An example is:

    ["function balanceOf(address account) view returns (uint256)"]
  • Other kinds of ABI are Binary ABI / Encoded ABI and Solidity interface ABIs. But these are not useful within the context of frontend integration. For a much deeper exploration of ABIs, checkout this article.

Since Ethers will be used for both interaction scripts and frontend integration, we are sticking with the Human-Readable ABI, again for simplicity. The ABI for the contract deployed earlier is:

const ABI = [
  "function balances(address) view returns (uint256)",
  "function owner() view returns (address)",
  "function mintToken(uint256 amount, address to)",
  "function transferToken(uint256 amount, address to)",
  "function getTokenDetail() view returns (string _name, string _symbol, uint256 _currentSupply, uint256 _maxSupply)"
];

Using the JSON ABI would be the same process. It would be placed in a filename.json and imported within the scripts. Using either JSON ABI or Human-Readable ABI does not affect functionality or scripting.

The interaction script setup:

import { ethers } from "hardhat";

async function main() {
  const CONTRACT_ADDRESS = "0x2aD44265185a6e739b53cBF6B190b43726553627".toLowerCase();
  //   Human-readable ABI
  const ABI = [
    "function balances(address) view returns (uint256)",
    "function owner() view returns (address)",
    "function mintToken(uint256 amount, address to)",
    "function transferToken(uint256 amount, address to)",
    "function getTokenDetail() view returns (string _name, string _symbol, uint256 _currentSupply, uint256 _maxSupply)",
  ];

  //   contract object
  const tokenContract = await ethers.getContractAt(ABI, CONTRACT_ADDRESS);
  // read or write contract calls
}

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

Adding Read functions:

import { ethers } from "hardhat";

async function main() {
  const CONTRACT_ADDRESS =
    "0x2aD44265185a6e739b53cBF6B190b43726553627".toLowerCase();
  //   Human-readable ABI
  const ABI = [
    "function balances(address) view returns (uint256)",
    "function owner() view returns (address)",
    "function mintToken(uint256 amount, address to)",
    "function transferToken(uint256 amount, address to)",
    "function getTokenDetail() view returns (string _name, string _symbol, uint256 _currentSupply, uint256 _maxSupply)",
  ];

  const [signer] = await ethers.getSigners();

  //   contract object
  const tokenContract = await ethers.getContractAt(ABI, CONTRACT_ADDRESS);

  //**** Read functions ****/
  //   get balance
  const balance = await tokenContract.balances(signer.address);
  console.log("balance -> ", balance);

  //   get owner
  const owner = await tokenContract.owner();
  console.log("owner of contraft -> ", owner);

  //   get token details
  const details = await tokenContract.getTokenDetail();
  console.log({ details });

  //**** Write functions ****/
}

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

Running the script, the output:

It can be observed that the result of calling await tokenContract.getTokenDetail() is an array of values. Checking the ABI, "function getTokenDetail() view returns (string _name, string _symbol, uint256 _currentSupply, uint256 _maxSupply)", it can be seen that the function returns an array of values representing the name, symbol, currentSupply, and maxSupply. These values can easily be destructured to get the values individually:

Changing from this:

 //   get token details
  const details = await tokenContract.getTokenDetail();
  console.log({ details });

To

 //   get token details
const [name, symbol, currentSupply, maxSupply ] = await tokenContract.getTokenDetail();
  console.log({ name, symbol, currentSupply, maxSupply });

The result:

Next, write calls.

import { ethers } from "hardhat";

async function main() {
  const CONTRACT_ADDRESS =
    "0x2aD44265185a6e739b53cBF6B190b43726553627".toLowerCase();
  //   Human-readable ABI
  const ABI = [
    "function balances(address) view returns (uint256)",
    "function owner() view returns (address)",
    "function mintToken(uint256 amount, address to)",
    "function transferToken(uint256 amount, address to)",
    "function getTokenDetail() view returns (string _name, string _symbol, uint256 _currentSupply, uint256 _maxSupply)",
  ];

  const [signer] = await ethers.getSigners();

  //   contract object
  const tokenContract = await ethers.getContractAt(ABI, CONTRACT_ADDRESS);

  //**** Read functions ****/
  //   get balance
  const balance = await tokenContract.balances(signer.address);
  console.log("signer balance -> ", balance);

  //   get owner
  const owner = await tokenContract.owner();
  console.log("owner of contract -> ", owner);
  //   get token details
  const [name, symbol, currentSupply, maxSupply] =
    await tokenContract.getTokenDetail();
  console.log({ name, symbol, currentSupply, maxSupply });

  //**** Write functions ****/

  //   mint token
  const amount = ethers.parseUnits("1000", 18); 

  //xxxxxxxxxxxxxxxxxxxxxx/
  const mintTx = await tokenContract.mintToken(amount, signer.address);
  await mintTx.wait();
  //xxxxxxxxxxxxxxxxxxxxxx/

  const balAfterMinting = await tokenContract.balances(signer.address);
  console.log("signer balance after minting -> ", balAfterMinting);

  //   transfer token
  const amt = ethers.parseUnits("100", 18); // returns BigInt("100000000000000000000")
  const receiver = "0x20d8eef9687cc126b586103911a4525f5351ae11".toLowerCase();
  const receiverBalBeforeReceiving = await tokenContract.balances(receiver);
  console.log(
    "receiver balance before receiving -> ",
    receiverBalBeforeReceiving
  );

  //xxxxxxxxxxxxxxxxxxxxxx/
  const transferTx = await tokenContract.transferToken(amt, receiver);
  await transferTx.wait();
  //xxxxxxxxxxxxxxxxxxxxxx/

  const balAfterSending = await tokenContract.balances(signer.address);
  console.log("signer balance after sending -> ", balAfterSending);

  const receiverBalAfterReceiving = await tokenContract.balances(receiver);
  console.log(
    "receiver balance after receiving -> ",
    receiverBalAfterReceiving
  );
}

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

Running the script, the output:

By observation, it can be seen that after minting, the signer's balance increased, and after the transfer, the balance decreased. Similarly, the receiver’s balance increased after the transfer.

If we run the script without the minting and transfer part, we get:

The currentSupply has increased from zero.

Transitioning to the frontend

The reason for starting with interaction scripting is that it mirrors the process that will be done on the frontend, but in a more organized manner.

  • The contract address will be stored in an ENV file

       const CONTRACT_ADDRESS =
          "0x2aD44265185a6e739b53cBF6B190b43726553627".toLowerCase();
    
  • The ABI will be stored in a constants/ABI folder

      const ABI = [
          "function balances(address) view returns (uint256)",
          "function owner() view returns (address)",
          "function mintToken(uint256 amount, address to)",
          "function transferToken(uint256 amount, address to)",
          "function getTokenDetail() view returns (string _name, string _symbol, uint256 _currentSupply, uint256 _maxSupply)",
        ];
    
  • The signer won’t be handled manually, rather reown appkit will be used for managing all wallet related state, including signing transactions

      const [signer] = await ethers.getSigners();
    
  • On the frontend, there will be different components which will make use of the contract object e.g Calling the read and write functions. This will be housed in a hook.

      const tokenContract = await ethers.getContractAt(ABI, CONTRACT_ADDRESS);
    
  • The various read and write calls will be managed by two separate hooks: useReadContract and useWriteContract. Each call will be a function contained within one of these hooks.

          // get balance -> getTokenBalance function in useReadContract hook
        const balance = await tokenContract.balances(signer.address);
    
        //   get owner  -> getOwner function in useReadContract hook
        const owner = await tokenContract.owner(); 
    
        //   get token details  -> getTokenDetails function in useReadContract hook
        const [name, symbol, currentSupply, maxSupply] =
          await tokenContract.getTokenDetail(); 
    
         // -> mintToken function in useWriteContract hook
        const mintTx = await tokenContract.mintToken(amount, signer.address);
        await mintTx.wait();
    
          // -> transferToken function in useWriteContract hook
        const transferTx = await tokenContract.transferToken(amt, receiver);
        await transferTx.wait();
    

What is Reown Appkit?

This is a developer toolkit built by the Reown team to simplify dApp building on different blockchains. It is a JavaScript/TypeScript SDK designed to abstract away low-level complexities. For more, check the documentation. For the dApp to be built, we need to sign up or log in to reown platform to get the PROJECT_ID .

When authenticated, within the dashboard, there’s a create at the top right corner. Click the button, fill in the details, and it creates a project.

As shown in the image above, in my account, there are existing projects already, and on the left pane, the project ID is displayed. When the project is created, it is listed as a card.

Clicking the card opens a page:

If you did not use the scaffold repo linked in this article, you need to copy and run the installation command. Copy the project_id to the project ENV file.

VITE_APPKIT_PROJECT_ID=<YOUR_PROJECT_ID>

Setting up the scaffold

Setting this up involves creating a react project, doing file cleanups, and configuring the React app to work with reown appkit and some fundamental custom hooks. Configuring the react app requires some boilerplate code and settings based on AppKit docs. For ease of learning, a GitHub repo containing the basic scaffold was created. Clone the repo and run npm install .

This clones all the branches: frontend, main, scaffold, etc. Switch to the scaffold branch and create another branch.

The scaffold folder structure looks like:

Based on the previous section, the interaction script was splitted into chunks of code that would work together to provide the functionality needed.

VITE_ROOTSTOCK_TESTNET_EXPLORER_URL=
VITE_ROOTSTOCK_TESTNET_RPC_URL=

VITE_APPKIT_PROJECT_ID=<YOUR_PROJECT_ID>

VITE_TOKEN_CONTRACT_ADDRESS=<YOUR_CONTRACT_ADDRESS>

The ENV file contains appkit project_id for wallet connection and management, the address of the token to be interacted with token contract address , Rootstock RPC URL and EXPLORER_URL used for appkit configuration.

The ABI/token.ts contains a human-readable ABI of the contract to be interacted with.

export const TOKEN_ABI = [
  "function balances(address) view returns (uint256)",
  "function owner() view returns (address)",
  "function mintToken(uint256 amount, address to)",
  "function transferToken(uint256 amount, address to)",
  "function getTokenDetail() view returns (string _name, string _symbol, uint256 _currentSupply, uint256 _maxSupply)",
];

The constants/provider.ts exports a constant representing an instance of ethers JsonRpcProvider. This would be used in the useRunner hook.

import { JsonRpcProvider } from "ethers";

export const jsonRpcProvider = new JsonRpcProvider(
    import.meta.env.VITE_ROOTSTOCK_TESTNET_RPC_URL
);

connection.ts is the script which configures appkit SDK to work in the webapp. Note this project uses appkit v1.6.0 . Appkit is currently undergoing lots of iterations, hence using later versions like v1.7.0 or more would require slight changes in the connection script. Check reown appkit docs for a much broader perspective and this article on appkit.

import { CaipNetwork, createAppKit } from "@reown/appkit/react";
import { EthersAdapter } from "@reown/appkit-adapter-ethers";
import { rootstockTestnet as rawRootstock } from "@reown/appkit/networks";


// 1. Get projectId
const projectId = import.meta.env.VITE_APPKIT_PROJECT_ID;

// create CaipNetwork instance
export const rootstockTestnet: CaipNetwork = {
  ...rawRootstock,
  id: 31,
  chainNamespace: "eip155",
  caipNetworkId: "eip155:31",
};

// 2. Set the networks
const networks: [CaipNetwork, ...CaipNetwork[]] = [
  rootstockTestnet,
];

// 3. Create a metadata object - optional
const metadata = {
  name: "Token Minting Dapp",
  description: "A token minting dapp built on Rootstock",
  url: "https://mywebsite.com",
  icons: ["https://avatars.mywebsite.com/"],
};

// 4. Create a AppKit instance
export const appkit = createAppKit({
  adapters: [new EthersAdapter()],
  networks,
  metadata,
  projectId,
  allowUnsupportedChain: false,
  allWallets: "SHOW",
  defaultNetwork: rootstockTestnet,
  enableEIP6963: true,
  features: {
    analytics: true,
    allWallets: true,
    email: false,
    socials: [],
  },
});
// enforces network switch
appkit.switchNetwork(rootstockTestnet);

This script is then imported into the root component, the App.tsx file.

import { ToastContainer } from "react-toastify";
import "./App.css";
import "./connection.ts";    // here

function App() {
  return (
    <>
      <h1>Hello world!</h1>
      <ToastContainer />
    </>
  );
}

export default App;

There are two hooks at the root of the hooks folder; the useRunners hook and the useContracts hook.

useRunners.ts

import { useAppKitProvider } from "@reown/appkit/react";
import { BrowserProvider, Eip1193Provider, JsonRpcSigner } from "ethers";
import { useEffect, useMemo, useState } from "react";
import { jsonRpcProvider } from "../constants/provider";

const useRunners = () => {
  const [signer, setSigner] = useState<JsonRpcSigner>();
  const { walletProvider } = useAppKitProvider<Eip1193Provider>("eip155");

  const provider = useMemo(
    () => (walletProvider ? new BrowserProvider(walletProvider) : null),
    [walletProvider]
  );

  useEffect(() => {
    if (!provider) return;
    provider.getSigner().then((newSigner) => {
      if (!signer) return setSigner(newSigner);
      if (newSigner.address === signer.address) return;
      setSigner(newSigner);
    });
  }, [provider, signer]);
  return { provider, signer, readOnlyProvider: jsonRpcProvider };
};

export default useRunners;

This is a hook that exports the readOnlyProvider for read calls, provider and signer for write calls.

It is a replica of this line in the interaction script:

  const [signer] = await ethers.getSigners();

useContracts.ts

import { useMemo } from "react";
import useRunners from "./useRunners";
import { Contract } from "ethers";
import { TOKEN_ABI } from "../ABI/token";

export const useTokenContract = (withSigner = false) => {
  const { readOnlyProvider, signer } = useRunners();

  return useMemo(() => {
    if (withSigner) {
      if (!signer) return null;
      return new Contract(
        import.meta.env.VITE_TOKEN_CONTRACT_ADDRESS,
        TOKEN_ABI,
        signer
      );
    }
    return new Contract(
      import.meta.env.VITE_TOKEN_CONTRACT_ADDRESS,
      TOKEN_ABI,
      readOnlyProvider
    );
  }, [readOnlyProvider, signer, withSigner]);
};

This hook imports the readOnlyProvider and signer from the useRunners hook. Based on the value of withSigner , the hook constructs a contract object with the following: token contract address, imported contract ABI, and the provider. If withSigner is true, the signer is passed as the provider else the readOnlyProvider is used. This hook is the replica of this line in the interaction script:

const tokenContract = await ethers.getContractAt(ABI, CONTRACT_ADDRESS);

Anywhere in the dApp where invocations of the contract functions are needed, this hook comes in very handy.

In the hooks folder, there’s contractHook/ , A folder which contains useReadContract and useWriteContract hooks.

Currently, both of them are empty:

useReadContract.ts

// will contain read functions
export const useReadFunctions = () => {
  return {};
};

useWriteContract.ts

// will contain write functions
export const useWriteFunctions = () => {
    return {};
  };

The useReadContract hook will contain getOwner , getBalance and getTokenDetails functions.


// will contain read functions
export const useReadFunctions = () => {
  const tokenContract = useTokenContract();
  const { address } = useAppKitAccount();

  const getBalance = useCallback(async () => {
    if (!tokenContract) {
      toast.error("token contract not found!");
      return;
    }
    try {
      const balance = await tokenContract.balances(address);
      return Number(balance);
    } catch (error) {
      toast.error("Failed to fetch balance");
      console.error(error);
      return null;
    }
  }, [tokenContract]);

  return { getBalance };
};

In the getBalance function, we first check to ensure the tokenContract object is not null. Then, we use a "try and catch" block because reading from the contract is an asynchronous action. If it fails, a toast displays an error message and returns null. If successful, it returns the value. The useCallback React hook is used with tokenContract as a dependency to memoize the function, meaning it avoids unnecessary re-creations of the function on re-renders. React will return the same function instance between renders unless the dependencies change.

In like manner, the other read functions are created.

import { toast } from "react-toastify";
import { useTokenContract } from "../useContracts";
import { useCallback } from "react";
import { useAppKitAccount } from "@reown/appkit/react";

// will contain read functions
export const useReadFunctions = () => {
  const tokenContract = useTokenContract();
  const { address } = useAppKitAccount();

  const getBalance = useCallback(async () => {
    if (!tokenContract) {
      toast.error("token contract not found!");
      return;
    }
    try {
      const balance = await tokenContract.balances(address);
      return Number(balance);
    } catch (error) {
      toast.error("Failed to fetch balance");
      console.error(error);
      return null;
    }
  }, [tokenContract]);

  const getOwner = useCallback(async () => {
    if (!tokenContract) {
      toast.error("token contract not found!");
      return;
    }
    try {
      const owner = await tokenContract.owner();
      return owner;
    } catch (error) {
      toast.error("Failed to fetch token owner");
      console.error(error);
      return null;
    }
  }, [tokenContract]);

  const getTokenDetail = useCallback(async () => {
    if (!tokenContract) {
      toast.error("token contract not found!");
      return;
    }
    try {
      const [name, symbol, currentSupply, maxSupply] =
        await tokenContract.getTokenDetail();
        return {
        name,
        symbol,
        currentSupply: Number(currentSupply),
        maxSupply: Number(maxSupply),
      };
    } catch (error) {
      toast.error("Failed to fetch token detail");
      console.error(error);
      return null;
    }
  }, [tokenContract]);

  return { getBalance, getOwner, getTokenDetail };
};

Now that read functions are already cooked, we work on the connect wallet button, UI walkthrough, and then plug in the hooks to them at work, after which the write functions will be written.

UI walkthrough

App.tsx

import { ToastContainer } from "react-toastify";
import "react-toastify/dist/ReactToastify.css";
import "./App.css";
import "./connection.ts";
import { useAppKit, useAppKitAccount } from "@reown/appkit/react";
import { formatAddress } from "./utils.ts";
import { useState } from "react";
import { ethers } from "ethers";

interface ITokenDetail {
  name: string;
  symbol: string;
  currentSupply: number;
  maxSupply: number;
}
function App() {
  const { isConnected, address } = useAppKitAccount();
  const [tokenDetail] = useState<ITokenDetail | null>(null);
  const [tokenBalance] = useState<number | null>(null);

  // controls popup of wallet connect monal
  const { open } = useAppKit();

  // mock loading state for read contract calls
  const [isLoadingBalance] = useState(false);
  const [isLoadingDetails] = useState(false);

  // mock loading state for write contract calls
  const [isMinting] = useState(false);
  const [isTransferring] = useState(false);

  return (
    <>
      <div className="img">
        <img src="/rootstock.png" className="logo" alt="Logo" />
      </div>
      <div className="">
        <button onClick={() => open()}>
          {isConnected ? formatAddress(address ?? "") : <>Connect Wallet</>}
        </button>
      </div>
      <div className="flex mt">
        <div>
          {isConnected ? (
            <div>
              {/* Token Balance Section */}
              {isLoadingBalance ? (
                <p>Loading balance...</p>
              ) : (
                <p>
                  Token Balance:{" "}
                  {tokenBalance != null
                    ? Number(
                        ethers.formatUnits(tokenBalance.toString(), 18)
                      ).toLocaleString()
                    : 0}
                </p>
              )}

              {/* Token Detail Section */}
              {isLoadingDetails ? (
                <p>Loading token details...</p>
              ) : tokenDetail != null ? (
                <div>
                  <p>Token Name: {tokenDetail.name}</p>
                  <p>Token Symbol: {tokenDetail.symbol}</p>
                  <p>Token Current Supply: {tokenDetail.currentSupply}</p>
                  <p>MAX_SUPPLY: {tokenDetail.maxSupply}</p>
                </div>
              ) : (
                <div>
                  <p>Token Name: None</p>
                  <p>Token Symbol: None</p>
                  <p>Token Current Supply: 0</p>
                  <p>MAX_SUPPLY: 0</p>
                </div>
              )}
            </div>
          ) : (
            <p>Please connect your wallet to see token details.</p>
          )}
        </div>
        <div className="flex-2">
          {isConnected && (
            <>
              <button
                onClick={() => console.log("call mint")}
                disabled={isMinting}
              >
                {isMinting ? "Minting" : "Mint Token"}
              </button>
              <button
                onClick={() => console.log("calling transfer")}
                disabled={isTransferring}
              >
                {isTransferring ? "Sending" : "Transfer"}
              </button>
            </>
          )}
        </div>
      </div>
      <ToastContainer />
    </>
  );
}

export default App;

isConnected is a boolean which represents if a user has connected wallet, while the address variable returns the connected address.

Utils.ts

export const formatAddress = (walletAddress: string): string => {
  const firstPart = walletAddress.slice(0, 4);
  const lastPart = walletAddress.slice(-6);
  return `${firstPart}...${lastPart}`;
};

The function formatAddress shortens an address.

Start the development server by running the command npm run dev

Copy and paste the link in the browser, the result:

Click the connect wallet button:

Connect your desired wallet. Sometimes wallet connection in development fails due to the following:

  • Network changes. If the connection fails, get a more stable internet connection, and refresh your browser or restart the development server.

  • Not adding the PROJECT_ID gotten from the reown Appkit dashboard to the .env of your project. Without this variable, the wallet connection won’t be possible.

  • Not creating accounts/addresses on the wallet to be connected. The picture above shows the ‘installed’ wallets, but you must have created or imported accounts to the wallet.

After a successful connection, if the dApp is not connected to the Rootstock testnet, which is the only supported network, there will be a popup requesting a network change

This happens automatically because in connections.ts, the only supported network is the Rootstock testnet. If there were more supported networks, the popup would ask the user to switch to one of them.

This is a very simple UI, and you are welcome to enhance the styling and layout. The purpose of this article is to focus on integration functionality.

The token balance is a statically rendered value that would be replaced by the value returned from the getBalance function. Similarly, the token details are prefilled with demo values, which will be replaced with the result from the getTokenDetail function.

Note: The tokenOwer property is missing. This is intentional, you can take it as an exercise. Add a default data just like the other details, but replace it with the data obtained from the getOwner function.

With the UI in place, let’s plug in data from the hook.

import { ToastContainer } from "react-toastify";
import "react-toastify/dist/ReactToastify.css";
import "./App.css";
import "./connection.ts";
import { useAppKit, useAppKitAccount } from "@reown/appkit/react";
import { formatAddress } from "./utils.ts";
import { useState } from "react";
import { ethers } from "ethers";

interface ITokenDetail {
  name: string;
  symbol: string;
  currentSupply: number;
  maxSupply: number;
}
function App() {
  const { isConnected, address } = useAppKitAccount();
  const [tokenDetail] = useState<ITokenDetail | null>(null);
  const [tokenBalance] = useState<number | null>(null);

  // controls popup of wallet connect monal
  const { open } = useAppKit();

  // mock loading state for read contract calls
  const [isLoadingBalance] = useState(false);
  const [isLoadingDetails] = useState(false);

  // mock loading state for write contract calls
  const [isMinting] = useState(false);
  const [isTransferring] = useState(false);

    // import hook functions
  const { getBalance, getTokenDetail } = useReadFunctions();

    // call within useEffect
  useEffect(() => {
    if (!isConnected) {
      return;
    }
    if (!address) {
      return;
    }
    const fetchData = async () => {
      const bal = await getBalance();
      const detail = await getTokenDetail();
      if (!bal) {
        return;
      }
      if (!detail) {
        return;
      }
      console.log(bal);
      console.log(detail);
    };
    fetchData();
  }, [isConnected, address]);

  return (
    <>
    // same thing is rendered like before, there's no change
    </>
  );
}

export default App;

Running the development server, we get:

Checking the console:

Checking the browser console while doing integrations is a very useful debugging skill. From the console error message, we see the error is coming from the useReadContract.ts file. Based on the code in the useEffect, when bal is undefined, it breaks the function call. This implies that the bug is in the getBalance function.

  const { address } = useAppKitAccount();

  const getBalance = useCallback(async () => {
    if (!tokenContract) {
      toast.error("token contract not found!");
      return;
    }
    try {
      const balance = await tokenContract.balances(address);
      return Number(balance);
    } catch (error) {
      toast.error("Failed to fetch balance");
      console.error(error);
      return null;
    }
  }, [tokenContract]);

By careful observation, it can be seen that in the try block, there’s an async call which uses the address gotten when the user connects the wallet. But this function does not check for the truthiness of address nor add it to the dependency array. This is the bug.

The fix:

const getBalance = useCallback(async () => {
    if (!tokenContract) {
      toast.error("token contract not found!");
      return;
    }
    if (!address) {
      toast.error("address is not found!");
      return;
    }
    try {
      const balance = await tokenContract.balances(address);
      return Number(balance);
    } catch (error) {
      toast.error("Failed to fetch balance");
      console.error(error);
      return null;
    }
  }, [tokenContract, address]);

The output:

Great! We fixed the bug. But from the output variables, there are issues. The balance is 1.9e+21 , this would be hard to parse; the same applies to the maxSupply variable. This is because in the hook functions, the balance , currentSupply and maxSupply were type cast to Number . The original type of these was Bigintwhich is generally larger than the Number type. Although it represents it in a readable way, it’s useless, i.e cannot be parsed.

To fix this, the variables are typecasted and formatted at the same time to strings using ethers format utils.

Instead of :

return Number(balance);

Change to:

return ethers.formatUnits(balance, 18);

For this token, the decimal is 18. If your token decimal is different, you can replace it with the decimal of your choice. This ethers format method returns a formatted decimal string.

Editing for the other variables, we get:

import { toast } from "react-toastify";
import { useTokenContract } from "../useContracts";
import { useCallback, useState } from "react";
import { useAppKitAccount } from "@reown/appkit/react";
import { ethers } from "ethers";

// will contain read functions
export const useReadFunctions = () => {
  const tokenContract = useTokenContract();
  const { address } = useAppKitAccount();
  const [isLoadingBalance, setIsLoadingBalance] = useState(false);
  const [isLoadingDetails, setIsLoadingDetails] = useState(false);

  const getBalance = useCallback(async () => {
    if (!tokenContract) {
      toast.error("token contract not found!");
      return;
    }
    if (!address) {
      toast.error("address is not found!");
      return;
    }
    try {
      const balance = await tokenContract.balances(address);
      return ethers.formatUnits(balance, 18);
    } catch (error) {
      toast.error("Failed to fetch balance");
      console.error(error);
      return null;
    }
  }, [tokenContract, address]);

  const getOwner = useCallback(async () => {
    if (!tokenContract) {
      toast.error("token contract not found!");
      return;
    }
    try {
      setIsLoadingBalance(true);
      const owner = await tokenContract.owner();
      return owner;
    } catch (error) {
      toast.error("Failed to fetch token owner");
      console.error(error);
      return null;
    } finally {
      setIsLoadingBalance(false);
    }
  }, [tokenContract]);

  const getTokenDetail = useCallback(async () => {
    if (!tokenContract) {
      toast.error("token contract not found!");
      return;
    }
    try {
      setIsLoadingDetails(true);
      const [name, symbol, currentSupply, maxSupply] =
        await tokenContract.getTokenDetail();
      return {
        name,
        symbol,
        currentSupply: ethers.formatUnits(currentSupply, 18),
        maxSupply: ethers.formatUnits(maxSupply, 18),
      };
    } catch (error) {
      toast.error("Failed to fetch token detail");
      console.error(error);
      return null;
    } finally {
      setIsLoadingDetails(false);
    }
  }, [tokenContract]);

  return {
    getBalance,
    getOwner,
    getTokenDetail,
    isLoadingBalance,
    isLoadingDetails,
  };
};

Note: Loading state variables were introduced to handle the waiting state on the UI.

Since the return values have changed types from Number to string , the App.tsx need to be edited. For example, changing ITokenDetail from :

interface ITokenDetail {
  name: string;
  symbol: string;
  currentSupply: number;
  maxSupply: number;
}

to

interface ITokenDetail {
  name: string;
  symbol: string;
  currentSupply: string;
  maxSupply: string;
}

The changed App.tsx :

import { ToastContainer } from "react-toastify";
import "react-toastify/dist/ReactToastify.css";
import "./App.css";
import "./connection.ts";
import { useAppKit, useAppKitAccount } from "@reown/appkit/react";
import { formatAddress } from "./utils.ts";
import { useEffect, useState } from "react";
import { useReadFunctions } from "./hooks/contractHook/useReadContract.ts";

interface ITokenDetail {
  name: string;
  symbol: string;
  currentSupply: string;
  maxSupply: string;
}
function App() {
  const { isConnected, address } = useAppKitAccount();
  const [tokenDetail, setTokenDetail] = useState<ITokenDetail | null>(null);
  const [tokenBalance, setTokenBalance] = useState<string | null>(null);

  // controls popup of wallet connect monal
  const { open } = useAppKit();

  // mock loading state for read contract calls
  const [isLoadingBalance] = useState(false);
  const [isLoadingDetails] = useState(false);

  // mock loading state for write contract calls
  const [isMinting] = useState(false);
  const [isTransferring] = useState(false);

  const { getBalance, getTokenDetail } = useReadFunctions();

  useEffect(() => {
    if (!isConnected) {
      return;
    }
    if (!address) {
      return;
    }
    const fetchData = async () => {
      const bal = await getBalance();
      const detail = await getTokenDetail();
      if (!bal) {
        return;
      }
      if (!detail) {
        return;
      }
      setTokenBalance(bal);
      setTokenDetail(detail);
    };

    fetchData();
  }, [isConnected, address]);

  return (
    <>
      <div className="img">
        <img src="/rootstock.png" className="logo" alt="Logo" />
      </div>
      <div className="">
        <button onClick={() => open()}>
          {isConnected ? formatAddress(address ?? "") : <>Connect Wallet</>}
        </button>
      </div>
      <div className="flex mt">
        <div>
          {isConnected ? (
            <div>
              {/* Token Balance Section */}
              {isLoadingBalance ? (
                <p>Loading balance...</p>
              ) : (
                <p>Token Balance: {tokenBalance != null ? tokenBalance : 0}</p>
              )}

              {/* Token Detail Section */}
              {isLoadingDetails ? (
                <p>Loading token details...</p>
              ) : tokenDetail != null ? (
                <div>
                  <p>Token Name: {tokenDetail.name}</p>
                  <p>Token Symbol: {tokenDetail.symbol}</p>
                  <p>Token Current Supply: {tokenDetail.currentSupply}</p>
                  <p>MAX_SUPPLY: {tokenDetail.maxSupply}</p>
                </div>
              ) : (
                <div>
                  <p>Token Name: None</p>
                  <p>Token Symbol: None</p>
                  <p>Token Current Supply: 0</p>
                  <p>MAX_SUPPLY: 0</p>
                </div>
              )}
            </div>
          ) : (
            <p>Please connect your wallet to see token details.</p>
          )}
        </div>
        <div className="flex-2">
          {isConnected && (
            <>
              <button
                onClick={() => console.log("call mint")}
                disabled={isMinting}
              >
                {isMinting ? "Minting" : "Mint Token"}
              </button>
              <button
                onClick={() => console.log("calling transfer")}
                disabled={isTransferring}
              >
                {isTransferring ? "Sending" : "Transfer"}
              </button>
            </>
          )}
        </div>
      </div>
      <ToastContainer />
    </>
  );
}

export default App;

The final step is to add proper loading states. Right now, isLoadingDetails and isLoadingBalance are just placeholder state values. These loading states should be retrieved from the useReadFunctions hook. By removing the placeholder state values and importing the actual loading state variables from the hook, App.tsx becomes:

import { ToastContainer } from "react-toastify";
import "react-toastify/dist/ReactToastify.css";
import "./App.css";
import "./connection.ts";
import { useAppKit, useAppKitAccount } from "@reown/appkit/react";
import { formatAddress } from "./utils.ts";
import { useEffect, useState } from "react";
import { useReadFunctions } from "./hooks/contractHook/useReadContract.ts";

interface ITokenDetail {
  name: string;
  symbol: string;
  currentSupply: string;
  maxSupply: string;
}
function App() {
  const { isConnected, address } = useAppKitAccount();
  const [tokenDetail, setTokenDetail] = useState<ITokenDetail | null>(null);
  const [tokenBalance, setTokenBalance] = useState<string | null>(null);

  // controls popup of wallet connect monal
  const { open } = useAppKit();

  // mock loading state for write contract calls
  const [isMinting] = useState(false);
  const [isTransferring] = useState(false);

  const { getBalance, getTokenDetail, isLoadingBalance, isLoadingDetails } =
    useReadFunctions();

  useEffect(() => {
    if (!isConnected) {
      return;
    }
    if (!address) {
      return;
    }
    const fetchData = async () => {
      const bal = await getBalance();
      const detail = await getTokenDetail();
      if (!bal) {
        return;
      }
      if (!detail) {
        return;
      }
      setTokenBalance(bal);
      setTokenDetail(detail);
    };

    fetchData();
  }, [isConnected, address]);

  return (
    <>
      <div className="img">
        <img src="/rootstock.png" className="logo" alt="Logo" />
      </div>
      <div className="">
        <button onClick={() => open()}>
          {isConnected ? formatAddress(address ?? "") : <>Connect Wallet</>}
        </button>
      </div>
      <div className="flex mt">
        <div>
          {isConnected ? (
            <div>
              {/* Token Balance Section */}
              {isLoadingBalance ? (
                <p>Loading balance...</p>
              ) : (
                <p>Token Balance: {tokenBalance != null ? tokenBalance : 0}</p>
              )}

              {/* Token Detail Section */}
              {isLoadingDetails ? (
                <p>Loading token details...</p>
              ) : tokenDetail != null ? (
                <div>
                  <p>Token Name: {tokenDetail.name}</p>
                  <p>Token Symbol: {tokenDetail.symbol}</p>
                  <p>Token Current Supply: {tokenDetail.currentSupply}</p>
                  <p>MAX_SUPPLY: {tokenDetail.maxSupply}</p>
                </div>
              ) : (
                <div>
                  <p>Token Name: None</p>
                  <p>Token Symbol: None</p>
                  <p>Token Current Supply: 0</p>
                  <p>MAX_SUPPLY: 0</p>
                </div>
              )}
            </div>
          ) : (
            <p>Please connect your wallet to see token details.</p>
          )}
        </div>
        <div className="flex-2">
          {isConnected && (
            <>
              <button
                onClick={() => console.log("call mint")}
                disabled={isMinting}
              >
                {isMinting ? "Minting" : "Mint Token"}
              </button>
              <button
                onClick={() => console.log("calling transfer")}
                disabled={isTransferring}
              >
                {isTransferring ? "Sending" : "Transfer"}
              </button>
            </>
          )}
        </div>
      </div>
      <ToastContainer />
    </>
  );
}

export default App;

The output:

Before the data was fetched, there was a loader text which disappeared immediately after the fetching was complete.

WRITE FUNCTIONS

Congratulations on coming this far. So far, most of the UI has been completed, and the read functions of the contract have been integrated.

The write functions in the interaction script:

  //xxxxxxxxxxxxxxxxxxxxxx/
  const mintTx = await tokenContract.mintToken(amount, signer.address);
  await mintTx.wait();
  //xxxxxxxxxxxxxxxxxxxxxx/

  //xxxxxxxxxxxxxxxxxxxxxx/
  const transferTx = await tokenContract.transferToken(amt, receiver);
  await transferTx.wait();
  //xxxxxxxxxxxxxxxxxxxxxx/

On the project, there’s a useWriteContract.ts file that houses the hook for writing to the contract. This hook will contain 2 functions; mintToken and transferToken .

useWriteContract.ts

// will contain write functions
export const useWriteFunctions = () => {
  return {};
};

Becomes:

import { useAppKitAccount } from "@reown/appkit/react";
import { useTokenContract } from "../useContracts";
import { useCallback, useState } from "react";
import { toast } from "react-toastify";
import { ethers } from "ethers";

// will contain write functions
export const useWriteFunctions = () => {
  const tokenContract = useTokenContract(true);
  const { address } = useAppKitAccount();
  const [isMinting, setIsMinting] = useState(false);
  const [isTransferring, setIsTransferring] = useState(false);

  const mintToken = useCallback(
    async (amount: string) => {
      if (!tokenContract) {
        toast.error("token contract not found!");
        return;
      }
      if (!address) {
        toast.error("address is not found!");
        return;
      }
      try {
        // call mint function
        setIsMinting(true);
        const amt = ethers.parseUnits(amount, 18);
        const mintTx = await tokenContract.mintToken(amt, address);
        const reciept = await mintTx.wait();
        return reciept.status === 1;
      } catch (error) {
        console.error(error);
        return false;
      } finally {
        setIsMinting(false);
      }
    },
    [tokenContract, address]
  );

  const transferToken = useCallback(
    async (amount: string, receiver: string) => {
      if (!tokenContract) {
        toast.error("token contract not found!");
        return;
      }
      try {
        // call transfer function
        setIsTransferring(true);
        const amt = ethers.parseUnits(amount, 18);
        const transferTx = await tokenContract.transferToken(amt, receiver);
        const reciept = await transferTx.wait();
        return reciept.status === 1;
      } catch (error) {
        console.error(error);
        return false;
      } finally{
        setIsTransferring(false);
      }
    },
    [tokenContract]
  );

  return { mintToken, transferToken, isMinting, isTransferring };
};

When integrating the read functions earlier, the useTokenContract hook was invoked without passing in the boolean, which states if a signer was needed or not. When reading from the blockchain, there’s no need to sign a transaction, so there is no need for a signer. But here, for writing data to the blockchain, the signer is needed. The state variables isMinting and isTransferring are the respective loading states for minting and transfer transactions. The necessary checks are performed to ensure that address and tokenContract are defined.

The mint function requires a string variable, amount, which is converted to a Bigint ethers.parseUnits(amount, 18) and passed as an argument on the contract function call.

const reciept = await mintTx.wait();
return reciept.status === 1;

The first line here ensures that the mint transaction is completely executed on the blockchain first before moving to the next line, i.e, it halts the execution of the function, waiting for a success or failure status from the transaction. If reciept.status === 1 is true, then the transaction was successful. On the UI, this boolean will be used to pop a success notification message. The logic applies to transferTx.

To use the write functions, we import the hook and the functions.

import { ToastContainer } from "react-toastify";
import "react-toastify/dist/ReactToastify.css";
import "./App.css";
import "./connection.ts";
import { useAppKit, useAppKitAccount } from "@reown/appkit/react";
import { formatAddress } from "./utils.ts";
import { useEffect, useState } from "react";
import { useReadFunctions } from "./hooks/contractHook/useReadContract.ts";
import { useWriteFunctions } from "./hooks/contractHook/useWriteContract.ts";

interface ITokenDetail {
  name: string;
  symbol: string;
  currentSupply: string;
  maxSupply: string;
}
function App() {
  const { isConnected, address } = useAppKitAccount();
  const [tokenDetail, setTokenDetail] = useState<ITokenDetail | null>(null);
  const [tokenBalance, setTokenBalance] = useState<string | null>(null);

  // controls popup of wallet connect monal
  const { open } = useAppKit();

  const { getBalance, getTokenDetail, isLoadingBalance, isLoadingDetails } =
    useReadFunctions();

  useEffect(() => {
    if (!isConnected) {
      return;
    }
    if (!address) {
      return;
    }
    const fetchData = async () => {
      const bal = await getBalance();
      const detail = await getTokenDetail();
      if (!bal) {
        return;
      }
      if (!detail) {
        return;
      }
      setTokenBalance(bal);
      setTokenDetail(detail);
    };

    fetchData();
  }, [isConnected, address]);

  const { mintToken, transferToken, isMinting, isTransferring } =
    useWriteFunctions();

  const handleMinting = async () => {};

  const handleTransfer = async () => {};

  return (
    <>
      <div className="img">
        <img src="/rootstock.png" className="logo" alt="Logo" />
      </div>
      <div className="">
        <button onClick={() => open()}>
          {isConnected ? formatAddress(address ?? "") : <>Connect Wallet</>}
        </button>
      </div>
      <div className="flex mt">
        <div>
          {isConnected ? (
            <div>
              {/* Token Balance Section */}
              {isLoadingBalance ? (
                <p>Loading balance...</p>
              ) : (
                <p>Token Balance: {tokenBalance != null ? tokenBalance : 0}</p>
              )}

              {/* Token Detail Section */}
              {isLoadingDetails ? (
                <p>Loading token details...</p>
              ) : tokenDetail != null ? (
                <div>
                  <p>Token Name: {tokenDetail.name}</p>
                  <p>Token Symbol: {tokenDetail.symbol}</p>
                  <p>Token Current Supply: {tokenDetail.currentSupply}</p>
                  <p>MAX_SUPPLY: {tokenDetail.maxSupply}</p>
                </div>
              ) : (
                <div>
                  <p>Token Name: None</p>
                  <p>Token Symbol: None</p>
                  <p>Token Current Supply: 0</p>
                  <p>MAX_SUPPLY: 0</p>
                </div>
              )}
            </div>
          ) : (
            <p>Please connect your wallet to see token details.</p>
          )}
        </div>
        <div className="flex-2">
          {isConnected && (
            <>
              <button onClick={handleMinting} disabled={isMinting}>
                {isMinting ? "Minting" : "Mint Token"}
              </button>
              <button onClick={handleTransfer} disabled={isTransferring}>
                {isTransferring ? "Sending" : "Transfer"}
              </button>
            </>
          )}
        </div>
      </div>
      <ToastContainer />
    </>
  );
}
export default App;

Next is the completion of the handleMinting and handleTransfer functions.

  const handleMinting = async () => {
    const amount = "1000";
    const isMintingSuccessful = await mintToken(amount);
    if(!isMintingSuccessful){
      toast.error("Minting Failed!!!");
      return;
    }
    toast.success("Minting successful!");
  };

  const handleTransfer = async () => {
    const amount = "1000";
    const receiver = "0xd3e0d7fa9ac7253c18ccac87f643e61baf1da3ea";
    const isTransferSuccessful = await transferToken(amount,receiver);
    if(!isTransferSuccessful){
      toast.error("Transfer Failed!!!");
      return;
    }
    toast.success("Transfer Successful!");
  };

The amount and receiver were hardcoded in the functions, this is to simplify the UI extension and give flexibility when entering error handling. For exploration, you can create HTML input fields to allow users to type in any amount and receiver address, hence making the values dynamic. This does not affect the hook functionality, the only difference is passing in the state values representing the dynamic amount and receiver into the respective functions.

Let’s test!

From this:

Clicking the mintToken button should popup your connected wallet:

Signing the transaction:

According to the algorithm followed, since the modal popup was a success modal, then the mint transaction was successful, but this does not reflect on the UI until the wallet is disconnected and connected again. To fix this, memoize the fetchData function and when a write transaction is successful, it is invoked within both handleMinting and handleTransfer, the data is updated accordingly.

Memoized fetchData and updated useEffect.

  const fetchData = useCallback(async () => {
    const bal = await getBalance();
    const detail = await getTokenDetail();
    if (!bal || !detail) return;
    setTokenBalance(bal);
    setTokenDetail(detail);
  }, [getBalance, getTokenDetail]);

  useEffect(() => {
    if (!isConnected || !address) {
      return;
    }
    fetchData();
  }, [isConnected, address, fetchData]);

handleMint and handleTransfer

  const handleMinting = async () => {
    const amount = "1000";
    const isMintingSuccessful = await mintToken(amount);
    if (!isMintingSuccessful) {
      toast.error("Minting Failed!!!");
      return;
    }
    toast.success("Minting successful!");
    fetchData();
  };

  const handleTransfer = async () => {
    const amount = "1000";
    const receiver = "0xd3e0d7fa9ac7253c18ccac87f643e61baf1da3ea";
    const isTransferSuccessful = await transferToken(amount, receiver);
    if (!isTransferSuccessful) {
      toast.error("Transfer Failed!!!");
      return;
    }
    toast.success("Transfer Successful!");
    fetchData();
  };

Testing Again:

Mint transaction

The balance and totalSupply updated accordingly after the minting was successful.

Transfer Transaction

Clicking the transfer button:

Signing the transaction:

The token balance dropped by 1000. Transfer only affects balance, but minting affects both balance and total current supply.

Error Handling

Both the read and write functions of the smart contract has been integrated to the dApp, the next thing to do is handle errors graciously. The dApp will be tested passing in invalid inputs and all kinds of things to break it, this is for us to find bugs we have thought about and handle the errors graciously instead of them causing the dApp to crash totally. For this dApp, there are two kinds of error; “non-smart contract” error and smart contract error. Most of the “non-smart contract” errors are easy to handle, but for contract errors, the ethers-decode-error package will be used.

Run the command:

npm install ethers-decode-error

Examples of “non-smart contract” errors:

When the mint transaction is not signed but canceled:

The same thing goes for the transfer transaction.

Examples of possible smart contract errors in the context of the smart contract deployed earlier:

A. For minting:

  1. amount cannot be zero.

  2. The sum of amount and totalCurrentSupply must not exceed MAX_SUPPLY

B. For transfer:

  1. amount cannot be zero.

  2. The sender’s balance must be greater than amount

  3. The receiver address must not be the same as the sender’s.

We will try to play out each scenario and handle the errors.

Scenario 1: amount cannot be zero.

const handleMinting = async () => {
    const amount = "0";        // changed to zero
    const isMintingSuccessful = await mintToken(amount);
    if (!isMintingSuccessful) {
      toast.error("Minting Failed!!!");
      return;
    }
    toast.success("Minting successful!");
    fetchData();
  };

  const handleTransfer = async () => {
    const amount = "0";    // changed to zero
    const receiver = "0xd3e0d7fa9ac7253c18ccac87f643e61baf1da3ea";
    const isTransferSuccessful = await transferToken(amount, receiver);
    if (!isTransferSuccessful) {
      toast.error("Transfer Failed!!!");
      return;
    }
    toast.success("Transfer Successful!");
    fetchData();
  };

The above functions are changed to play the scenario. Next, run the app with the console open:

Both the mint and transfer functions failed.

Taking a closer look at the logged error message:

The error reverted to “Cannot transfer 0 tokens” for the transfer transaction and “Cannot mint 0 tokens” for the mint transaction. These revert messages came from the contract. Our goal is to extract that error and display it properly. If the goal was to prevent this error from occurring, in the hook, there could be a condition to check when amount == “0” and break the function before making the contract call, but the goal here is to handle errors from the contract. To achieve this , the package ethers-decode-error will be used.

import { ErrorDecoder, DecodedError } from "ethers-decode-error";
// add to the imports in useWriteContract.ts
const errorDecoder = ErrorDecoder.create();
// add to the function body of the useWriteFunctions hook
// this creates an instance of the error decoder object
// add to the catch block
const decodedError: DecodedError = await errorDecoder.decode(error);
toast.error(decodedError.reason);
// here the error is decoded using the async decode method and typed to the DecodedError type
// then reason derived is poped as a toast message

useWriteContract.ts final look

import { useAppKitAccount } from "@reown/appkit/react";
import { useTokenContract } from "../useContracts";
import { useCallback, useState } from "react";
import { toast } from "react-toastify";
import { ethers } from "ethers";
import { ErrorDecoder, DecodedError } from "ethers-decode-error";

// will contain write functions
export const useWriteFunctions = () => {
  const tokenContract = useTokenContract(true);
  const { address } = useAppKitAccount();
  const [isMinting, setIsMinting] = useState(false);
  const [isTransferring, setIsTransferring] = useState(false);
  const errorDecoder = ErrorDecoder.create();

  const mintToken = useCallback(
    async (amount: string) => {
      if (!tokenContract) {
        toast.error("token contract not found!");
        return;
      }
      if (!address) {
        toast.error("address is not found!");
        return;
      }
      try {
        // call mint function
        setIsMinting(true);
        const amt = ethers.parseUnits(amount, 18);
        const mintTx = await tokenContract.mintToken(amt, address);
        const reciept = await mintTx.wait();
        return reciept.status === 1;
      } catch (error) {
        console.error(error);
        // decodes error
        const decodedError: DecodedError = await errorDecoder.decode(error);
        toast.error(decodedError.reason);
        return false;
      } finally {
        setIsMinting(false);
      }
    },
    [tokenContract, address]
  );

  const transferToken = useCallback(
    async (amount: string, receiver: string) => {
      if (!tokenContract) {
        toast.error("token contract not found!");
        return;
      }
      try {
        // call transfer function
        setIsTransferring(true);
        const amt = ethers.parseUnits(amount, 18);
        const transferTx = await tokenContract.transferToken(amt, receiver);
        const reciept = await transferTx.wait();
        return reciept.status === 1;
      } catch (error) {
        // decodes error
        const decodedError: DecodedError = await errorDecoder.decode(error);
        toast.error(decodedError.reason);
        return false;
      } finally {
        setIsTransferring(false);
      }
    },
    [tokenContract]
  );

  return { mintToken, transferToken, isMinting, isTransferring };
};

The output:

Note: This approach of using ethers-decode-error only works for string revert error messages , if the contract uses custom errors, it requires a different approach.

Scenario 1 has been solved!

Scenario 2: The sum of amount and totalCurrentSupply must not exceed MAX_SUPPLY

Now, assign the mint amount a value which when added to totalCurrentSupply exceeds the MAX_SUPPLY . For this contract current state, 1000000 - 5000 = 995000 , any amount above 995000 works.

const handleMinting = async () => {
    const amount = "995001"; // 995001 > 995000      
    const isMintingSuccessful = await mintToken(amount);
    if (!isMintingSuccessful) {
      toast.error("Minting Failed!!!");
      return;
    }
    toast.success("Minting successful!");
    fetchData();
  };

The output:

Scenario 2 has been handled!

Scenario 3 was handled alongside Scenario 1.

Scenario 4: The sender’s balance must be greater than amount

Here, try to transfer any amount above the sender’s balance which, according to this context, is 3900 .

const handleTransfer = async () => {
    const amount = "4000";  // 4000 > 3900
    const receiver = "0xd3e0d7fa9ac7253c18ccac87f643e61baf1da3ea";
    const isTransferSuccessful = await transferToken(amount, receiver);
    if (!isTransferSuccessful) {
      toast.error("Transfer Failed!!!");
      return;
    }
    toast.success("Transfer Successful!");
    fetchData();
  };

The output:

Scenario 4 cleared!

Scenario 5: The receiver address must not be the same as the sender’s.

Set the receiver’s address to be same as the sender’s address.

const { isConnected, address } = useAppKitAccount();

const handleTransfer = async () => {
    const amount = "1000";  // 1000 < 3900
    const receiver = address;    // receiver and sender address are the same
    if (!receiver) return;
    const isTransferSuccessful = await transferToken(amount, receiver);
    if (!isTransferSuccessful) {
      toast.error("Transfer Failed!!!");
      return;
    }
    toast.success("Transfer Successful!");
    fetchData();
  };

The output:

Scenario 5 cleared!

Now that these cases have been tested and the errors handled graciously, the functions can be reverted:

  const handleMinting = async () => {
    const amount = "1000";
    const isMintingSuccessful = await mintToken(amount);
    if (!isMintingSuccessful) {
      toast.error("Minting Failed!!!");
      return;
    }
    toast.success("Minting successful!");
    fetchData();
  };

  const handleTransfer = async () => {
    const amount = "1000";
    const receiver = "0xd3e0d7fa9ac7253c18ccac87f643e61baf1da3ea";
    const isTransferSuccessful = await transferToken(amount, receiver);
    if (!isTransferSuccessful) {
      toast.error("Transfer Failed!!!");
      return;
    }
    toast.success("Transfer Successful!");
    fetchData();
  };

Remember, we talked about making the amount and receiver variables dynamic using input fields? Yup! That’s something you can explore! Looking for the completed code, check my github.

Conclusion

Whoosh! Congratulations! You made it down the rabbit hole! Exploring the concept of building a token minting and transfer dApp on Rootstock has been a jolly ride, especially because of the exquisite usage of Ethers and Appkit, and then graciously handling errors was cool also! For more complex concepts and deep practice, check out Rootstock’s documentation, Rootstock Community blog, and also explore Rootstock developer resources for extra support and a supportive Rootstock community to stay connected.

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