Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

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") or require(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 calls

This 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 execution

Best 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 IllegalArgumentException

2. 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.