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 aProfile
profileAddresses
array consists of all addresses that have created a profilesetProfile
function saves and updates a user’s profile and spends gas for the write operationsgetName
,getRole
, andgetExperience
fetch the name, role, and years of experience, respectively, for a profilegetAllProfiles
function returns all registered addressesgetProfileCount
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.ts
file. Copy the contract ABI from the Rootstock Explorer Code section into a file inside the utils/contractABI.ts
file 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 (viagetName)
, Role (viagetRole
), Experience (viagetExperience
)
- 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.
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