Skip to content

feat: fetch ERC-20 balances via Multicall3 (per-chain address for Tron) - #1700

Open
cranycrane wants to merge 26 commits into
masterfrom
feat/multicall3-erc20-balances-1683
Open

feat: fetch ERC-20 balances via Multicall3 (per-chain address for Tron)#1700
cranycrane wants to merge 26 commits into
masterfrom
feat/multicall3-erc20-balances-1683

Conversation

@cranycrane

@cranycrane cranycrane commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Closes #1683.

Routes ERC-20 balance lookups through Multicall3 aggregate3: one billable eth_call per chunk instead of N (a JSON-RPC batch is 1 HTTP request but N metered method calls), with every balance pinned to the same block. Falls back per-chunk to the existing JSON-RPC batch path on any failure (chain without Multicall3, transient error, decode/count mismatch), preserving the same-length/order and nil-on-failure contract callers rely on.

Adds a code-set per-chain override (EthereumRPC.Multicall3AddressOverride, empty = canonical) for chains that deploy Multicall3 at a non-canonical address, and wires Tron mainnet's (0x32a4f47a…) in NewTronRPC — code-driven rather than chain-id-detected because Tron's runtime net_version is not the real chain id.

Observability

  • eth_call_requests{mode="multicall"} — new: aggregate3 call volume (the fast path), so the credit savings are measurable. aggregate3 calls were previously counted as mode="single".
  • rpc_fallback_calls{component="erc20_multicall", reason="error"} — unexpected fallbacks to the JSON-RPC batch (the expected "Multicall3 not deployed" case is silent).
  • The deployment probe logs once per process whether Multicall3 was found on the chain.
  • No metrics.yaml change (mode is a dynamic label). Note: the mode="multicall" reclassification also moves the existing erc4626 enrichment's aggregate3 calls from singlemulticall.

Commits

  • feat(eth): add per-chain Multicall3 address override
  • feat(eth): fetch ERC-20 balances via Multicall3 aggregate3
  • feat(tron): fetch ERC-20 balances via Tron's Multicall3
  • feat(eth): label Multicall3 aggregate3 calls as mode="multicall"

Testing: go build ./..., go vet, and unit tests for the resolver/override, aggregate3 decode + fallback, and NewTronRPC wiring all pass. Not yet smoke-tested against a live RPC.

🤖 Generated with Claude Code

cranycrane and others added 7 commits August 3, 2026 11:56
The Multicall3 contract address was a single hardcoded canonical const, so chains that deploy Multicall3 at a non-canonical address could not use the aggregate3 path. Add a code-set override field EthereumRPC.Multicall3AddressOverride (empty = canonical); the resolver multicall3ContractAddress() reads it for both the eth_getCode deployment probe and the aggregate3 call, so the cached deployment verdict refers to the address actually called. The override is set in code at construction (see the Tron commit), not via config.

Refs: #1683
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EthereumTypeGetErc20ContractBalancesAtBlock issued one billable eth_call (balanceOf) per token via a JSON-RPC batch; on metered providers that is ~20xN credits and each call runs at an independent block. Route each chunk through Multicall3 aggregate3 instead (one eth_call, all balances pinned to the same block), with AllowFailure=true so a reverting balanceOf yields nil rather than failing the batch. Fall back per chunk to the existing JSON-RPC batch path on any error (not deployed, transient probe/RPC failure, decode error, or result-count mismatch), preserving the same-length/order and nil-on-failure contract callers depend on. errMulticall3NotDeployed is the expected steady state on non-multicall chains and is excluded from the erc20_multicall fallback metric to avoid alert noise.

Refs: #1683
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tron mainnet deploys Multicall3 at a non-canonical address (0x32a4f47a74a6810bd0bf861cabab99656a75de9e, base58 TEazPvZwDjDtFeJupyo7QunvnrnUjPH8ED), not the canonical one. Apply it in NewTronRPC via EthereumRPC.Multicall3AddressOverride as a Tron protocol constant, alongside the other Tron protocol facts (chain id, token standards), rather than in tron.json. This enables the aggregate3 ERC-20 balance path on Tron; it must be code-driven rather than chain-id-detected because Tron's runtime net_version is not the real chain id (728126428).

