Batch token balances
The multicall replacement: one compiled script takes an address[] of tokens as a runtime
argument, loops balanceOf over it, and returns all balances as one typed array. Compile
once; every batch — 5 tokens or 50 — is the same bytecode with different args.
The script
Section titled “The script”import { arg, evscript, t } from '@maxencerb/evs';import { erc20Abi } from 'viem';
export const balances = evscript( { name: 'balances', args: [arg('tokens', t.array(t.address)), arg('owner', t.address)] }, (s) => { const n = s.args.tokens.length(); const out = s.newArray(t.uint256, n); // zero-filled uint256[n] s.for({ type: t.uint256, from: 0n, until: n }, (i) => { const token = s.args.tokens.at(i); // bounds-checked const r = s.tryCall({ address: token, abi: erc20Abi, functionName: 'balanceOf', args: [s.args.owner], }); out.set(i, s.select(r.success, r.value, 0n)); // non-token addresses → 0, no revert }); return s.return({ balances: out.expr() }); },);Three pieces do the work:
s.args.tokens.length()is a runtime value — the loop bound is whatever array the caller sends, with no recompilation.s.foris a real on-chain loop, not an unrolled one; see control flow for why native JSforcannot do this.s.newArray(t.uint256, n)allocates a zero-filled mutable output array;out.expr()hands the same buffer back as anExprfors.return.s.tryCallpluss.selectmakes the batch fault-tolerant: an EOA or non-token address in the list yieldssuccess = falseand a zeroedvalue, so that slot reports0ninstead of reverting the entire batch. See calls for the exact zeroing semantics.
Execution
Section titled “Execution”import { arg, evscript, t } from '@maxencerb/evs';import { createPublicClient, erc20Abi, http } from 'viem';
// --- the script from the previous section, unchanged ---
const balances = evscript( { name: 'balances', args: [arg('tokens', t.array(t.address)), arg('owner', t.address)] }, (s) => { const n = s.args.tokens.length(); const out = s.newArray(t.uint256, n); s.for({ type: t.uint256, from: 0n, until: n }, (i) => { const token = s.args.tokens.at(i); const r = s.tryCall({ address: token, abi: erc20Abi, functionName: 'balanceOf', args: [s.args.owner], }); out.set(i, s.select(r.success, r.value, 0n)); }); return s.return({ balances: out.expr() }); },);
// --- one eth_call for the whole batch ---
const client = createPublicClient({ transport: http('https://ethereum-rpc.publicnode.com') });
const tokens = [ '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // USDC '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', // WETH '0x6B175474E89094C44Da98b954EedeAC495271d0F', // DAI '0xdAC17F958D2ee523a2206206994597C13D831ec7', // USDT '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', // WBTC '0x514910771AF9Ca656af840dff83E8264EcF986CA', // LINK '0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984', // UNI '0x7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9', // AAVE '0x9f8F72aA9304c8B593d555F12eF6589cC3A579A2', // MKR '0xD533a949740bb3306d119CC777fa900bA034cd52', // CRV] as const;
const owner = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045';
const res = await client.readContract({ ...balances.compile().toViem(), // { abi, code } — deployless functionName: 'balances', args: [tokens, owner],});
// res: { balances: readonly bigint[] } — one entry per input token, same orderexport const byToken = Object.fromEntries(tokens.map((token, i) => [token, res.balances[i]]));A 50-token batch is this exact call with a longer tokens array — the array is calldata, not
code, so nothing about the script changes. res.balances decodes as
readonly bigint[] in input order, and tolerant slots come back as 0n:
{ '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48': 20126724012543n, '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2': 4108579460116529341n, '0x6B175474E89094C44Da98b954EedeAC495271d0F': 19265880506953696862947n, ...}Limits for large batches
Section titled “Limits for large batches”Batch size never touches the compiled bytecode (the loop is fixed-size code, so the EIP-170 size cap is irrelevant here). The real budgets are:
- Gas. Each iteration costs one
STATICCALLinto the token — the subcalls dominate — plus roughly 60 gas of loop bookkeeping per iteration. The ceiling is your node’seth_callgas cap, not a block limit: anvil defaults to 30M, geth’s default cap is 50M. A 50-token batch sits far inside either; thousands of tokens per call are realistic before the cap matters. - A misbehaving target.
tryCallforwards all remaining gas by default; if a listed address could burn the whole budget, pass the per-callgasoption to cap it — see calls. - Allocation.
s.newArrayreverts with Panic0x41if the runtime length is at or above2^32— you hit the gas cap long before that bound.
Run it from the repo
Section titled “Run it from the repo”A runnable version —
examples/token-balances
— spawns a local anvil, deploys mock ERC-20s, appends an EOA to the batch to demonstrate the
tryCall zero default, and prints every balance. For dependent (not just parallel) reads, see
the flagship pool metadata example; for more loop and subroutine
recipes, see patterns.