Batch-request and multicall smart contracts
Overview
While interacting with Ronin, you might want to speed up your process or improve semantic consistency. A way to do that is by submitting a single HTTP request with several sub-requests inside the JSON-RPC body.
Submitting several sub-requests in one has the following advantages:
- Improved request and response latency.
- Improved consistency. For example, when making several requests with the “latest“ BlockNumber, you expect the "latest" block number to be the same for all requests. This mechanism gives you a better chance of reaching that level, but not 100%.
This tutorial shows to make a batch JSON-RPC request to Ronin. It also describes how to wrap several smart contract calls in a single request and send it to a specific contract that performs multiple call resolution. The task used throughout the tutorial is, “I want to get the WETH, AXS, USDC, SLP balances of an address with just one request.“
Before you start
Before you start the tutorial, you should have the following:
- Node.js or your preferred web3 client written in another language.
- npm or yarn for package installation. This tutorial uses npm.
- A Ronin Wallet address.
- Basic knowledge of HTTP.
- Basic knowledge of JavaScript.
- Basic knowledge of JSON-RPC. For more information, see the JSON-RPC specification.
Environment
Throughout this tutorial, you use the public RPC endpoint for the Saigon testnet: https://saigon-testnet.roninchain.com/rpc
.
We recommend using the public endpoint only for testing. For production, make sure to use the developer portal's endpoint https://api-gateway.skymavis.com/rpc/testnet
.
Step 1. Create a project
- Create a new directory for your project and open the terminal in that directory:
cd ~/
mkdir ronin_rpc_contract_sample
cd ronin_rpc_contract_sample
- Initialize a new Node.js project:
npm init -y
You should see output similar to this:
Wrote to ***/ronin_rpc_contract_sample/package.json:
{
"name": "ronin_rpc_sample",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}
- Install the
ethers.js
library:
npm install ethers@5.7.2
This tutorial uses ethers.js
version 5.7.2.
- Install the
axios
library:
npm install axios
Step 2. Use a JSON-RPC batch request
About batch requests
A batch request is a single HTTP request that contains many nested JSON RPC methods. The client can send several request objects in an array and receive a corresponding array of response objects from the server.
The server processes all requests of this batch RPC call concurrently, in any order. The response objects can be in any order as well. The client should match the context of the request objects to the response objects based on the ID of each object.
For more information, see the JSON-RPC specification.
Example request:
curl https://saigon-testnet.roninchain.com/rpc \
-X POST \
-H "Content-Type: application/json" \
-d '[{"jsonrpc": "2.0", "id": 1, "method": "eth_blockNumber", "params": []},
{"jsonrpc": "2.0", "id": 2, "method": "eth_blockNumber", "params": []},
{"jsonrpc": "2.0", "id": 3, "method": "eth_blockNumber", "params": []},
{"jsonrpc": "2.0", "id": 4, "method": "eth_blockNumber", "params": []}]'
Example response:
[
{
"jsonrpc": "2.0",
"id": 1,
"result": "0x119ab0e"
},
{
"jsonrpc": "2.0",
"id": 2,
"result": "0x119ab0e"
},
{
"jsonrpc": "2.0",
"id": 3,
"result": "0x119ab0e"
},
{
"jsonrpc": "2.0",
"id": 4,
"result": "0x119ab0e"
}
]
Query balances
At this step, you create a script to get balance (ERC20 token: WETH, AXS, USDC & SLP) of the address 0xf6fd5fca4bd769ba495b29b98dba5f2ecf4ceed3
.
- Create a file called
batch_request.js
and paste the following code:
// Import library
const {ethers} = require('ethers');
const axios = require('axios');
// Define your contract ABI and addresses
const contractABI = [{
"constant": true,
"inputs": [
{
"name": "_owner",
"type": "address"
}
],
"name": "balanceOf",
"outputs": [
{
"name": "balance",
"type": "uint256"
}
],
"payable": false,
"stateMutability": "view",
"type": "function"
}];
// Define contract interface
const ERC20ContractInterface = new ethers.utils.Interface(contractABI);
// Array of contract addresses on Saigon Testnet
const contractAddresses = [
'0x29c6f8349a028e1bdfc68bfa08bdee7bc5d47e16', //WETH
'0x3c4e17b9056272ce1b49f6900d8cfd6171a1869d', //AXS
'0x067FBFf8990c58Ab90BaE3c97241C5d736053F77', //USDC
'0x82f5483623d636bc3deba8ae67e1751b6cf2bad2' //SLP
];
const callArray = []
contractAddresses.forEach((contractAddress, index) => {
callArray.push({
"jsonrpc": "2.0",
"method": "eth_call",
"params": [{
"to": contractAddress,
"data": ERC20ContractInterface.encodeFunctionData(
"balanceOf",
["0xf6fd5fca4bd769ba495b29b98dba5f2ecf4ceed3"]
)
}, "latest"],
"id": Math.floor(Math.random() * 100000)
})
})
axios.post('https://saigon-testnet.roninchain.com/rpc', callArray)
.then(response => {
const results = response.data;
results.forEach((result, index) => {
console.log(`Balance of ${contractAddresses[index]}: ${BigInt(result.result)}`);
});
})
- Execute the code:
node batch_request.js
You should see output similar to this:
Balance of 0x29c6f8349a028e1bdfc68bfa08bdee7bc5d47e16: 11201224440355637251309597
Balance of 0x3c4e17b9056272ce1b49f6900d8cfd6171a1869d: 42316291079920745148389
Balance of 0x067FBFf8990c58Ab90BaE3c97241C5d736053F77: 714649238541
Balance of 0x82f5483623d636bc3deba8ae67e1751b6cf2bad2: 109999995842674717
Code explanation
Let's walk through the contents of batch_request.js
.
- These lines import the
ethers
andaxios
libraries for interacting with Ethereum and making HTTP requests, respectively.
const {ethers} = require('ethers');
const axios = require('axios');
- This array defines the contract's ABI. The ABI describes the structure of the contract, including its functions, inputs, outputs, and other properties.
const contractABI = [{
"constant": true,
"inputs": [
{
"name": "_owner",
"type": "address"
}
],
"name": "balanceOf",
"outputs": [
{
"name": "balance",
"type": "uint256"
}
],
"payable": false,
"stateMutability": "view",
"type": "function"
}];
- The following line creates an instance of
ethers.utils.Interface
using the contract's ABI. This interface provides utility functions for encoding and decoding contract function calls.
const ERC20ContractInterface = new ethers.utils.Interface(contractABI);
- These lines define an array
contractAddresses
that contains the addresses of the contracts we want to interact with on the Saigon testnet.
const contractAddresses = [
'0x29c6f8349a028e1bdfc68bfa08bdee7bc5d47e16', //WETH
'0x3c4e17b9056272ce1b49f6900d8cfd6171a1869d', //AXS
'0x067FBFf8990c58Ab90BaE3c97241C5d736053F77', //USDC
'0x82f5483623d636bc3deba8ae67e1751b6cf2bad2' //SLP
];
- This initializes an empty array
callArray
that will store the JSON-RPC call objects for each contract.
const callArray = []
- A loop iterates over each contract address in
contractAddresses
. For each address, a JSON-RPC call object is created and added to the arraycallArray
.- The JSON-RPC call object follows the JSON-RPC 2.0 specification. It specifies the method
eth_call
, the parameters for the call (including the contract address and the encoded function data), and an identifierid
. - The
encodeFunctionData
method ofERC20ContractInterface
is used to encode the function name (balanceOf
) and its arguments (the address0xf6fd5fca4bd769ba495b29b98dba5f2ecf4ceed3
) into the contract's function call data.
- The JSON-RPC call object follows the JSON-RPC 2.0 specification. It specifies the method
contractAddresses.forEach((contractAddress, index) => {
callArray.push({
"jsonrpc": "2.0",
"method": "eth_call",
"params": [{
"to": contractAddress,
"data": ERC20ContractInterface.encodeFunctionData(
"balanceOf",
["0xf6fd5fca4bd769ba495b29b98dba5f2ecf4ceed3"]
)
}, "latest"],
"id": Math.floor(Math.random() * 100000)
})
})
- Finally, an HTTP POST request is made to the Saigon testnet RPC endpoint
https://saigon-testnet.roninchain.com/rpc'
usingaxios.post
. The arraycallArray
is sent as the request payload.
axios.post('https://saigon-testnet.roninchain.com/rpc', callArray)
.then(response => {
const results = response.data;
results.forEach((result, index) => {
console.log(`Balance of ${contractAddresses[index]}: ${BigInt(result.result)}`);
});
})
The code assumes you are making a balanceOf
call on each contract for a specific address 0xf6fd5fca4bd769ba495b29b98dba5f2ecf4ceed3
. Make sure to adjust the address or modify the code as per your requirements.
Step 3. Use a multicall smart contract
About multicall
A multicall contract is a smart contract that takes function call objects as parameters and executes them together. You can use the multicall contract as a proxy for interacting with other smart contracts in a "batch" style. With multicall, a single request to the eth_call
endpoint returns the results of many contract function calls.
Multicall benefits include the following:
- Fewer JSON-RPC requests to send. This reduces the cost of RPC services as well as the number of round trips between the client and the node.
- All returned values are guaranteed to be from the same block.
- The block number or timestamp can be returned in the response, helping you detect stale data.
Query balances
In this tutorial, you make requests to a multicall contract that's already deployed on the Saigon testnet at the address 0x31c9ef8a631e2489e69833df3b2cb4bf0dc413bc
. You can view it in the Ronin app.
- Create a file called
multicall_contract.js
and paste the following code:
// Import library
const {ethers} = require('ethers');
// Initialize the provider
const provider = new ethers.providers.JsonRpcProvider('https://saigon-testnet.roninchain.com/rpc');
// Define your ERC20 contract ABI and interface
const contractABI = [{
"constant": true,
"inputs": [
{
"name": "_owner",
"type": "address"
}
],
"name": "balanceOf",
"outputs": [
{
"name": "balance",
"type": "uint256"
}
],
"payable": false,
"stateMutability": "view",
"type": "function"
}];
const ERC20ContractInterface = new ethers.utils.Interface(contractABI);
const multicallContractABI = [{
"constant": false,
"inputs": [
{
"internal_type": "",
"name": "_calls",
"type": "tuple[]",
"components": [
{
"internal_type": "",
"name": "target",
"type": "address"
},
{
"internal_type": "",
"name": "callData",
"type": "bytes"
}
],
"indexed": false
}
],
"name": "aggregate",
"outputs": [
{
"internal_type": "",
"name": "_blockNumber",
"type": "uint256",
"indexed": false
},
{
"internal_type": "",
"name": "_returnData",
"type": "bytes[]",
"indexed": false
}
],
"payable": false,
"stateMutability": "nonpayable",
"type": "function",
"anonymous": false
}];
const multicallInterface = new ethers.utils.Interface(multicallContractABI);
// Array of contract addresses
const contractAddresses = [
'0x29c6f8349a028e1bdfc68bfa08bdee7bc5d47e16', //WETH
'0x3c4e17b9056272ce1b49f6900d8cfd6171a1869d', //AXS
'0x067FBFf8990c58Ab90BaE3c97241C5d736053F77', //USDC
'0x82f5483623d636bc3deba8ae67e1751b6cf2bad2' //SLP
];
const callArray = []
contractAddresses.forEach((contractAddress, index) => {
callArray.push(
[contractAddress,
ERC20ContractInterface.encodeFunctionData(
'balanceOf',
['0xf6fd5fca4bd769ba495b29b98dba5f2ecf4ceed3'])
]
)
})
provider.call({
to: '0x31c9ef8a631e2489e69833df3b2cb4bf0dc413bc',
data: multicallInterface.encodeFunctionData('aggregate', [callArray])
})
.then(response => {
const callResult = multicallInterface.decodeFunctionResult('aggregate', response);
const results = callResult._returnData
results.forEach((result, index) => {
console.log(`Balance of ${contractAddresses[index]}: ${BigInt(result)}`);
});
})
- Execute the code:
node multicall_contract.js
You should see output similar to this:
Balance of 0x29c6f8349a028e1bdfc68bfa08bdee7bc5d47e16: 11201224440355637251309597
Balance of 0x3c4e17b9056272ce1b49f6900d8cfd6171a1869d: 42316291079920745148389
Balance of 0x067FBFf8990c58Ab90BaE3c97241C5d736053F77: 714649238541
Balance of 0x82f5483623d636bc3deba8ae67e1751b6cf2bad2: 109999995842674717
Code explanation
Let's walk through the contents of multicall_contract.js
.
- This line imports the
ethers
library, which provides utilities and classes for interacting with Ethereum.
const { ethers } = require('ethers');
- Here, a JSON-RPC provider is created using the URL of the Ronin node.
const provider = new ethers.providers.JsonRpcProvider('https://saigon-testnet.roninchain.com/rpc');
- These lines define the ABI and interface of an ERC20 contract. The
contractABI
variable holds the ABI of an ERC20 contract. TheERC20ContractInterface
is created using theethers.utils.Interface
class and the contract's ABI.
const contractABI = [...]; // ERC20 contract ABI
const ERC20ContractInterface = new ethers.utils.Interface(contractABI);
- These lines define the ABI and interface of a multicall contract. Similar to step 3, the
multicallContractABI
variable holds the ABI of a multicall contract, and themulticallInterface
is created using theethers.utils.Interface
class.
const multicallContractABI = [...]; // Multicall contract ABI
const multicallInterface = new ethers.utils.Interface(multicallContractABI);
- This line defines an array of contract addresses, which represent the ERC20 contracts for which you want to retrieve the balance.
const contractAddresses = [...]; // Array of contract addresses
- This code creates a call array to store the contract addresses and encoded function data for the
balanceOf
function. TheforEach
loop iterates over the contract addresses, and for each address, the function data is encoded using theERC20ContractInterface.encodeFunctionData
method and added to the call array.
const callArray = [];
contractAddresses.forEach((contractAddress, index) => {
callArray.push([
contractAddress,
ERC20ContractInterface.encodeFunctionData('balanceOf', ['0xf6fd5fca4bd769ba495b29b98dba5f2ecf4ceed3'])
]);
});
- Finally, a multicall request is made using the
provider.call
method. Theto
field specifies the address of the multicall contract, and thedata
field contains the encoded function data for theaggregate
function, which takes the call array as a parameter. The response is then processed to decode the returned data using themulticallInterface.decodeFunctionResult
method. The results are retrieved from the decoded data and printed to the console.
provider.call({
to: '0x31c9ef8a631e2489e69833df3b2cb4bf0dc413bc',
data: multicallInterface.encodeFunctionData('aggregate', [callArray])
})
.then(response => {
const callResult = multicallInterface.decodeFunctionResult('aggregate', response);
const results = callResult._returnData;
results.forEach((result, index) => {
console.log(`Balance of ${contractAddresses[index]}: ${BigInt(result)}`);
});
})