Refs: #1683
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
aggregate3 calls flowed through EthereumTypeRpcCallAtBlock and were counted as eth_call_requests{mode=single}, indistinguishable from direct reads. Extract the shared eth_call primitive as ethCallAtBlock(mode) and label aggregate3 batches mode=multicall so the Multicall3 fast path's call volume (and thus the credit savings) is measurable. Also reclassifies the erc4626 enrichment's aggregate3 calls from single to multicall. Label value only — no metrics.yaml change (eth_call_requests mode is dynamic); fallback rate stays on ChainDataFallbacks{component=erc20_multicall}.

Refs: #1683
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Trim the multi-line explanatory comments added for the Multicall3 balance work down to brief why-focused notes; no code changes.

Refs: #1683
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…calls

aggregate3 runs every balanceOf under one shared eth_call gas budget, so an expensive or late sub-call can hit out-of-gas and return Success=false while the batch/single path (an independent eth_call with the full node gas cap) returns the real balance. Success=false was mapped to nil and taken as final, silently dropping a real balance on multicall-enabled chains (notably Tron / low-gas-cap providers). erc20BalancesMulticall3 now reports the Success=false indices, and EthereumTypeGetErc20ContractBalancesAtBlock re-resolves just those via the JSON-RPC batch; a genuine revert still stays nil. Success=true-but-unparseable results (the common dead/airdropped-token case) are left nil without a retry, so the credit win is preserved. Reported in review.

Refs: #1683
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Bound the aggregate3 chunk at multicall3MaxCallsPerAggregate (min with erc20BatchSize) so it stays within the node eth_call gas cap: erc20_batch_size sizes a JSON-RPC batch by request count, not gas, and a raised value must not make aggregate3 fail-and-fall-back every request.
- Gate the multicall attempt on the cached deployment probe, so non-Multicall3 chains no longer build+hex-encode a call slice on every balances request just to discard it when the probe reports not-deployed.
- Count aggregate3 as len(calls) reads under mode="multicall" and feed the eth_call batch-size histogram (like the JSON-RPC batch path), so per-token read volume and batch-size percentiles stay continuous when a chain moves balances onto aggregate3 (the mode="batch" drop is inherent).
- Fix stale doc on EthereumTypeGetErc20ContractBalances (still claimed RPC batch) and reword errMulticall3NotDeployed, which hardcoded "canonical address" (misleading for override chains like Tron).

Reported in review.

Refs: #1683
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@pragmaxim pragmaxim left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed 138b9c32. Verified locally: go build ./..., go vet, and go test ./bchain/coins/eth/... ./bchain/coins/tron/... (150 tests) all pass. Also confirmed the Tron constant is correct — base58 TEazPvZwDjDtFeJupyo7QunvnrnUjPH8ED decodes with a valid checksum to 0x41 + 32a4f47a74a6810bd0bf861cabab99656a75de9e.

The design is sound and the risky part is handled well: because api/worker.go:1298-1305 treats map presence as "already covered, do not single-call", a gas-starved Success=false would otherwise surface as a wrong balance rather than a missing one — the per-element re-resolve is exactly the right guard. Test coverage is targeted at the actual claims (call-count assertions, whole-chunk fallback, length mismatch, chunk bounding, genuine-revert-stays-nil).

Five things worth addressing, left as inline comments. I'd consider (1) and (3) blocking: (1) is a silent per-request cost regression on a hot path, and (3) undercuts the PR's own observability story. (2) is the one to watch in production — most relevant for Tron, where the constant-call energy cap for a 100-element aggregate3 is still unverified against a live node.

Nits not worth inline comments: min(erc20BatchSize, multicall3MaxCallsPerAggregate) couples two limits that bound different things (request count vs gas), so lowering erc20_batch_size for a provider's batch limit needlessly shrinks aggregate3 chunks too (latent — both default to 100); probeMulticall3's doc comment still says "deployed at the canonical address" though it is now override-aware; mode == "" as a "don't instrument" sentinel in ethCallAtBlock would read better as a bool; Multicall3AddressOverride is unvalidated, so a typo degrades to "not deployed" instead of failing loudly; and the balances path probes, then EthereumTypeMulticallAggregate3 probes again (harmless atomic load).

Test gap: no coverage for Success=false without a batcher — the one branch where a user-visible wrong balance is possible by design.

Comment thread bchain/coins/eth/contract.go Outdated

