Skip to content

eth: fetch ERC-20 balances via Multicall3 aggregate3 (with per-chain Multicall3 address for Tron) #1683

Description

@cranycrane

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions