Summary
ERC-20 balance lookups for an address currently issue one eth_call (balanceOf) per token contract, delivered as a JSON-RPC batch. On metered RPC providers this costs per_call_credits × N (e.g. 20 × N on QuickNode's Ethereum tier) and executes N independent calls that are not block-consistent.
We already have a Multicall3 aggregate3 primitive — EthereumTypeMulticallAggregate3 (bchain/coins/eth/multicall.go), used today only by ERC-4626 enrichment (api/erc4626.go). Routing balances through it collapses N sub-calls into a single eth_call (~20 credits flat, all at one block).
Current behavior
EthereumTypeGetErc20ContractBalancesAtBlock (bchain/coins/eth/contract.go) → erc20BalancesBatchAtBlock builds []rpc.BatchElem of eth_call/balanceOf and sends via BatchCallContext.
- A JSON-RPC batch is 1 HTTP request but N billable method calls; batching saves round-trips, not credits or rate-limit budget. Each sub-call also runs at its own
latest, so results are not guaranteed block-consistent.
Proposed change
1. Route balances through aggregate3 with batch fallback.
In EthereumTypeGetErc20ContractBalancesAtBlock, try Multicall3 first (all balanceOf(holder) calls with AllowFailure=true), decode (success, bytes)[] → balances. Fall back to the existing JSON-RPC batch path when Multicall3 isn't available (errMulticall3NotDeployed) or on aggregate error. Preserve the current nil-on-failure semantics for dead / non-conforming tokens.
2. Make the probed Multicall3 address per-chain.
Today multicall3Address is a single hardcoded canonical constant (0xcA11bde05977b3631167028862bE2a173976CA11, multicall.go:18). Tron's Multicall3 is deployed at a non-canonical address, so the canonical-only probe misses it.
Multicall3 availability across supported EVM chains (verified 2026-07-29)
| Chain |
Chain ID |
Multicall3 |
At canonical 0xcA11…CA11? |
Path after change |
| Ethereum, ETC, Optimism, BSC, Polygon, Base, Arbitrum One/Nova, Avalanche, Sepolia, Hoodi |
— |
✅ |
✅ |
aggregate3 |
| Robinhood mainnet |
4663 |
✅ |
✅ (verified via robinhoodchain.blockscout.com) |
aggregate3 |
| Tron mainnet |
728126428 |
✅ |
❌ 0x32a4f47a74a6810bd0bf861cabab99656a75de9e (TEazPvZ…) |
aggregate3 after per-chain address override |
| Robinhood testnet |
46630 |
❌ |
— |
eth_call batch (unchanged) |
| Tron Nile testnet |
3448148188 |
❌ |
— |
eth_call batch (unchanged) |
Tron override
Add a per-chain override so Tron mainnet only (728126428) probes/uses 0x32a4f47a74a6810bd0bf861cabab99656a75de9e. Tron Nile and every other chain keep the canonical default. Chains without a Multicall3 deployment (incl. Robinhood testnet, Tron Nile) continue to fall back to the eth_call batch — this is intended and requires no work.
Impact
- Cost: on metered providers, ERC-20 balance credit cost drops from ~
20 × N to ~20 (flat) on chains with Multicall3.
- Consistency: all balances observed at a single block.
- Safety: no behavior change on chains without Multicall3 (batch fallback preserved);
AllowFailure=true keeps dead/non-conforming tokens producing nil rather than failing the whole request.
References
Summary
ERC-20 balance lookups for an address currently issue one
eth_call(balanceOf) per token contract, delivered as a JSON-RPC batch. On metered RPC providers this costsper_call_credits × N(e.g. 20 × N on QuickNode's Ethereum tier) and executes N independent calls that are not block-consistent.We already have a Multicall3
aggregate3primitive —EthereumTypeMulticallAggregate3(bchain/coins/eth/multicall.go), used today only by ERC-4626 enrichment (api/erc4626.go). Routing balances through it collapses N sub-calls into a singleeth_call(~20 credits flat, all at one block).Current behavior
EthereumTypeGetErc20ContractBalancesAtBlock(bchain/coins/eth/contract.go) →erc20BalancesBatchAtBlockbuilds[]rpc.BatchElemofeth_call/balanceOfand sends viaBatchCallContext.latest, so results are not guaranteed block-consistent.Proposed change
1. Route balances through
aggregate3with batch fallback.In
EthereumTypeGetErc20ContractBalancesAtBlock, try Multicall3 first (allbalanceOf(holder)calls withAllowFailure=true), decode(success, bytes)[]→ balances. Fall back to the existing JSON-RPC batch path when Multicall3 isn't available (errMulticall3NotDeployed) or on aggregate error. Preserve the current nil-on-failure semantics for dead / non-conforming tokens.2. Make the probed Multicall3 address per-chain.
Today
multicall3Addressis a single hardcoded canonical constant (0xcA11bde05977b3631167028862bE2a173976CA11,multicall.go:18). Tron's Multicall3 is deployed at a non-canonical address, so the canonical-only probe misses it.Multicall3 availability across supported EVM chains (verified 2026-07-29)
0xcA11…CA11?aggregate3aggregate30x32a4f47a74a6810bd0bf861cabab99656a75de9e(TEazPvZ…)aggregate3after per-chain address overrideeth_callbatch (unchanged)eth_callbatch (unchanged)Tron override
Add a per-chain override so Tron mainnet only (728126428) probes/uses
0x32a4f47a74a6810bd0bf861cabab99656a75de9e. Tron Nile and every other chain keep the canonical default. Chains without a Multicall3 deployment (incl. Robinhood testnet, Tron Nile) continue to fall back to theeth_callbatch — this is intended and requires no work.Impact
20 × Nto ~20(flat) on chains with Multicall3.AllowFailure=truekeeps dead/non-conforming tokens producingnilrather than failing the whole request.References
EthereumTypeMulticallAggregate3(bchain/coins/eth/multicall.go), consumerapi/erc4626.go