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


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:
Bitcoin Security: Building dApps on Rootstock means deploying your project on a blockchain with a Bitcoin security layer.
Lower Latency: Transactions are faster, and block finality is faster compared to Ethereum.
Lower Gas Fee: Gas fees are relatively low. This means users pay very low transaction fees.
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:
Basic knowledge of solidity and smart contracts:
- This article will not focus on smart contract development and deployment, for more on this topic check this article on Rootstock blog.
Basic knowledge of JavaScript/TypeScript
Basic knowledge of React and React hooks:
- This article uses custom hooks to abstract and simplify contract integration logic.
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).
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:
Rootstock Testnet (RSK Testnet) Configuration:
Network Name: Rootstock Testnet
New RPC URL:
https://public-node.testnet.rsk.co
Chain ID:
31
Currency Symbol:
tRBTC
Block Explorer URL:
https://explorer.testnet.rsk.co/
Public RPCs might experience timeouts because of high traffic. An alternative would be to use Alchemy or Infura.
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 livedeploy/
- For deployment and interaction scriptstest/
- For your test fileshardhat.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
andtransferToken
are write functions, whilegetTokenDetail
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
anduseWriteContract
. 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 Bigint
which 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:
amount
cannot be zero.The sum of
amount
andtotalCurrentSupply
must not exceedMAX_SUPPLY
B. For transfer:
amount
cannot be zero.The sender’s balance must be greater than
amount
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.
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.