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 { evscript, t } from '@maxencerb/evs';import { erc20Abi } from 'viem';
export const balances = evscript( { name: 'balances', args: [t.array(t.address), t.address] }, (s, tokens, owner) => { const n = 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 = tokens.at(i); // bounds-checked const r = s.tryRead({ address: token, abi: erc20Abi, functionName: 'balanceOf', 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:
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.tryReadpluss.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 { evscript, t } from '@maxencerb/evs';import { createPublicClient, erc20Abi, http } from 'viem';
// --- the script from the previous section, unchanged ---
const balances = evscript( { name: 'balances', args: [t.array(t.address), t.address] }, (s, tokens, owner) => { const n = tokens.length(); const out = s.newArray(t.uint256, n); s.for({ type: t.uint256, from: 0n, until: n }, (i) => { const token = tokens.at(i); const r = s.tryRead({ address: token, abi: erc20Abi, functionName: 'balanceOf', 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 runtime size cap is irrelevant here). The real budgets are:
- The deployless result cap. The default
toViem()runs the script as a contract creation, so its result is capped at 24,576 bytes (EIP-170 applies to it).{ balances: uint256[] }encodes to96 + 32 × nbytes, so a deployless call handles at most 765 tokens; at 766 the node rejects it (max code size exceeded, orCreateContractSizeLimiton anvil). For larger batches usetoViem({ mode: 'stateOverride' }), which has no such cap, or split the token list into chunks of at most 765. See deployless limits. - 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; in state-override mode thousands of tokens per call are realistic before the cap matters. Deployless mode also spends about 200 gas per byte of result (about 6,400 per token) on the creation. - A misbehaving target.
s.tryReadforwards 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
s.tryRead 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.