Multicall3 Batching
Overview
Instead of making separate RPC calls for each contract read:
// Inefficient: 3 separate RPC calls
BigInteger balance1 = token1.balanceOf(user);
BigInteger balance2 = token2.balanceOf(user);
String symbol = token.symbol();You can batch them into a single request:
// Efficient: 1 RPC call for all reads
MulticallBatch batch = client.batch();
Erc20 token1Proxy = batch.bind(Erc20.class, token1Address, abiJson);
Erc20 token2Proxy = batch.bind(Erc20.class, token2Address, abiJson);
Erc20 tokenProxy = batch.bind(Erc20.class, tokenAddress, abiJson);
BatchHandle<BigInteger> balance1Handle = batch.add(token1Proxy.balanceOf(user));
BatchHandle<BigInteger> balance2Handle = batch.add(token2Proxy.balanceOf(user));
BatchHandle<String> symbolHandle = batch.add(tokenProxy.symbol());
batch.execute();
BigInteger balance1 = balance1Handle.result().data();
BigInteger balance2 = balance2Handle.result().data();
String symbol = symbolHandle.result().data();Basic Usage
1. Create a Batch
import sh.brane.rpc.MulticallBatch;
import sh.brane.rpc.Brane;
MulticallBatch batch = client.batch();2. Bind Contract Interfaces
Use bind() to create "recording proxies" that capture function calls without executing them immediately.
public interface Erc20 {
BigInteger balanceOf(Address account);
String symbol();
BigInteger totalSupply();
}
Address tokenAddress = new Address("0x...");
String abiJson = "[...]"; // Your contract ABI
Erc20 token = batch.bind(Erc20.class, tokenAddress, abiJson);3. Record Calls
Call methods on the proxy to record them in the batch. The proxy returns default values (null for objects, false for booleans, 0 for numbers) but captures the call metadata.
Address user = new Address("0x...");
// These calls are recorded, not executed
BigInteger balance = token.balanceOf(user); // Returns null (default)
String symbol = token.symbol(); // Returns null (default)
BigInteger supply = token.totalSupply(); // Returns null (default)4. Add to Batch
Use add() to add each recorded call to the batch and receive a handle for the result.
BatchHandle<BigInteger> balanceHandle = batch.add(balance);
BatchHandle<String> symbolHandle = batch.add(symbol);
BatchHandle<BigInteger> supplyHandle = batch.add(supply);5. Execute
Execute all batched calls in a single RPC request.
batch.execute();6. Access Results
After execution, access results through the handles.
BatchResult<BigInteger> balanceResult = balanceHandle.result();
if (balanceResult.success()) {
System.out.println("Balance: " + balanceResult.data());
} else {
System.out.println("Failed: " + balanceResult.revertReason());
}Complete Example
import sh.brane.rpc.MulticallBatch;
import sh.brane.rpc.BatchHandle;
import sh.brane.rpc.BatchResult;
import sh.brane.core.types.Address;
import sh.brane.rpc.Brane;
import java.math.BigInteger;
public class MulticallExample {
// Contract interface (must be declared outside the method)
public interface Erc20 {
BigInteger balanceOf(Address account);
String symbol();
BigInteger totalSupply();
}
public static void main(String[] args) {
Brane client = Brane.connect("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY");
// Create batch
MulticallBatch batch = client.batch();
// Bind contracts
Address tokenAddress = new Address("0x...");
String abiJson = "[...]";
Erc20 token = batch.bind(Erc20.class, tokenAddress, abiJson);
// Record calls
Address user = new Address("0x...");
BigInteger balance = token.balanceOf(user);
String symbol = token.symbol();
BigInteger supply = token.totalSupply();
// Add to batch
BatchHandle<BigInteger> balanceHandle = batch.add(balance);
BatchHandle<String> symbolHandle = batch.add(symbol);
BatchHandle<BigInteger> supplyHandle = batch.add(supply);
// Execute (single RPC call)
batch.execute();
// Access results
System.out.println("Balance: " + balanceHandle.result().data());
System.out.println("Symbol: " + symbolHandle.result().data());
System.out.println("Supply: " + supplyHandle.result().data());
}
}Error Handling
When a call in a batch fails, Brane automatically decodes the revert reason using RevertDecoder. The BatchResult includes both the success status and a human-readable error message.
BatchResult<BigInteger> result = balanceHandle.result();
if (!result.success()) {
// result.revertReason() contains decoded error message
// e.g., "Insufficient balance", "Unauthorized", or panic codes
System.err.println("Call failed: " + result.revertReason());
} else {
BigInteger value = result.data();
}Supported Revert Types
- Error Strings:
revert("message")orrequire(false, "message")- Returns the message - Panic Codes:
assert(false), division by zero, etc. - Returns descriptive panic reason - Custom Errors: Solidity custom errors - Returns formatted error with parameters
- Unknown: Unrecognized revert data - Returns raw hex data
Failure Policy
By default, individual calls in a batch are allowed to fail without reverting the entire batch. You can control this behavior:
// Allow individual failures (default)
batch.allowFailure(true);
// Revert entire batch if any call fails
batch.allowFailure(false);When allowFailure is false, if any call fails, the entire batch will revert. When true, each call's result is independent.
Automatic Chunking
Large batches are automatically split into smaller chunks to comply with RPC provider limits. The default chunk size is 500 calls per request.
// Customize chunk size
batch.chunkSize(100); // Split into chunks of 100 calls
// Execute will automatically handle chunking
batch.execute(); // May make multiple RPC calls if batch > 100 callsThis is especially useful when:
- Your RPC provider has strict limits on request size
- You're batching hundreds or thousands of calls
- You want to balance between network efficiency and request size
Multiple Contracts
You can batch calls across multiple contracts in a single batch:
MulticallBatch batch = client.batch();
// Bind multiple contracts
Erc20 token1 = batch.bind(Erc20.class, token1Address, abiJson);
Erc20 token2 = batch.bind(Erc20.class, token2Address, abiJson);
NftContract nft = batch.bind(NftContract.class, nftAddress, nftAbiJson);
// Mix calls from different contracts
BatchHandle<BigInteger> balance1 = batch.add(token1.balanceOf(user));
BatchHandle<BigInteger> balance2 = batch.add(token2.balanceOf(user));
BatchHandle<BigInteger> tokenCount = batch.add(nft.balanceOf(user));
batch.execute();
// All results available after single executionBest Practices
1. Only Batch View Functions
Multicall3 is designed for read-only operations. State-changing functions cannot be batched.
// Good: View functions
BigInteger balance = token.balanceOf(user);
String symbol = token.symbol();
// Bad: State-changing functions will throw
// TransactionReceipt receipt = token.transfer(to, amount); // Throws IllegalArgumentException2. Handle Results After Execution
Access results only after calling execute():
batch.add(token.balanceOf(user));
// Don't access result yet - will throw IllegalStateException
// BigInteger balance = handle.result().data();
batch.execute();
// Now safe to access
BigInteger balance = handle.result().data();3. Reuse Batches Carefully
Each batch is designed for a single execution cycle. After execute(), create a new batch for additional calls.
MulticallBatch batch = client.batch();
// ... add calls ...
batch.execute();
// ... use results ...
// For more calls, create a new batch
MulticallBatch batch2 = client.batch();
// ... add new calls ...4. Clear Pending Calls on Exceptions
MulticallBatch uses a ThreadLocal to capture call metadata between the proxy method call and add(). If an exception occurs between these two calls, you must call clearPending() to prevent ThreadLocal leaks.
MulticallBatch batch = client.batch();
Erc20 token = batch.bind(Erc20.class, tokenAddress, abiJson);
try {
BigInteger balance = token.balanceOf(user); // Call captured in ThreadLocal
// ... validation that might throw ...
BatchHandle<BigInteger> handle = batch.add(balance); // Clears ThreadLocal
} catch (Exception e) {
batch.clearPending(); // Prevent ThreadLocal leak!
throw e;
}Performance Benefits
Multicall3 batching provides significant performance improvements:
- Reduced Network Overhead: 1 RPC call instead of N calls
- Lower Latency: Parallel execution on-chain
- Atomic Reads: All calls read from the same block state
- Cost Efficiency: Fewer network round-trips
For example, reading 100 token balances:
- Without Multicall: 100 RPC calls, ~10-20 seconds
- With Multicall: 1 RPC call, ~200-500ms
Technical Details
Multicall3 Contract
Brane uses the standard Multicall3 contract deployed at:
- Address:
0xcA11bde05977b3631167028862bE2a173976CA11 - Method:
aggregate3((address target, bool allowFailure, bytes callData)[] calls) - Returns:
(bool success, bytes returnData)[]
This address is deterministic and works across all major EVM chains (Ethereum, Polygon, Arbitrum, Optimism, Base, BSC, etc.).
Recording Proxy
Brane uses dynamic Java proxies to intercept method calls and capture:
- Target contract address
- Encoded function calldata
- Expected return type
The proxy returns default values during recording, allowing you to use the same interface patterns as regular contract interactions.