Skip to content

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.

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.for is a real on-chain loop, not an unrolled one; see control flow for why native JS for cannot do this.
  • s.newArray(t.uint256, n) allocates a zero-filled mutable output array; out.expr() hands the same buffer back as an Expr for s.return.
  • s.tryCall plus s.select makes the batch fault-tolerant: an EOA or non-token address in the list yields success = false and a zeroed value, so that slot reports 0n instead of reverting the entire batch. See calls for the exact zeroing semantics.
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 order
export 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,
...
}

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 STATICCALL into the token — the subcalls dominate — plus roughly 60 gas of loop bookkeeping per iteration. The ceiling is your node’s eth_call gas 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. tryCall forwards all remaining gas by default; if a listed address could burn the whole budget, pass the per-call gas option to cap it — see calls.
  • Allocation. s.newArray reverts with Panic 0x41 if the runtime length is at or above 2^32 — you hit the gas cap long before that bound.

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.