Skip to content

eth: fix alt-provider quota burn (#1629), lingering cancelled/replaced private txs (#1573) and trezor-suite#30846 - #1638

Open
pragmaxim wants to merge 25 commits into
masterfrom
fix/eth-alt-provider-1573-1629
Open

eth: fix alt-provider quota burn (#1629), lingering cancelled/replaced private txs (#1573) and trezor-suite#30846#1638
pragmaxim wants to merge 25 commits into
masterfrom
fix/eth-alt-provider-1573-1629

Conversation

@pragmaxim

@pragmaxim pragmaxim commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Fixes #1629 and #1573 in the EVM alternative send-tx provider, plus hardening from an xhigh review of the surrounding subsystem. Also the backend counterpart to trezor/trezor-suite#30846 — a wallet told "send failed" while Blockbook has already broadcast risks a double payment when the user retries.

Follow-up https://gist.github.com/pragmaxim/f71b03a4ed4c0030b6cba7fb0a302e96

#1629 — gas estimates burned the relay's rate quota

EthereumTypeEstimateGas routed every eth_estimateGas to the alternative provider (always urls[0], errors swallowed). The WS estimateFee handler fires on every send-form keystroke, so concurrent users drained a private relay's quota even without using private txs. Now routed only for recent private senders (useForNonces + nonceURL), matching the nonce path; others hit the primary backend. Provider errors log and fall back. New eth_alternative_estimate_gas_requests_total metric + Grafana panel.

#1573 — cancelled / replaced private txs lingered until timeout

A Blink drop-mode cancel or an accepted-but-unsurfaced RBF replacement left the superseded tx "Unconfirmed" for up to the 5-min cache timeout, because eviction waited on a fetch-back that never came. Now a same-(from, nonce) predecessor is retired the moment the relay ACKs its replacement (decoded from the raw hex), independent of fetch-back — deliberately distinct from a non-authoritative empty getTransactionByHash probe.

Counterpart to trezor-suite#30846 — a relay-accepted send must never look failed

The suite PR gives a transaction push its own 60 s deadline and stops showing "send failed" when the backend is merely slow. Both only hold if Blockbook answers within the wallet's budget and surfaces every send the relay accepted, whatever the relay does afterwards. The issues fixed here:

  • an accepted send is cached from its own signed bytes — an accepted send used to be cached only if the post-send eth_getTransactionByHash round-trip surfaced it; a relay that stopped surfacing (or never surfaced) what it accepted left the send exposed nowhere — not served as pending, not in the address index, not raising the pending-nonce floor — so a wallet that retried re-signed at the next nonce and both transactions mined. The signed bytes carry everything a pending transaction needs, so the send path now decodes once and caches + indexes the transaction before the relay is ever asked; the fetch-back only refreshes that entry with the relay's own view.
  • the send path stays inside the wallet's deadline — the broadcast to every configured relay used to run sequentially, so one unresponsive relay held the wallet's answer for its full rpc_timeout before the next URL was even tried, and the post-send fetch-back sat in the wait too. Both are gone: the broadcast runs concurrently (worst case a single rpc_timeout, not N), and the fetch-back runs in the background, so the wallet's own deadline is no longer consumed by relay handling.
  • the background fetch-back updates, never re-creates — now that the fetch-back runs off the send path, its answer can straddle a block; re-inserting the pending body over a transaction that mined and was cleared by block sync would flip it back to Unconfirmed and re-index it as pending. It only updates a still-cached entry (or evicts on a mined answer), capped at 16 in flight so a burst of accepted sends cannot grow goroutines, sockets or relay-quota usage without bound.

Review-driven hardening (same subsystem)

  • estimate-gas fallback: build the primary-RPC deadline after the alt round-trip so a slow relay can't hand the fallback an already-expired context; also fall back (not error) on a malformed provider result.
  • rejected send: skip the mempool-cache path when no relay accepted, so a rejected send no longer fans out eth_getTransactionByHash(0x0…0) and logs a spurious error.
  • metrics gating: lifecycle counters/residence recorded only by the goroutine that actually removed the entry — no more double-counting across concurrent reconcile / read-path / RBF evictions.
  • generation ordering: an older submission's slow fetch-back can no longer evict or shadow a newer same-nonce replacement.
  • read-path eviction: routes through removeMempoolTx, so an expired private tx is cleared from the wrapped mempool even when the caller's primary lookup errors.

Docs

docs/evm-send.md — Mermaid flowchart of the broadcast + pending-tx cache lifecycle; fee estimation cross-linked to fees.md; what a relay-accepted send guarantees (see above) stated precisely.

Testing

go build ./..., go vet, and the eth + common suites pass; each fix ships a regression test.

🤖 Generated with Claude Code

@pragmaxim
pragmaxim force-pushed the fix/eth-alt-provider-1573-1629 branch from fa0e5dc to 696adbf Compare July 21, 2026 19:21
pragmaxim and others added 8 commits July 23, 2026 06:08
…enders

EthereumTypeEstimateGas routed every eth_estimateGas through the alternative
send-tx provider whenever one was configured, always via urls[0], and silently
swallowed provider errors. The WebSocket estimateFee handler calls this on every
send-form keystroke in trezor-suite (see sendFormEthereumThunks), so a handful of
concurrent users draining a ~10 req/s private relay exhausted its rate quota even
though they never used private transactions - the users who actually need the
relay (pending private-tx estimates) then saw degraded service (#1629).

Route eth_estimateGas to the provider only for a sender that recently sent a
private tx through it (useForNonces), and to the URL that accepted that send
(nonceURL) - mirroring the nonce path. Estimates without a from address, or from
any non-recent sender, go straight to the primary backend, which is authoritative
for gas estimation in that case. Provider errors now log at warning level and
fall back to the primary RPC instead of being discarded. New
eth_alternative_estimate_gas_requests_total metric counts the gated subset by
result so the quota impact is observable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ch-back

Cancelling or RBF-replacing a pending private transaction through a relay left
the superseded predecessor showing "Unconfirmed" for up to the 5-minute cache
timeout (#1573). Predecessor eviction lived inside handleMempoolTransaction and
only ran after eth_getTransactionByHash surfaced the replacement, so two cases
fell through: a Blink drop-mode cancel is never surfaced at all (and its nonce
is never consumed on-chain, so neither the mined nor nonce_superseded early
evictions fire), and an accepted-but-unsurfaced RBF replacement returned from
handleMempoolTransaction before reaching the removal block.

A successful eth_sendRawTransaction acceptance is itself positive, irreversible
proof that any cached transaction with the same (from, nonce) can no longer
mine. Decode the sender and nonce from the raw hex and retire the predecessor at
acceptance time, independent of whether the relay ever surfaces the replacement.
This is deliberately distinct from an empty getTransactionByHash probe, which is
NOT authoritative (21a868f stopped evicting on it to avoid deleting still-
mineable Blink txs) - here the ACK of a same-nonce replacement is the fact, not
the predecessor's absence. The 5-minute timeout remains the backstop.

The eviction is extracted into evictReplacedByNonce, matching by decoded address
and numeric nonce (robust to provider hex casing / zero-padding) and shared with
handleMempoolTransaction, so the surfaced path counts the fee-replacement exit
exactly once and reaching handleMempoolTransaction directly still evicts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Follow-up polish from the multi-agent review of the #1629/#1573 changes:

- EthereumTypeEstimateGas counted "success" the instant the alternative
  provider returned err==nil, before decoding the hex quantity, and returned
  any decode error straight to the caller instead of falling back. Decode
  first: a non-decodable result is a provider failure, so count "error", log,
  and fall back to the primary RPC - keeping the success label honest for the
  quota dashboard and the fallback behavior consistent with a transport error.

- Add the Grafana panel for eth_alternative_estimate_gas_requests_total next to
  the nonce-requests panel (same subsystem), mirroring fcb5ce1: panels.yaml +
  template.json with x-panel-key sync.eth_alternative_estimate_gas_requests;
  render_grafana.py --check passes (grafana.json is generated, not committed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ejected send

Two defects surfaced by the xhigh subsystem review, both in functions this
branch already touched:

- EthereumTypeEstimateGas created its context at function entry and reused it
  for the primary-RPC fallback. A slow or rate-limited alternative-provider
  round-trip (the exact #1629 scenario) runs on its own deadline and can consume
  most of b.Timeout, so the fallback could hit an already-expired context and
  return "context deadline exceeded" even with a healthy primary backend. Build
  the fallback deadline after the alternative attempt instead.

- SendRawTransaction ran the mempool-cache path (handleMempoolTransaction)
  even when no relay accepted the send: with an empty txid it fanned out
  eth_getTransactionByHash(0x0..0) to every provider and logged a spurious
  "did not find txid" error on every rejected (underpriced / nonce-too-low)
  send, burning the quota the #1629 gating protects. Gate the whole cache path
  on acceptedURL, matching the RBF-eviction guard beside it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The reconciliation-event counter and residence histogram were observed
unconditionally, before (and regardless of) the entry actually leaving the
cache. reconcileMempoolTxs works off a snapshot taken at cycle start and then
does per-tx network probes without the lock, so the read-path timeout eviction
(GetTransaction) or a concurrent RBF eviction can remove and meter an entry
while reconcile still holds its stale copy — then reconcile meters the same
txid again under a second action (timeout/mined/provider_missing), and residence
is sampled twice. The dashboards #1562 added to detect premature eviction thus
over-count and mislabel exits.

Make the cache delete the single source of truth: RemoveTransaction and
removeMempoolTx now report whether the entry was actually present, and
evictMempoolTx / evictReplacedByNonce / the GetTransaction read-path record the
event + residence only when this call is the one that removed it. removeMempoolTx
performs the authoritative provider-cache delete first and invokes the wrapped-
mempool delegate only on a real removal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… arrival

Two concurrent same-nonce sends from one wallet - tx A then an RBF speed-up B -
run in independent SendRawTransaction goroutines and each blocks on an
eth_getTransactionByHash fetch-back. If A's fetch-back returned last,
handleMempoolTransaction re-inserted the obsolete A and its evictReplacedByNonce
removed B - the higher-fee tx that will actually mine - so Blockbook exposed the
replaced lower-fee tx to sender and recipient until reconcile cleaned it up
minutes later. The send generations that exist to order these events were never
consulted on the insert/evict path.

Consult them now:
- evictReplacedByNonce takes the replacement's generation and leaves a strictly
  newer cached entry intact, so an older send cannot evict a newer replacement.
- handleMempoolTransaction skips a fetch-back whose (from, nonce) slot is already
  held by a strictly newer generation, so a stale submission neither caches
  itself (no duplicate pending tx for one nonce) nor reaches the eviction.

Generation 0 (undecodable sender / legacy entry) means unknown order and never
wins a comparison, preserving the previous generation-agnostic behavior for
those. This also closes the concurrent-same-nonce "both evicted" window noted in
the earlier concurrency review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GetTransaction's read-path staleness eviction called RemoveTransaction, which
clears only the alternative-provider cache, whereas every reconcile/RBF eviction
routes through removeMempoolTx and its removeTransactionFromMempool delegate that
also clears the wrapped Blockbook mempool's per-address index. The gap was masked
because the caller (EthereumRPC.GetTransaction) then queries the primary RPC and,
on a null result, cleans the mempool itself - but when that primary
eth_getTransactionByHash call errors instead of returning null, it returns early
without cleanup, leaving the expired private tx listed as pending for the address
until the 10-minute mempool sweep.

Route the read-path eviction through removeMempoolTx like the others. Safe from
re-entrancy: GetTransaction holds no mempool lock when calling the provider, and
MempoolEthereumType.RemoveTransactionFromMempool takes only its own mutex.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add docs/evm-send.md: how Blockbook broadcasts a Trezor Suite EVM transaction
through the private send-tx relay and reconciles its own pending-transaction
cache (RBF/cancel eviction on relay ACK, generation ordering, single-metered
removal, background reconcile vs read-path eviction), with a Mermaid flowchart
and the cache-lifecycle metrics. Fee estimation is cross-linked to fees.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@pragmaxim
pragmaxim force-pushed the fix/eth-alt-provider-1573-1629 branch from 696adbf to b8d7460 Compare July 23, 2026 06:08
pragmaxim and others added 3 commits July 23, 2026 07:03
…ement

evictReplacedByNonce skipped a same-(from,nonce) cache entry only when
newerGen(entry.gen, keepGen) held. newerGen is false whenever either side is 0,
so a keeper with an unknown send generation (keepGen==0 - raw-hex sender recovery
failed at send time, though the fetched tx still decoded) evicted a genuinely
newer, generation-carrying replacement: Blockbook then surfaced the stale
lower-fee tx that will not mine and dropped the one that will, reporting a wrong
pending tx and pending nonce.

Skip on `entry.gen > keepGen` instead. For keepGen>0 this is exactly newerGen
(so the acceptance-time RBF eviction and its tests are unchanged); for keepGen==0
it protects every generation-carrying replacement while still evicting equally
unordered (gen==0) predecessors, so the #1573 acceptance-time cleanup keeps
working. Regression tests cover both keepGen==0 branches.

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

handleMempoolTransaction released mempoolTxsMux after caching the tx and then
called AddTransactionToMempool unconditionally, which does a backend RPC
round-trip. A concurrent higher-generation send for the same (from,nonce) could,
in that window, run evictReplacedByNonce and delete the txid from both the
provider cache and the wrapped mempool; the add then re-inserted it into the
wrapped mempool only. reconcileMempoolTxs walks only the provider cache, so that
orphan lingered as "Unconfirmed" until the 10-minute sweep - the #1573 symptom.

Re-read the provider cache after the add (lock held only for the map read, never
across a network call) and undo the wrapped-mempool add when the txid is gone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The accepted-send path ran ECDSA sender recovery twice per broadcast: once inside
registerSuccessfulSend and again in the RBF-eviction block. Decode (from, nonce)
once from the raw hex and reuse it for both; registerSuccessfulSend now takes the
decoded sender, and the single-caller alternativeTxSender helper is removed.
Behavior on decode failure is preserved exactly (gen stays 0, no eviction runs,
handleMempoolTransaction is still called with gen=0); only one duplicate warning
log is dropped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
pragmaxim and others added 2 commits July 28, 2026 08:00
- Same-nonce staleness check was asymmetric with the eviction rule:
  handleMempoolTransaction used newerGen (both generations must be non-zero)
  while evictReplacedByNonce uses a plain `>`. A gen-0 submission then neither
  skipped itself nor let the other entry be evicted, so both transactions stayed
  cached for one (from, nonce) slot until the cache timeout - master removed the
  predecessor unconditionally. Use `>` in both places; newerGen had no other
  caller and is dropped.

- Acceptance-time retirement was not durable. It scans only the cache, so it
  finds nothing while the predecessor's fetch-back is in flight; if the
  replacement's own fetch-back then returns null (Blink drop mode) no entry ever
  exists to order the predecessor against, and it was cached afterwards and
  surfaced as Unconfirmed - the #1573 symptom, in the window the fix targeted.
  Record the (sender, nonce) slot each accepted send fills and drop a superseded
  fetch-back on arrival. Keyed per slot, not per sender: recentSenders is keyed
  by address only, so weighing against the sender's latest generation would also
  discard a legitimate fetch-back for a different nonce.

- Cache exits performed by block sync were counted nowhere. evictMempoolTx
  meters only the goroutine whose own delete removed the entry (622dbef), but
  removeTransactionFromMempool -> RemoveTransaction meters nothing, and block
  sync almost always beats the next reconcile probe. Meter that path as
  sync_removed instead of ungating evictMempoolTx, which would reopen the
  double-count. sync_removed is expected to dominate and mined to be rare; the
  metric help, Grafana descriptions and docs say so.

- Nothing checked that the provider cache expires before the wrapped mempool.
  Inverted, the mempool's timeout sweep - the one exit that does not clear the
  cache - drops a private tx's address index while the cache still serves it as
  pending. Warn from CreateMempool, where both effective retentions are known.

Each fix ships a regression test verified to fail without it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs/evm-send.md draws only the private path, but its removal funnel clears the
wrapped Blockbook mempool too, and that store is never shown - so neither its
own ingest (newPendingTransactions feed, resync snapshot) nor its own exits
(block connect, timeout sweep, backend-missing removal) are visible, and nor is
the fact that a private tx lives in both stores at once.

Three diagrams: the broadcast/ingest/eviction lifecycle across both stores, the
per-entry reconcile ladder, and the serve path that combines them. Prose is
limited to the store comparison table and the two load-bearing couplings - a
private tx must be cached before the mempool add can index it, and the cache
must expire before the mempool.

The reconcile decisions are a separate diagram rather than a subgraph in the
first: nodes with no edges between them share a dagre rank and lay out in one
very wide row, which a subgraph does not constrain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@pragmaxim
pragmaxim force-pushed the fix/eth-alt-provider-1573-1629 branch from 5cb22dd to a698e2c Compare July 28, 2026 06:01
Adds signals that reveal a private send stuck Unconfirmed or a nonce pinned
above a dead on-chain gap - the failure modes the alt-provider cache can
otherwise hide until the 5-min timeout.

New metrics:
- eth_alternative_mempool_oldest_age_seconds (gauge) - age of the oldest still
  -cached entry, sampled per reconcile cycle. The residence histogram records an
  age only on exit, so a stuck tx is invisible until it times out; this exposes
  it live. Climbing toward the timeout at non-zero depth = txs dying underpriced.
- eth_alternative_send_accepted_but_uncached_total{reason} - a relay-accepted
  send whose fetch-back never surfaced (not_found/error), so it is cached and
  indexed nowhere and does not raise the pending-nonce floor; a later send can
  reuse its nonce. The precursor to a nonce-reuse hang, previously invisible.
- eth_alternative_pending_floor_raised_total{source} - raiseToPendingFloor
  lifted the reported pending nonce above the backend answer (provider: relay
  already dropped the still-cached tx past its ~1-min window; primary: fallback
  RPC never knew it). The precursor to queueing behind a dead nonce.

Also recalibrates the residence histogram buckets to a sub-420s set (entries
cannot live past ~360s = 5-min timeout + 1-min reconcile, so the old top 5
buckets never filled and fast-chain confirmations collapsed into le=30), and
adds four Grafana panels: oldest-age, accepted-but-uncached, floor-raised, and
a death-ratio panel derived from the existing reconciliation counter (no new
metric). Panels note that empty is expected on coins without a relay.

All three metrics carry the existing coin const-label. Metric wiring covered by
tests (accepted_but_uncached end-to-end, oldest-age gauge); render_grafana
--check passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pragmaxim and others added 4 commits August 5, 2026 04:25
… bytes

An accepted private send was only cached and indexed if the relay then returned
it from eth_getTransactionByHash. When that fetch-back came back empty or
errored, the transaction existed nowhere in Blockbook: not served as pending,
not in the address index, and not raising the pending-nonce floor - so the
sender's next send could be handed the very nonce still in flight. The PR that
added eth_alternative_send_accepted_but_uncached_total made that hole visible;
this closes it.

Everything a pending RpcTransaction carries is already in the signed bytes, so
the send path now decodes the accepted raw hex once and caches the body it
derives (decodeAlternativeSendTx), before asking the relay about it. The
fetch-back is demoted to a refresh with the relay's own view, and the cache key
is the hash of the signed bytes rather than the relay's echo - what the chain
will show - with a mismatch logged rather than silently trusted.

- cacheMempoolTransaction extracted from handleMempoolTransaction, so both the
  send path and the fetch-back run the same generation-ordered insert, the same
  predecessor retirement and the same wrapped-mempool indexing.
- handleMempoolTransaction now skips caching a body the relay reports as mined;
  block sync owns it from there and re-inserting it would surface a mined
  transaction as pending.
- accepted_but_uncached keeps its name and labels but no longer means "exposed
  nowhere" - it now means "the relay does not surface what it accepted", so the
  entry can only be reconciled by the cache timeout. Metric help, Grafana panel
  and both EVM send docs updated accordingly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Trezor Suite gives a websocket request 20 s (packages/websocket-client
DEFAULT_TIMEOUT) and, on expiry, rejects every in-flight request and closes the
socket. Blockbook's private send could take far longer: the broadcast walked the
relay URLs sequentially, each with its own rpc_timeout (25 s on ETH), and then
waited for a post-send eth_getTransactionByHash fan-out on top. One unresponsive
relay was enough to cross the deadline, and the user is then told a transaction
failed while it is on its way to the chain - a re-send at the next nonce pays the
recipient twice.

Two changes, no protocol or configuration change:

- Broadcast to every configured relay concurrently. Results are collected in URL
  order, so which URL counts as the accepting one, the returned txid and the
  error aggregation are exactly as deterministic as the sequential loop, but the
  wall time is the slowest single URL instead of the sum. Redundancy is kept: all
  URLs still receive the transaction.
- Run the fetch-back in the background. Since the previous commit it only
  refreshes a body that is already cached, so no observable outcome depends on
  it. Ordering stays correct: it carries the send's generation, so a concurrent
  higher-generation send for the same nonce slot makes the refresh a no-op
  through slotSupersededBy / the obsolete scan, exactly as for a slow fetch-back
  before.

Both goroutines recover panics - they outlive the request handler whose own
recover cannot protect them - and a pre-set error result keeps a recovered
broadcast from reading as an acceptance. waitForRefreshes lets tests observe a
deterministic cache state; production never waits for a refresh.

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

Review of the two previous commits found the invariant they both rest on - "the
fetch-back can only refresh a body that is already cached" - asserted in the
commit message and the docs but enforced nowhere: cacheMempoolTransaction was an
unconditional upsert. Reproduced deterministically: the send caches the tx, the
tx mines a block later and block sync clears both stores (metered sync_removed),
then the fetch-back answers - up to rpcTimeout per URL after the wallet was told
the send succeeded, which straddles a block on every chain we index - and a relay
that still reports the tx as pending gets it re-inserted and re-indexed. A
confirmed transaction flips back to Unconfirmed, its address gains a duplicate
pending row, a second NewTx is pushed and the cache exit is counted twice. On
Base (2 s blocks) and Arbitrum (250 ms) this is not a narrow race.

refreshCachedTransaction replaces that path: it updates the body of an entry that
is still present and still carries this send's generation, keeps its insertion
time (so retention, the reconcile freshness window and the residence metric keep
measuring from the broadcast), and evicts instead of refreshing when the relay
reports the transaction mined - the same decision reconcileMempoolTxs makes on
the same signal. It also refuses to replace a decodable body with one that does
not identify the same (from, nonce) slot: the derived body is what keeps the
entry visible to the pending-nonce floor, to releaseRecentSender and to the
nonce-superseded check, and a relay omitting `from` would silently undo that.
handleMempoolTransaction keeps create semantics for its one remaining caller,
the raw hex that does not decode, where nothing was cached to update.

Also from the review:

- decodeAlternativeSendTx no longer panics on an unprotected (pre-EIP-155)
  transaction. Its ChainId() is a non-nil zero and LatestSignerForChainID panics
  on that, which would have aborted the answer to a wallet whose transaction a
  permissive relay had already accepted - the exact failure this series prevents.
  Such a transaction is Homestead-signed; derive its sender that way.
- SendRawTransaction returns the hash of the signed bytes, not the relay's echo.
  Everything else was already keyed on it, so on a mismatch the wallet was handed
  a txid Blockbook has no entry for - and Suite keys its own optimistic pending
  entry on that value, so it would have shown two pending rows for one send. The
  echo comparison is case-insensitive, so upper-case hex is not an error.
- The background fetch-backs are capped (maxBackgroundFetchBacks) and refuse to
  start after shutdown. The send path no longer applies backpressure, so a burst
  of accepted sends could otherwise grow goroutines, sockets and relay-quota use
  without bound; dropping one is safe because reconcile revisits every entry
  within a minute. Their tracking moved from a reused sync.WaitGroup (where Add
  races Wait, panics and leaves the counter stuck) to a counter with a Cond.
- The cache insert moved into insertMempoolTx, which defers its unlock and
  lazily creates the map. With the fetch-back now running under a recover, a
  panic inside that critical section would have deadlocked every send, read,
  reconcile and nonce-floor lookup instead of crashing the process.

Tests: the two wall-clock assertions are gone. Concurrency is now proven by a
barrier the relays only release once all broadcasts are in flight (mutation
testing showed the old 750 ms margin accepted a broadcast capped at 2-way
concurrency), and the fetch-back's independence by holding its response on a
channel and asserting while it is provably still in flight. New coverage for the
resurrection guard, the mined-answer eviction, the body-mismatch rejection, the
undecodable-hex fallback, the not-surfaced error label, the signed-bytes cache
key, access-list transactions, unprotected transactions, unrecoverable
signatures, and an unresponsive relay URL. Three tests that had become
assertions about the background goroutine now check the synchronous guarantee
before waiting for it.

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

The metric this series added, eth_alternative_send_accepted_but_uncached_total,
now asserts the opposite of what it measures: the transaction IS cached, from its
own signed bytes. It is unreleased, so rename it to
eth_alternative_send_not_surfaced_total while that is still free - what it counts
is a relay that does not report back what it accepted, which leaves the entry
reconcilable only against the cache timeout. The panel key follows; the Go doc
comment that still described the old meaning is corrected.

Accuracy fixes the review found in the text the previous commits added:

- "the transaction is still cached and indexed" and "the fetch-back can only
  refresh an already-cached body" are true only when the raw hex decodes. On the
  decode-failure branch nothing is cached and the fetch-back is the only thing
  that can expose the transaction - stated now in the metric help, the panel
  description and docs/evm-send.md.
- What is cached is what a relay ACCEPTED, which is not what will mine. A
  drop-mode cancel holds its nonce slot, and with it the pending-nonce floor, for
  the full cache timeout, so a wallet's next send is built above a nonce nothing
  will consume until the floor falls. That is the accepted side of the #1638
  trade - a gap that self-heals beats handing out a nonce that is in flight - and
  it was documented nowhere. It is also a benign cause of two panels an operator
  pages on (oldest cached age, exit death ratio) and of cache depth, so all three
  now say so.
- The residence-histogram wording was inverted: clustering near the cache timeout
  is the healthy shape, clustering at ~1-2 min is the #1573 premature-eviction
  regression, which is what the Grafana panel already said.
- docs/env.md still claimed an empty eth_getTransactionByHash response removes the
  pending entry; that stopped being true at #1573.
- Both diagrams: the refresh edge says it updates rather than re-creates, and the
  provider_missing node in evm-send.md is no longer an orphan box.

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

The dashboard render fails on the PR merge commit, not on the branch: CI builds
the branch merged with master, and master has since claimed ids 350 and 351 for
websocket.blocked_ips / websocket.blocked_connections (the per-IP rate-limit
work). This branch had handed the same two ids to
sync.eth_alternative_estimate_gas_requests and
sync.eth_alternative_mempool_oldest_age, so the merged dashboard carried two
duplicate panel ids - which Grafana rejects at import time and the render check
therefore rejects here.

Renumber the five panels this branch adds into a contiguous 352-356 block, above
master's highest id. Panel ids are Grafana's own identity for a panel and carry no
meaning of ours: the join keys the render uses are x-panel-key and x-query-key,
both untouched, so no title, query or layout changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@pragmaxim
pragmaxim requested a review from cranycrane August 5, 2026 07:37
@pragmaxim pragmaxim changed the title eth: fix alt-provider quota burn (#1629) and lingering cancelled/replaced private txs (#1573) eth: fix alt-provider quota burn (#1629), lingering cancelled/replaced private txs (#1573) and trezor-suite#30846 Aug 5, 2026

@cranycrane cranycrane 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 at 53ee56fd. The design is sound and the three fixes are real — caching from the signed bytes is the right call, and the nonce-pin trade-off it accepts is correctly documented and instrumented. go build/go vet/gofmt clean, eth + common suites pass, -race -count=30 on the concurrency tests clean, merges cleanly on current master.

Worth calling out as a bonus: this removes a real pre-existing panic. In geth v1.17.0 newModernSigner panics on chainID == nil || chainID.Sign() <= 0, and LatestSignerForChainID routes every non-nil chain id into it — including the zero an unprotected pre-EIP-155 legacy tx decodes to. master's alternativeTxSender therefore panicked on the send path for such a raw tx. The new HomesteadSigner guard is correct, and it cannot silently recover a wrong sender (signature validation rejects v ∈ {35,36} rather than returning a garbage address).

Below are the major findings only; medium/minor ones (stale comments, doc inaccuracies, test gaps, bucket change, comment density) are separate.


1. The cached tx body is shared by pointer and mutated with no lock

alternativesendtx.go:778 returns the live storedTx.tx. ethrpc.go:1859 then calls EthTxToTx(..., fixEIP55=true), which writes tx.From/tx.To in place (ethparser.go:137,143) holding no lock — while pendingNonceFloor:541, txSenderAndNonce (via insertMempoolTx:677, evictReplacedByNonce:720) and refreshCachedTransaction:347 read those same fields under mempoolTxsMux. Reproducible under -race.

Pre-existing on master, but this PR is what makes it worth fixing here: it creates an entry for every accepted send instead of only surfaced ones, adds three new readers (two on a background goroutine), and the send path itself drives the writer via cacheMempoolTransaction:637AddTransactionToMempoolcreateTxEntryGetTransaction.

  • On EVM the damage is bounded — both values are 42 chars, so a torn read still yields a valid string, and HexToAddress is case-insensitive. But the cached body is permanently rewritten to EIP-55, which silently falsifies decodeAlternativeSendTx's own documented "lower-case hex addresses" contract after the first read.
  • On Tron it is not bounded: tronparser.go:38 sets FormatAddressFunc = ToTronAddressFromAddress, rewriting From to a 34-char base58 T…, so txSenderAndNonce then decodes the wrong address — no RBF retirement, and pendingNonceFloor stops covering the tx, i.e. exactly the nonce-reuse exposure this PR exists to close. Latent today (no in-repo config sets TRON_ALTERNATIVE_SENDTX_URLS) but TronRPC embeds *eth.EthereumRPC, so it is env-reachable.

Fix: one line in GetTransactioncp := *storedTx.tx; return &cp, true. A shallow copy suffices for a pending tx.

2. evictReplacedByNonce retires at most one predecessor

The break at :733 (with a single rbfTxid/rbfTime) means one pass evicts one victim. There are two eviction passes per send (:220 pre-cache, :632 post-insert), and insertMempoolTx only skips on a strictly higher gen already visible. With three concurrent sends for one (from, nonce) whose register → slotSupersededBy → insert windows interleave, all three insert, the newest pass evicts one, and the mid pass finds the newest protected — leaving two entries cached and address-indexed for one nonce slot, the state insertMempoolTx's own comment says must never exist. It does not self-heal: a later gen-4 send again evicts only one. Reproduced 174/200 trials by driving the real call sequence.

Impact: two Unconfirmed txs for one nonce served to the wallet until the winner mines or the 5-min timeout; rbf_replaced under-counts. Needs a fee-bump storm to hit, but the send-generation machinery exists precisely to make this impossible.

Fix: collect all victims into a slice, drop the break, meter each successful removeMempoolTx. That is sufficient — the max-gen send then clears every lower-gen entry, and no lower-gen send can insert afterwards. Also :700-702 says "removes any cached transaction that shares…", which is currently false.

3. A dropped fetch-back in the decode-failure branch loses the accepted send entirely

:240 sends the decErr != nil fetch-back through inBackground, and startBackground:282 drops the work when 16 are already in flight, with only a glog.Warningf. In that branch nothing was cached and registerSuccessfulSend never ran — so the accepted send is served nowhere, not address-indexed, and raises no pending-nonce floor. That is precisely the failure this PR exists to prevent, and it is unmetered (observeSendNotSurfaced does not fire on a drop, so the dashboard cannot see it).

inBackground's justification at :250-254 — "reconcileMempoolTxs revisits every cached entry within a minute anyway" — holds for the :236 call site and is false for this one.

Fix: run the decode-failure fetch-back synchronously (it is the only thing that can expose the tx), or at minimum observeSendNotSurfaced("dropped") when startBackground refuses, and split the comment per call site.

4. The fallback tail still exceeds Suite's new 60 s push deadline

ethrpc.go:2248-2267: when no relay accepts and _ALTERNATIVE_SENDTX_ONLY is not TRUE, the handler falls through to the primary eth_sendRawTransaction under b.Timeout — 25 s (relays, now concurrent) + 25 s ≈ 50 s — and on the disableMempoolSync coins (arbitrum, base, bsc, optimism, polygon) AddTransactionToMempool then issues a primary eth_getTransactionByHash under another 25 s → ≈75 s, past the 60 s that trezor/trezor-suite#30846 installs.

Pre-existing, and the wallet gets an error on that path anyway — but it is the one remaining route where the user is told "send failed" while the primary may have broadcast. At minimum docs/evm-send.md:67-72 should stop asserting "the wallet's answer waits for the broadcast and nothing else"; better, skip the fallback AddTransactionToMempool round-trip.

5. Deployment order: trezor-suite#30846 has to land first

The suite side (PUSH_TRANSACTION_TIMEOUT = 60_000, no retry — a Blockbook error and a socket timeout are indistinguishable to sendFormThunks) is satisfied by the concurrent broadcast with margin, and returning the signed-bytes hash rather than the relay's echo is an improvement for it, since synchronizeSentTransactionThunk keys its pending row on that txid.

But the coupling runs both ways: caching every accepted send means OnNewTx now pushes pending txs Blockbook previously never surfaced — including contract creations, which serialise addresses: null. Pre-#30846 getEthereumRbfParams dereferences vout[0].addresses![0] and throws inside addTransaction, failing the whole account batch. Reachable today for surfaced private txs, so not new, but this PR widens the exposure and old Suite builds stay in the field. Worth stating the order in the PR body/changelog.


🤖 Generated with Claude Code

pragmaxim and others added 2 commits August 5, 2026 13:48
Review finding 1. GetTransaction returned the live storedTx.tx, and its only
caller passes it straight to EthTxToTx with fixEIP55=true, which rewrites From
and To IN PLACE holding no lock. Every reader in this file takes mempoolTxsMux -
pendingNonceFloor, txSenderAndNonce via insertMempoolTx and evictReplacedByNonce,
refreshCachedTransaction, the senderSettled scan - so the mutex protected nothing
against that writer, and reconcileMempoolTxs dereferences snapshotted bodies
outside the mutex entirely. Confirmed with the race detector:

    WARNING: DATA RACE
    Write at 0x...: eth.(*EthereumParser).EthTxToTx()   ethparser.go:137
    Previous read:  eth.(*AlternativeSendTxProvider).pendingNonceFloor()

The trigger is not rare: cacheMempoolTransaction -> AddTransactionToMempool ->
GetTransactionForMempool -> GetTransaction -> EthTxToTx means every private send
rewrites its own just-inserted entry from the send goroutine, with the lock
released, while the reconcile ticker or an address poll may be reading.

Nothing observes the mutated value today - every reader normalises through
HexToAddress or strings.ToLower, so the EIP-55 rewrite is decode-equivalent - but
it does silently falsify the lower-case hex body decodeAlternativeSendTx
documents, and a torn string read of a (pointer, length) pair is undefined
behaviour rather than a benign case flip. On master the same rewrite WAS a live
bug: predecessors were matched by raw string equality, which an EIP-55 rewrite
defeats. This PR already fixed that by matching decoded addresses.

RpcTransaction is twelve string fields and nothing else, so the shallow copy is a
complete one, and no caller depends on the mutation reaching the cache: the mined
branch's in-place BaseFeePerGas write is unreachable for a cache hit, because
every path that publishes a body rejects a non-empty BlockNumber. The copy also
makes the reconcile snapshot's lockless reads safe, since it removes the only
writer of a published body - so this is not whack-a-mole; the invariant that now
carries nine read sites is written down on storedTx.

Also nil-guards the body. GetTransaction could return (nil, true) where every
sibling reader defends against a nil tx, and the dereference the copy needs would
have made this the only hard-panic site.

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

Review finding 3. The post-send fetch-back has two call sites with opposite
consequences when the work is refused by the concurrency cap. A refused refresh
loses nothing: the transaction is already cached and indexed from its signed
bytes, and reconcile re-probes it within a cycle. A refused fetch-back on the
raw-hex-decode-failure path loses the send itself - registerSuccessfulSend never
ran, nothing was cached, so nothing serves it, nothing indexes it and the
pending-nonce floor does not rise. Both drew on one allowance and both were
silent, so the ordinary traffic - which is all of it in practice - could starve
the only fetch-back that carries data, and the resulting loss was the one variant
of the nonce-reuse precursor the dashboard could not see.

Two changes:

- Independent allowances per kind, so a burst of refreshes or a hanging relay
  holding its slots for len(urls) * rpcTimeout cannot refuse the exposing one.
- A refused exposing fetch-back is reported as send_not_surfaced{reason=dropped}.
  Scoped deliberately: a refused refresh must NOT be counted, or the metric
  documented as the nonce-reuse precursor would alarm on ordinary backpressure.
  A refusal during shutdown is not counted either, so the alert does not fire on
  every restart that catches a send in flight.

Not made synchronous, which was the other option on the table: that would put
len(urls) * rpcTimeout (25 s per URL) back inside the wallet's own deadline, on
every decode-failure send, to prevent a loss that needs a geth-version skew
window AND a saturated cap - and in the only regime where the cap saturates, a
stalled relay, the fetch-back would have surfaced nothing anyway. It would also
lose the recover() that the background path provides, turning a panic into
"Internal error" for an accepted send.

Queueing instead of dropping is not an alternative either: a queue either grows
unbounded or pushes back into the send path, and a fetch-back that runs minutes
late tells us nothing reconcile has not already decided. That reasoning moved
from the shared helper's doc comment - where it was true of one call site and
false of the other - to the call sites themselves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pragmaxim and others added 4 commits August 5, 2026 14:25
Review finding 2. evictReplacedByNonce broke out of its scan after one victim,
while insertMempoolTx refuses an insert only when a STRICTLY newer entry is
already cached - so a send whose scan ran before a newer send inserted can still
land beside it, and one eviction pass then left the slot holding two cached,
address-indexed transactions. That is the state insertMempoolTx's own comment
says the send generations exist to prevent, and the function's summary line
already claimed it removes "any" matching transaction.

Every entry the break skipped satisfies exactly the predicate the loop had just
evaluated, so collecting all of them is the smaller statement of intent, not a
behaviour change in steady state: with at most one predecessor - the case two
concurrent sends can produce - the loop finds exactly what it found before.

Committed as invariant hardening rather than an incident fix, because the
reachability claims in the review do not hold up. Independent reproduction:
0 occurrences in 27,100 forced 3-way same-nonce races through the real send path
(with and without -race, GOMAXPROCS 1/2/4/52), and 89 of 756,756 exhaustive
interleavings of the real step sequence - 0.0118%, worst case two entries, never
three, and only when both stragglers insert inside the sub-microsecond window
between the newest send's pre-insert scan and its own insert. Nor is it stuck:
each send runs two eviction passes, so the next same-nonce send clears both stale
entries, and reconcile retires them as nonce_superseded once the nonce confirms.
Both entries carry the same nonce, so pendingNonceFloor is identical either way -
there is no nonce-reuse exposure, only a duplicate Unconfirmed row and one
missing rbf_replaced count. With the fix, 0 of 2,270,268 interleavings duplicate.

The victims are collected under mempoolTxsMux and removed after unlocking:
removeMempoolTx re-acquires the same non-reentrant mutex, so removing inside the
scan would deadlock the provider. Metering stays exactly-once per entry, still
gated on removeMempoolTx returning true, so rbf_replaced and the residence
histogram gain samples only for removals that really happened.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review finding 4. On the no-relay-accepted fall-through, the mempool add ran
regardless of whether the primary send had succeeded. After a failure txid is
empty, so it indexed the zero hash: a primary eth_getTransactionByHash(0x0..0)
and then - because that answers null - the pruned-index recovery's
eth_getTransactionReceipt(0x0..0), each on its own b.Timeout. Two more
rpc_timeouts, 50 s at the 25 s these coins configure, spent on nothing, on the one
path where the wallet has already waited a full timeout for the relay broadcast
and another for the primary send. The relay path learned the same lesson at its
own acceptance gate, with a comment saying so.

Gating the add on retErr == nil is the whole change. Nothing is lost: with no txid
there is nothing to index, and when the send does succeed the add still runs -
it must, because on disableMempoolSync coins there is no newPendingTransactions
feed, so it is the only thing that makes an own send visible for its addresses and
pushes it to subscribed wallets. The reviewer's suggestion to skip the add
outright would have removed that.

The reviewer also asked the docs to stop asserting the tail does not exist, and
the corrected figure is worse than the one filed: up to 3-4 x rpc_timeout (~100 s,
and ~960 s on bsc_archive, which configures rpc_timeout 240 while also disabling
mempool sync), because the add costs two round-trips rather than one whenever the
node does not know the hash yet. Two framings in the review needed correcting
too, and the doc now reflects both: the wallet gets SUCCESS on this path when the
primary accepts, not an error - so the harmful case is a successful send answering
after the client deadline - and the tail is not relay-conditional, since a coin
with no relay configured carries the same legs today.

Left alone deliberately: building the mempool entry locally from the signed bytes
would remove the remaining round-trip on the success path, but it touches shared
mempool code that has no test around its entry point, and EthereumRPC.SendRawTransaction
has no test at all. That belongs in its own change.

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

Two regressions an independent review of my own earlier commit found. Both were
introduced by making the fetch-back an update instead of an insert, and both are
removed by making it observe only.

Evicting on a mined answer was premature. The relay can see the transaction in a
block before Blockbook's block sync has indexed that block, and evictMempoolTx
clears the wrapped mempool's address index as well - so in between the transaction
is in NEITHER store: not pending, not confirmed, absent from its addresses for
both sender and recipient. On a 250 ms-block chain the fetch-back regularly wins
that race, and reconcile's mined branch could not produce the same gap because it
never probes an entry younger than one check period. Block sync removes it as
sync_removed, which is what should happen.

Adopting the relay's body bought nothing and cost trust. Everything a pending
RpcTransaction carries is in the signed bytes - that is the premise of this whole
series - so the relay's view is at best identical. At worst it carries a different
hash, `to` or `value`, and EthTxToTx takes the transaction's identity from tx.Hash,
so Blockbook would have served the relay's hash as the txid of an entry keyed on
its own. The send path deliberately overrides a relay that echoes the wrong txid;
adopting the same echo one round-trip later undid that.

What remains is a probe: it reports whether the relay surfaces what it accepted
(the send_not_surfaced counter) and logs when the relay already considers the
transaction mined. The mismatch-rejection logic goes away with it - there is
nothing left to reject - and so does the reason the entry's time and gen had to be
preserved by hand.

Tests follow the inversion: the two that asserted a refreshed body and a mined
eviction now assert the derived body survives and block sync keeps ownership.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Accuracy items from the review's minor list and from an independent second pass,
all of them places where the text describes code that has since changed - twice
in commits whose own messages claimed the correction was made.

- observeSendNotSurfaced's doc comment still stated the pre-rename meaning ("cached
  and indexed nowhere - the precursor to a nonce-reuse incident") and contradicted
  the metric help it documents. It now says what each reason means, including that
  dropped is the one that really is exposed nowhere.
- Both send diagrams still named the fetch-back a refresh that updates the cached
  body, and evm-send-mempools.md still had it evicting on a mined answer. Neither
  is true since the fetch-back became a probe.
- The invariant bullet in evm-send.md read as though the clause list after the dash
  described current behaviour; it describes what the code used to do.
- The reconciliation-events note explained a rare `mined` by block sync winning the
  race, which is now only half the reason: the fetch-back deliberately declines to
  evict a transaction the relay reports as mined.
- The pending-store table said own sends are added when disableMempoolSync, without
  the "and only when the send succeeded" gate added in this series.
- The residence histogram's buckets ended at 420 s, which assumed the default
  5-minute retention. alternativeMempoolTxTimeout is operator-configurable with no
  upper bound, so a coin configured wider put every timeout observation in +Inf and
  made the two residence panels return NaN on exactly the deployment that widened
  the window. Added 600/1200/1800.

Note for whoever reads the residence panels across the deploy: this is the second
bucket-schema change to that histogram in this series, so quantiles over a window
straddling either deploy mix schemas until the old le series age out.

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: every gas estimate is sent to the alternative provider, burning its rate-limit quota

2 participants