A Guide to Conducting Batch Transactions with Viem Multicall on Rootstock

On-chain transaction batching combines multiple blockchain transactions to save on gas costs. This approach helps prevent excessively high gas fees while reducing latency, frequency, and the load on RPC calls. By aggregating multiple read or write calls into a single external contract call, this cost-efficient method improves efficiency.

Read-only multicalls, which consist of view or pure calls, are considered safe and are regarded as best practices. However, state-changing multicalls, which batch transactions that alter the blockchain state, are less commonly used in real-world applications. They can complicate interactions during audits and may introduce unintended state changes if not managed correctly.

Nonetheless, multicalls are highly useful for building frontends and fetching off-chain data, with applications in DeFi, NFT offerings, and more. Some popular DeFi projects that utilize multicalls behind the scenes include Uniswap, Dune, and Zapper.

This guide aims to help developers perform read-only contract calls using Viem’s native Multical3 implementation. Viem is a modern alternative to Ethers.js and Web3.js, both of which are used for interacting with the blockchain from frontend JavaScript applications.

The advantages of read-only multicall when used are:

  • User Experience: They give a better user experience since they aggregate state data in a single RPC call

  • Cost-Efficiency: They do not spend gas or change blockchain state as they run via a node or locally

  • Compatibility: They are compatible with nearly all EVM contracts

In the next section, you'll see a practical demonstration of how to batch transactions with multicall.

Contract Setup

1. Compile and Deploy Your Contract

For this demo, let’s create a contract for an on-chain resume app, like LinkedIn. You can use the Remix browser IDE to set up, compile, and deploy the following contract.

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

contract OnChainProfile {
    struct Profile {
        string name;
        string role;
        uint256 yearsExperience;
    }

    mapping(address => Profile) public profiles;
    address[] public profileAddresses;

    function setProfile(
        string memory name,
        string memory role,
        uint256 yearsExperience
    ) external {
        profiles[msg.sender] = Profile(name, role, yearsExperience);
        profileAddresses.push(msg.sender);
    }

    function getName(address user) external view returns (string memory) {
        return profiles[user].name;
    }

    function getRole(address user) external view returns (string memory) {
    return profiles[user].role;
    }

    function getExperience(address user) external view returns (uint256) {
    return profiles[user].yearsExperience;
    }

    function getAllProfiles() external view returns (address[] memory) {
        return profileAddresses; // Returns list of registered addresses
    }

    function getProfileCount() external view returns (uint256) {
        return profileAddresses.length; // Get total profiles stored 
    }
}

The above code is the contract for the on-chain profile that lets people store their name, job role, and years of experience. The:

  • profiles mapping ties each wallet address to a Profile

  • profileAddresses array consists of all addresses that have created a profile

  • setProfile function saves and updates a user’s profile and spends gas for the write operations

  • getName, getRole, and getExperience fetch the name, role, and years of experience, respectively, for a profile

  • getAllProfiles function returns all registered addresses

  • getProfileCount returns the number of profiles that exist

You should be able to interact with the deployed contract on Remix as shown in the following screenshot:

Figure: Deployed on-chain profile

Note the deployed contract address, as you will need it later in this guide. Use the setProfile function to add a few profiles by supplying the names, roles, and years of experience.

2. Verify Your Contract

You can view the deployed contract address on the Rootstock Testnet Explorer. You can follow this tutorial on how to verify a smart contract on the RSK Block Explorer. After you verify the contract, you will be able to view the Code section containing the contract ABI and contract source code. You could also use the Contract Interaction section to interact with the contract function call. The following screenshot shows what the Code section looks like.

Figure: Code section after verifying the contract

Building the Next.js Frontend

1. Set up Next.js and Viem Project

Now that you have a deployed contract, you can create a Next.js frontend to use Viem to interact with the contracts. Use the following terminal command to create a new Next.js project, which you can call rsk_viem_frontend:

npx create-next-app@latest rsk_viem_frontend  --typescript

Select the default options in the prompt that the CLI returns while creating the new Next.js project. After creating the project, change the directory into the new directory and install Viem:

npm install viem

Next, create a .env.local file in the root of the rsk_viem_frontend/ directory and add the following secrets:

NEXT_PUBLIC_RPC_URL=<Your Rootstock Testnet RPC>
NEXT_PUBLIC_CONTRACT_ADDRESS=<Deployed OnChainProfile Contract Address>
  • You can get your RPC URL from the Rootstock RPC node by following the official getting started guide on the Rootstock RPC API.

  • You can get the deployed contract address from the Deployed Contracts section of the Remix IDE.