// Prefer Multicall3, but only when the cached probe reports it deployed — otherwise a
// non-multicall chain would build and hex-encode calls on every request just to discard them.
if deployed, _ := b.probeMulticall3(); deployed {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1. Probe failure is not cached, and this path is now hot.

probeMulticall3 deliberately does not cache a transient eth_getCode error ("will retry on next call"). That was fine when only the erc4626 enrichment probed. Now every account-info request with more than one token pays an extra eth_getCode before falling back to the batch — forever — on any chain whose provider errors on or restricts eth_getCode. It is also silent, since the error is discarded here (deployed, _ :=).

Net effect on such a chain: +1 metered call per balances request, permanently, with nothing in the metrics to show it.

Suggestion: a consecutive-failure counter that latches multicall3NotDeployed after K errors, or at least backoff plus a ObserveChainDataFallback("erc20_multicall", "probe_error") so it is visible.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 05436d95. The probe now latches to not-deployed after multicall3MaxProbeFailures (5) consecutive transient errors, so an eth_getCode-restricting provider stops paying a probe per request; and the transient case now emits ObserveChainDataFallback("erc20_multicall", "probe_error") so it is visible.

Comment thread bchain/coins/eth/contract.go Outdated
Comment on lines +576 to +592
// aggregate3 shares one gas budget, so a Success=false element can be a gas-starved
// (not truly empty) balance. Re-resolve those via independent eth_calls (full gas cap);
// a real revert stays nil. Without a batcher we can't, so leave them nil (best effort).
if len(failed) > 0 && hasBatcher {
sub := make([]bchain.AddressDescriptor, len(failed))
for j, idx := range failed {
sub[j] = contractDescs[idx]
}
b.ObserveChainDataFallback("erc20_multicall", "elem_fallback")
if subBalances, berr := b.erc20BalancesBatchChunked(batcher, callData, sub, blockNumber); berr == nil {
for j, idx := range failed {
mcBalances[idx] = subBalances[j]
}
} else {
glog.Warningf("erc20 multicall3 elem fallback failed for %d contract(s): %v", len(sub), berr)
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2. Re-resolve cost on revert-heavy addresses may erase the savings.

Every Success=false element is re-resolved on every request, and the code cannot distinguish gas starvation from a deterministic revert. But ErrInvalidErc20Balance's own comment (contract.go:508-512) says reverting/dead/non-conforming tokens are "benign and common for dead/non-conforming/rebasing tokens" — and those are precisely the tokens that linger in holders' contract lists.

So for an address with, say, 30 such tokens, this path now costs 1 aggregate3 plus a 30-element batch — more metered calls than the old path, on a request that repeats for every account view, with no caching between requests.

Cheap partial mitigation: skip the re-resolve when len(results[i].Data) > 2. A revert carrying reason data or a Solidity Panic(uint256) cannot be an out-of-gas sub-call, so those are known-genuine reverts. Bare revert() / require(false) still returns empty returndata, so this is a filter rather than a proof — but it should remove most of the repeated work while keeping the gas-starvation guard intact.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 05436d95 via your returndata heuristic: only Success=false elements with empty returndata are re-resolved (possible gas starvation). A failure carrying revert returndata (Error/Panic) is a genuine revert and stays nil, so revert-heavy addresses no longer pay a re-resolve batch every request. Covered by TestEthereumTypeGetErc20ContractBalancesGenuineRevertNotReresolved.

Comment thread bchain/coins/eth/contract.go Outdated
for j, idx := range failed {
sub[j] = contractDescs[idx]
}
b.ObserveChainDataFallback("erc20_multicall", "elem_fallback")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4. elem_fallback counts requests, not elements.

This increments once per request regardless of len(failed), so the metric cannot distinguish "one dead token re-resolved" from "80 of 100 re-resolved". That element count is precisely the number that tells you whether the credit savings this PR is measuring are actually being realized, or being eaten by the re-resolve path (see the comment on the surrounding block).

ChainDataFallbacks.With(...).Add(float64(len(failed))) — or a separate histogram of the failed-subset size — would make it actionable.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 05436d95. Added observeChainDataFallback(component, reason, count); the call site now records Add(len(failed)), so the metric counts elements re-resolved rather than requests. (After the comment-2 change failed is only the empty-returndata subset, so this now tracks the potential-gas-starvation volume specifically.)

Comment thread bchain/coins/eth/contract.go Outdated
Comment on lines +667 to +674
for i := range results {
if !results[i].Success {
failed = append(failed, start+i)
continue
}
// nil on unparseable output (empty/short), matching the batch path
balances[start+i] = parseSimpleNumericProperty(results[i].Data)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3. Metric continuity gap in the multicall path.

The batch path emits observeEthCallError("batch", "invalid") when parseSimpleNumericProperty returns nil (contract.go:752) and observeEthCallError("batch", "elem") on per-element failure (contract.go:730). The multicall path emits neither: a nil parse is silent, and Success=false is silent.

Consequence: eth_call_errors{type="invalid"} volume drops off the dashboard the moment a chain flips onto multicall, and there is no signal at all for per-element aggregate3 failures. For a PR whose explicit goal is that "the credit savings are measurable" and that per-token read volume "stays continuous", this is the one place where continuity breaks.

Suggestion: observeEthCallError("multicall", "elem") on !results[i].Success and observeEthCallError("multicall", "invalid") when the parse yields nil, mirroring the batch path.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 05436d95. The multicall path now emits observeEthCallError("multicall", "elem") on !Success and observeEthCallError("multicall", "invalid") on a nil parse, mirroring the batch path so eth_call_errors continuity holds when a chain moves onto aggregate3.

var code string
if err := b.RPC.CallContext(ctx, &code, "eth_getCode", multicall3Address, "latest"); err != nil {
glog.Warningf("multicall3 probe at %s failed: %v (will retry on next call)", multicall3Address, err)
if err := b.RPC.CallContext(ctx, &code, "eth_getCode", addr, "latest"); err != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5. Probe is at latest, but aggregate3 runs at blockNumber.

For a blockNumber below the chain's Multicall3 deployment, eth_call to a then-codeless address returns "0x", which decodeAggregate3Result rejects as "response too short". That surfaces as an error, so it is safe — the caller logs a warning, bumps rpc_fallback_calls{reason="error"}, and falls back to the batch — but it happens on every such request, and it pollutes the "unexpected fallback" metric that is supposed to be quiet.

Impact today is essentially nil: bchain/erc20_batch_integration.go:103 is the only caller passing a non-nil block, and it uses the best block height. But it is a trap for the next caller of ...AtBlock. Worth either a comment here, or skipping the multicall path when blockNumber is non-nil and older than the probe.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documented in 05436d95 rather than adding deployment-block tracking: the only non-nil-block caller uses the best block height, and a historical block below deployment already falls back safely (aggregate3 returns 0x → decode error → batch). Added a comment at the probe explaining the trap for future ...AtBlock callers, and fixed the stale "canonical address" wording in the probe doc.

- probe latch: after multicall3MaxProbeFailures consecutive transient
  eth_getCode errors, cache not-deployed so an eth_getCode-restricting
  provider stops paying a probe per request; surface probe_error fallback
- re-resolve only empty-returndata failures: a Success=false element with
  revert returndata (Error/Panic) is a genuine revert, never gas-starved,
  so it stays nil instead of being re-resolved every request
- per-element multicall metrics: observeEthCallError("multicall","elem"|
  "invalid") mirroring the batch path, keeping error continuity on a chain
  that moves onto aggregate3
- elem_fallback now counts elements re-resolved (Add(len(failed))) not
  requests, so the re-resolve volume is measurable
- document the probe-at-latest vs aggregate3-at-block trap and fix the
  stale "canonical address" probe doc

Tests: genuine-revert-not-re-resolved, empty-revert-without-batcher stays
nil, probe latches after repeated failures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@pragmaxim pragmaxim left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed 05436d95. Verified: go build ./..., go vet ./bchain/... ./api/..., and go test ./bchain/coins/eth/... ./bchain/coins/tron/... ./api/... — 191 tests pass.

All five are resolved, and correctly:

  • (2) revert filterlen(results[i].Data) <= 2 is exactly the right predicate. decodeAggregate3Result always emits "0x"+hex, so length 2 is precisely empty returndata; out-of-gas and invalid-opcode produce empty returndata, while an explicit REVERT carrying Error/Panic data cannot. The Panic(0x11) test asserting a size-1 re-resolve batch pins the distinction well.
  • (3) metricsobserveEthCallError("multicall", "elem"/"invalid") now mirrors the batch path exactly, including counting benign reverts under "elem" the way erc20BalancesBatchAtBlock does.
  • (4) element count — clean; keeping ObserveChainDataFallback as a count=1 wrapper avoids touching other callers.
  • (5) historical blocks — the comment is accurate and sufficient given only the integration helper passes a non-nil block today.
  • Plus the probeMulticall3 doc fix and the no-batcher Success=false test I mentioned as gaps.

One new issue introduced by the fix for (1), inline below. It is not a correctness problem — the code always degrades safely to the batch path — but the failure mode is a silent, permanent loss of the savings this PR exists for, which I think is a worse trade than the extra probe it removes.

Nit: rpc_fallback_calls_total{component="erc20_multicall", reason="elem_fallback"} now counts elements while every other series in that family counts requests, so sum by (component) mixes units. A reason name that signals it, or a separate metric, would avoid a confusing dashboard later.

Otherwise this looks good to merge.

Comment thread bchain/coins/eth/multicall.go Outdated
Comment on lines +126 to +132
// Latch to not-deployed after repeated transient failures so a provider that
// restricts eth_getCode does not incur an extra probe on every request forever.
if b.multicall3ProbeFailures.Add(1) >= multicall3MaxProbeFailures {
glog.Warningf("multicall3 probe at %s failed %d times; disabling multicall, using JSON-RPC batch: %v", addr, multicall3MaxProbeFailures, err)
b.multicall3Probe.Store(multicall3NotDeployed)
return probeResult{}, nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The latch is permanent, and its blast radius is wider than balances.

multicall3ProbeFailures never resets and the K-th failure stores multicall3NotDeployed for the process lifetime. Three things make that a harsher trade than the problem it fixes:

  1. Five consecutive probe rounds is a low bar. A backend restart, or a brief provider 5xx window while the API keeps serving address requests, reaches it easily. Blockbook processes then run for weeks with multicall off — the entire saving this PR exists for, gone until someone restarts the process.
  2. It disables more than balances. probeMulticall3 is shared, so the latch also permanently kills ERC-4626 enrichment: api/erc4626.go:184-187 silently continues on the resulting errMulticall3NotDeployed.
  3. The decisive event is invisible in metrics. See the separate comment on contract.go:573.

Suggestion: re-arm instead of latching. An atomic.Int64 holding a nextProbeAt deadline keeps the "no probe on every request" property while recovering after the outage — on the K-th failure set the deadline ~10 minutes out and return (false, nil) without storing multicall3NotDeployed, then allow one re-probe once it passes. Alternatively, reset multicall3ProbeFailures whenever any other RPC call succeeds.

TestProbeMulticall3_LatchesNotDeployedAfterRepeatedFailures would need adjusting; nothing else in the suite depends on this.

Comment on lines +573 to +576
deployed, probeErr := b.probeMulticall3()
if probeErr != nil {
// Transient probe failure — visible so an eth_getCode-restricting provider is not silent.
b.ObserveChainDataFallback("erc20_multicall", "probe_error")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-on to the latch comment on multicall.go:126: probe_error fires only while probeErr != nil, but the call that actually trips the latch returns (false, nil), so the transition to permanently-disabled emits no metric at all — just the one glog.Warningf. That is the one event you would want to alert on, and it is the only one not counted, which sits a little awkwardly next to the mode="multicall" error metrics added in this same commit.

A distinct reason="probe_disabled" (or "probe_latched") on the latching branch would close it.

pragmaxim and others added 18 commits August 4, 2026 10:04
erc20BalancesMulticall3 aborted on the first failing chunk, so the caller
re-fetched every contract through the JSON-RPC batch. For a 300-token address
whose third chunk failed that cost 200 metered sub-calls plus 300 batch calls
against a 300-call baseline - worse than never using multicall at all.

Return the resolved balances plus two index sets instead of an error: the
Success=false elements to re-resolve, and the elements whose own chunk failed.
The caller settles both in one batch, so a chunk failure only ever costs the
contracts in that chunk.

Holes that no batcher can fill still discard the multicall result, so an
unknown balance is never reported as nil to callers that treat a present
entry as authoritative.

rpc_fallback_calls{component="erc20_multicall",reason="error"} now counts
failed chunks rather than requests, and the warning names the index range.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…trics

Review follow-up to the previous commit.

A failing chunk only continued the loop, so a chain whose eth_call gas cap
cannot fit aggregate3 paid one doomed call per chunk and then re-fetched the
whole list anyway: 10 failing calls plus a 1000-contract batch for a
1000-token address, where before there was one. Chunk failures are
systemic far more often than not, so stop at the first one and mark the
remainder unresolved. Partial results from earlier chunks are still kept,
which is what the previous commit set out to fix.

elem_fallback counted len(reresolve) while the batch it describes covers
reresolve+unresolved, so a chunk-failure fallback recorded zero - the count
hit observeChainDataFallback's count<=0 guard - and the extra round trip was
invisible on cost dashboards. Count the whole subset.

Stopping at the first failure also restores one "error" fallback event and
one log line per request, rather than one per failing chunk, and the log no
longer claims only that chunk's contracts fall back when the whole list does.

Tests, each verified to fail without its fix: systemic failure stops after
one aggregate3; a reresolve hole and a failed chunk in one request map every
result back to its own contract; a chunk failure with no batcher errors
instead of returning unknown balances as nil entries; and the two fallback
counters record the whole subset and one event per request.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
common.GetMetrics registers with the global default registerer, so a
repeated in-binary run (go test -count=2) hit duplicate-registration
errors. Swap in a fresh registry per test, restored via t.Cleanup,
mirroring common/metrics_test.go.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The eth test const duplicated Tron's real Multicall3 address under the
same name, implying it locks the Tron wiring — it cannot; only the tron
package test (against the tronrpc.go const) does. Rename it and use an
obviously arbitrary address so the override mechanism test reads as
what it is.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
decodeAggregate3Result narrowed node-supplied 32-byte offset, length
and count words with int(...) after only an IsUint64 check; a word
>= 2^63 wraps negative, slips past the later bounds checks and panics
on slicing (or a negative make). Bound every word by len(raw) before
narrowing — nothing larger can address data inside the response — so a
malformed response errors into the batch fallback instead of crashing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The len(results) != len(calls) sanity check lived only at the erc20
balance call site, leaving the erc4626 enrichment call sites exposed to
a silent result misalignment. Enforce it inside
EthereumTypeMulticallAggregate3 so every caller gets the guarantee, and
drop the call-site copy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Five consecutive transient eth_getCode failures — e.g. a node briefly
down at startup — permanently stored multicall3NotDeployed, disabling
Multicall3 (including the unrelated ERC-4626 enrichment) for the
process lifetime and collapsing the transient/permanent distinction
into a false "not deployed on this chain". Suspend probing for a
10-minute window instead: an eth_getCode-restricting provider still
stops paying a probe per request, and probing self-heals once the
window elapses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The mode-labeled eth_call counter reports one request per sub-call
(deliberately, to keep per-token read volume continuous with the batch
path), so the many-sub-calls-per-one-metered-eth_call reduction that
motivates aggregate3 was invisible, and no metric counted actual
aggregate3 requests. Add a blockbook_eth_call_multicall_requests
counter incremented once per physical aggregate3 eth_call; it also
pairs with eth_call_errors{mode="multicall",type="rpc"} as a matching
denominator.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
min(erc20BatchSize, multicall3MaxCallsPerAggregate) coupled a gas
budget to a knob that exists for provider JSON-RPC batch-count limits;
with the default of 100 the min could never fire, and a chain
configured with a smaller batch size would pointlessly shrink a
single-request aggregate3. Chunk at the gas-bound const directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
encodeAggregate3 fails whole-chunk on a single non-address descriptor,
and the chunk loop treats any error as systemic: the chunk and every
later one were marked unresolved, disabling multicall for that address
on every request. Filter non-20-byte descriptors up front instead —
they stay nil, exactly what the batch path produces for a bogus `to` —
and chunk over the valid indexes only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two paths could surface an unknown balance as an authoritative nil
entry, which callers treat as "no balance":

- without a batcher, a possibly gas-starved (empty-returndata)
  Success=false element was silently left nil. Single eth_calls need
  no batcher, so settle those holes with individual calls, mirroring
  the batch path's single-call fallback semantics.
- if the merge fallback batch errored, mcBalances was returned anyway
  with chunk-failure holes as nil — exactly what the sibling no-batcher
  branch refuses. Propagate the error instead (unreachable today, kept
  defensive).

Also name the real failure when a deployed Multicall3 leaves holes no
batcher can fill, instead of the misleading "BatchCallContext not
supported".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A deployed Multicall3 whose aggregate3 eth_call always fails (eth_call
gas cap, node quirks) cost every address request a doomed eth_call plus
a warning log, forever. After 5 consecutive requests in which
aggregate3 resolved nothing, suspend the erc20 multicall balance path
for 10 minutes (counted under the "suspended" fallback reason) and let
the JSON-RPC batch serve balances; any resolving request closes the
breaker. Scoped to the balance path only — the deployment probe and
other Multicall3 users such as the ERC-4626 enrichment are untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Trim the multi-line explanatory comments added for the aggregate3
re-resolve, breaker and probe-suspension work down to brief why-focused
notes; no code changes.

Refs: #1683
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…aths

The erc20 balance path grew three structures that guard states it
cannot reach:

- the reresolve/unresolved split, canFillHoles and the error return on
  a failed merge batch all rest on erc20BalancesBatchAtBlock returning
  an error, which it never does: every path returns (balances, nil),
  absorbing a batch-level RPC failure into per-element single calls. So
  no unknown hole could ever have leaked as nil through that branch.
  One hole set, settled by the batch, with plain error propagation.
- every !hasBatcher branch inside the multicall path was dead. Each
  EVMRPCClient either embeds *rpc.Client or forwards BatchCallContext,
  so only test mocks lack it. Gate the whole function on the batcher up
  front, as before the multicall path existed, and drop the per-contract
  single-call loop and its bespoke error.
- the erc20 breaker needed a deployed Multicall3 whose aggregate3 fails
  on five consecutive requests, to save one eth_call per request while
  results stayed correct throughout. Its anyResolved signal also
  misread a fully failed chunk as resolved whenever a malformed
  descriptor was present. Removed; the probe breaker is untouched.

elem_fallback now counts element-level holes only, emitted beside the
"error" reason it belongs with, so a systemic chunk failure is no longer
counted twice — once per request as "error" and once per contract.

Behaviour is unchanged on every reachable path. Gas starvation, the
reason holes exist at all, stays guarded: eth_call carries no gas, so a
node's RPCGasCap (geth default 50M) covers a 100-call chunk many times
over, leaving Tron's constant-call energy cap as the open question.

Refs: #1683
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
counterValue hardcoded the label name to "action" while counterVecValue
took it as a parameter, so the package carried two readers doing the same
job. Keep the general one and move it to an untagged file, since only the
unittest-tagged tests could reach it before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The old note justified "in code, not config" with net_version not being
the real chain id, which argues against auto-detection rather than against
config. State the actual reason: the address is a property of the coin.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cleanup pass over the aggregate3 balance code, no behaviour change.

Instrumentation: ethCallAtBlock took a mode="" sentinel so the multicall
caller could skip it and count len(calls) itself. It now takes subCalls, so
the primitive owns the mode label and the rpc-error label and no caller can
opt out; anything added there cannot silently miss the largest calls.

State: the four loose multicall3Probe* fields on the shared EthereumRPC
struct become one multicall3Gate value in multicall.go, and the aggregate3
chunk bound moves next to the primitive whose gas budget it describes.

Allocations, per 100-call chunk: encode 609 -> 205, decode 605 -> 202.
One wordAsIndex helper replaces four copy-pasted bounds checks and drops the
big.Int per ABI word; the per-element error counters are tallied and emitted
once instead of rebuilding a label map per held token.

Tests: drop the byte-for-byte copy of common/metrics_test.go's registry swap
and build the collectors under test by hand; two of the four aggregate3
mocks were subsumed by the others.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
decodeAggregate3Result checked every offset and length against len(raw) but
never their total, and nothing required head offsets to be distinct. A
response could therefore declare len(raw)/32 elements whose heads all alias
one large tuple, and each element passed its own bounds check while the set
of them decoded far past the response. The result count was only compared
with the request after the full decode had already been materialized.

Measured on the previous commit: a 0.16 MB response decodes into 132.91 MB
of hex strings with no error. Output grows with the square of the input, and
the eth_call response itself is uncapped (geth's HTTP client hands back
resp.Body unwrapped), so one reply from a hostile or buggy node is enough to
OOM the process. This PR put the per-address balance path through it.

The decoder now takes the number of sub-calls sent and rejects any other
count before touching the tails, and it tracks cumulative returndata against
len(raw) so aliased heads cannot amplify. A canonical response lays its
tuples out disjointly, so neither guard can reject one. The same 20k-head
aliasing payload that would decode 7.63 GB is now refused after 4.46 MB.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants