eth: fix alt-provider quota burn (#1629), lingering cancelled/replaced private txs (#1573) and trezor-suite#30846 - #1638
eth: fix alt-provider quota burn (#1629), lingering cancelled/replaced private txs (#1573) and trezor-suite#30846#1638pragmaxim wants to merge 25 commits into
Conversation
fa0e5dc to
696adbf
Compare
…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>
696adbf to
b8d7460
Compare
…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>
- 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>
5cb22dd to
a698e2c
Compare
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>
… 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>
cranycrane
left a comment
There was a problem hiding this comment.
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:637 → AddTransactionToMempool → createTxEntry → GetTransaction.
- On EVM the damage is bounded — both values are 42 chars, so a torn read still yields a valid string, and
HexToAddressis case-insensitive. But the cached body is permanently rewritten to EIP-55, which silently falsifiesdecodeAlternativeSendTx's own documented "lower-case hex addresses" contract after the first read. - On Tron it is not bounded:
tronparser.go:38setsFormatAddressFunc = ToTronAddressFromAddress, rewritingFromto a 34-char base58T…, sotxSenderAndNoncethen decodes the wrong address — no RBF retirement, andpendingNonceFloorstops covering the tx, i.e. exactly the nonce-reuse exposure this PR exists to close. Latent today (no in-repo config setsTRON_ALTERNATIVE_SENDTX_URLS) butTronRPCembeds*eth.EthereumRPC, so it is env-reachable.
Fix: one line in GetTransaction — cp := *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
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>
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>
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
EthereumTypeEstimateGasrouted everyeth_estimateGasto the alternative provider (alwaysurls[0], errors swallowed). The WSestimateFeehandler 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. Neweth_alternative_estimate_gas_requests_totalmetric + 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 emptygetTransactionByHashprobe.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:
eth_getTransactionByHashround-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.rpc_timeoutbefore 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 singlerpc_timeout, not N), and the fetch-back runs in the background, so the wallet's own deadline is no longer consumed by relay handling.Review-driven hardening (same subsystem)
eth_getTransactionByHash(0x0…0)and logs a spurious error.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 tofees.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