2. Setup Viem Multicall (utils/contractAbi.ts and utils/multicall.ts)

To keep things organized, you can create a utils folder at the root of the Next.js project and add a contractABI.tsfile. Copy the contract ABI from the Rootstock Explorer Code section into a file inside the utils/contractABI.tsfile like the following.

export const profileContractAbi = [
    {
      "inputs": [],
      "name": "getAllNames",
      "outputs": [
        {
          "internalType": "string[]",
          "name": "",
          "type": "string[]"
        }
      ],
      "stateMutability": "view",
      "type": "function"
    },
    {
      "inputs": [],
      "name": "getAllProfiles",
      "outputs": [
        {
          "internalType": "address[]",
          "name": "",
          "type": "address[]"
        }
      ],
      "stateMutability": "view",
      "type": "function"
    },
    {
      "inputs": [
        {
          "internalType": "address",
          "name": "user",
          "type": "address"
        }
      ],
      "name": "getExperience",
      "outputs": [
        {
          "internalType": "uint256",
          "name": "",
          "type": "uint256"
        }
      ],
      "stateMutability": "view",
      "type": "function"
    },
    {
      "inputs": [
        {
          "internalType": "address",
          "name": "user",
          "type": "address"
        }
      ],
      "name": "getName",
      "outputs": [
        {
          "internalType": "string",
          "name": "",
          "type": "string"
        }
      ],
      "stateMutability": "view",
      "type": "function"
    },
    {
      "inputs": [],
      "name": "getProfileCount",
      "outputs": [
        {
          "internalType": "uint256",
          "name": "",
          "type": "uint256"
        }
      ],
      "stateMutability": "view",
      "type": "function"
    },
    {
      "inputs": [
        {
          "internalType": "address",
          "name": "user",
          "type": "address"
        }
      ],
      "name": "getRole",
      "outputs": [
        {
          "internalType": "string",
          "name": "",
          "type": "string"
        }
      ],
      "stateMutability": "view",
      "type": "function"
    },
    {
      "inputs": [
        {
          "internalType": "uint256",
          "name": "",
          "type": "uint256"
        }
      ],
      "name": "profileAddresses",
      "outputs": [
        {
          "internalType": "address",
          "name": "",
          "type": "address"
        }
      ],
      "stateMutability": "view",
      "type": "function"
    },
    {
      "inputs": [
        {
          "internalType": "address",
          "name": "",
          "type": "address"
        }
      ],
      "name": "profiles",
      "outputs": [
        {
          "internalType": "string",
          "name": "name",
          "type": "string"
        },
        {
          "internalType": "string",
          "name": "role",
          "type": "string"
        },
        {
          "internalType": "uint256",
          "name": "yearsExperience",
          "type": "uint256"
        }
      ],
      "stateMutability": "view",
      "type": "function"
    },
    {
      "inputs": [
        {
          "internalType": "string",
          "name": "name",
          "type": "string"
        },
        {
          "internalType": "string",
          "name": "role",
          "type": "string"
        },
        {
          "internalType": "uint256",
          "name": "yearsExperience",
          "type": "uint256"
        }
      ],
      "name": "setProfile",
      "outputs": [],
      "stateMutability": "nonpayable",
      "type": "function"
    }
  ] as const;

Next, create a file called multicall.ts for the Safe configuration in the utils directory and add the following code inside it:

 import { createPublicClient, http } from 'viem';
import { rootstockTestnet } from 'viem/chains';
import { profileContractAbi } from './contractABI';


const RPC_URL = process.env.NEXT_PUBLIC_RPC_URL;
const CONTRACT_ADDRESS =  process.env.NEXT_PUBLIC_CONTRACT_ADDRESS as 0x${string};

const client = createPublicClient({
  chain: rootstockTestnet,
  transport: http(RPC_URL)
});

export async function fetchProfile(userAddress: string) {
  try {
    // Format address safely
    const formattedAddress = (userAddress.startsWith('0x') 
      ? userAddress.toLowerCase() as 0x${string}
      : (`0x${userAddress}` as 0x${string}));

    // Verify the profile exists
    const profileExists = await client.readContract({
      address: CONTRACT_ADDRESS,
      abi: profileContractAbi,
      functionName: 'profiles',
      args: [formattedAddress]
    });

    // Multicall to get all data
    const [name, role, experience] = await client.multicall({
      contracts: [
        {
          address: CONTRACT_ADDRESS,
          abi: profileContractAbi,
          functionName: 'getName',
          args: [formattedAddress],
        },
        {
          address: CONTRACT_ADDRESS,
          abi: profileContractAbi,
          functionName: 'getRole',
          args: [formattedAddress],
        },
        {
          address: CONTRACT_ADDRESS,
          abi: profileContractAbi,
          functionName: 'getExperience',
          args: [formattedAddress],
        },
      ],
      allowFailure: false // Thrown if any call fails
    });

    // Verify the name exists
    if (!name) {
        throw new Error('No profile found for this address');
    }

    return {
      name,
      role,
      experience: Number(experience) // Converts bigInt to number for Next.js
    };

  } catch (error) {
    console.error('Profile fetch failed:', error);
    throw new Error(
      error instanceof Error 
        ? error.message 
        : 'Failed to fetch profile data'
    );
  }
}

The above code:

  • Initializes the connection to Rootstock testnet using the Viem createPublicClient method for creating a blockchain client. The client will handle all your blockchain requests.

  • Uses the fetchProfile function to take in a raw user address and calls the profiles function on the deployed contract. It then checks if the address has a profile set and uses multicall to get three pieces of data in one request: Name (via getName), Role (via getRole), Experience (via getExperience)

  • Checks if the name exists and throws a “No profile” error if it doesn’t.

3. Create UI (pages/index.tsx)

Now, you can create a UI page for the user to interact with the project. Create a new folder in the Next.js project root called pages and create an index.tsx file inside the folder. You can delete the app folder as you won’t need it for this demo. Put the following code in the pages/index.tsx file:

import { useState } from 'react';
import { fetchProfile } from '../utils/multicall';


export default function Home() {
    const [address, setAddress] = useState('');
    const [profile, setProfile] = useState<{ name?: string; role?: string; experience?: number }>({});

    const handleFetch = async () => {
        try {
            console.log('Fetching profile for:', address); 
            const formattedAddress = address.startsWith('0x') ? address : 0x${address};
            const data = await fetchProfile(formattedAddress as 0x${string});
            console.log('Received data:', data);
            setProfile(data);
        } catch (error) {
            console.error('Fetch error:', error);
            alert(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
        }
    };


    return (
        <div style={{ padding: '20px', maxWidth: '600px', margin: '0 auto' }}>
            <h1>Profile Lookup</h1>
            <input
                type="text"
                value={address}
                onChange={(e) => setAddress(e.target.value)}
                placeholder="Enter wallet address (0x...)"
                style={{ width: '100%', padding: '10px', marginBottom: '10px' }}
            />

            <button onClick={handleFetch} style={{ padding: '10px 20px' }}>
                Fetch Profile
            </button>
            {profile.name && (
            <div style={{ marginTop: '20px' }}>
                <h3>Results:</h3>
                <p>Name: {profile.name}</p>
                <p>Role: {profile.role}</p>
                <p>Experience: {profile.experience} years</p>
            </div>
            )}
        </div>
    );
}

The above code defines the UI layer for the application. The code:

  • Configures the state to store the address, user-input wallet address, and fetched profile data

  • Uses its handleFetch function to call the fetchProfile function that calls the blockchain in the multicall util

  • Updates the fetched data to the profile, which is then displayed to the UI components for the user to see on the browser

Now, start the frontend from the root of the frontend/ directory with the following terminal command:

npm run dev

Navigate to http://localhost:3000 on your browser, and you will see the following page.

Figure: The running app on the browser

Supply the public address of the MetaMask account that you used to create the profile after you deployed the OnChainProfile contract in Remix. That was the user address that you used to create a profile. You could have used any other address that you used to create a profile via the Rootstock Explorer or any other means.

After inputting the public address, click the Fetch Profile button, and you should get the profile as shown in the following screenshot.

Figure: Batch-fetched profile data

And now, you have retrieved all the profile details using multicall.

Conclusion

You have successfully utilized Viem's multicall feature to batch static calls to the contract. Now, you can explore other functions of the deployed contract. Additionally, you can start incorporating this technique into your projects whenever you use Viem. This approach will enhance user experience and improve efficiency for users interacting with your dApps and DeFi projects.

0
Subscribe to my newsletter

Read articles from Jekayin-Oluwa Olabemiwo directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Jekayin-Oluwa Olabemiwo
Jekayin-Oluwa Olabemiwo