Skip to content

eth: private send-tx pending-nonce floor can hang a tx on a fetch-back miss (nonce reuse / gap) #1675

Description

@pragmaxim

Summary

For EVM coins configured with the alternative (Blinklabs) private send-tx relay, a relay-accepted transaction whose post-send fetch-back transiently misses is cached and indexed nowhere. Because pendingNonceFloor derives the reported pending nonce from the cache alone as max(cached nonce) + 1 with no contiguity check, an incomplete cache then mis-reports the account's next nonce in two ways — nonce reuse and a nonce gap — either of which can leave a trezor-suite user with a transaction stuck "Unconfirmed" or a later send queued behind a dead nonce. Block sync cannot rescue either while the transaction is unmined; it self-heals only at the 5-minute cache timeout.

This is a pre-existing hazard in the alternative-provider subsystem (it is not introduced by #1638SendRawTransaction and the nonce-floor logic are unchanged by that PR). It was surfaced by an adversarial review of #1638.

Affected configuration

  • Coins with *_ALTERNATIVE_SENDTX_URLS + *_ALTERNATIVE_SENDTX_ONLY=TRUE + *_ALTERNATIVE_FETCH_MEMPOOL_TX=TRUE — i.e. the Blinklabs coins (arb / base / bsc / eth).
  • Most acute on Base and other disableMempoolSync=true deployments: there the newPendingTransactions feed is off, so the wrapped mempool has no independent way to pick the transaction up.
  • Not applicable to non-relay coins (optimism, polygon, …): alternativeSendTxProvider == nil, the whole path is skipped.

Root cause

On a relay acceptance, AlternativeSendTxProvider.SendRawTransaction records the sender in recentSenders (so useForNonces routes its nonce lookups to the relay for alternativeMempoolTxTimeout = 5 min) and then calls handleMempoolTransaction, which fetches the tx back from the relay via eth_getTransactionByHash and only then caches it and adds it to the wrapped mempool.

If that fetch-back returns null or errors — the documented transient where the relay node that accepted the send has not yet indexed it — handleMempoolTransaction returns early without caching or indexing anything (bchain/coins/eth/alternativesendtx.go:339-346). Its error return is discarded by the caller (:166), so EthereumRPC.SendRawTransaction sees retErr == nil and returns early (bchain/coins/eth/ethrpc.go:2254), never reaching the disableMempoolSync own-send add. The transaction is now live on the relay but present in neither the provider cache nor the wrapped mempool.

pendingNonceFloor (bchain/coins/eth/alternativesendtx.go:302) computes the floor purely from mempoolTxs as max(nonce) + 1, and raiseToPendingFloor (applied in EthereumTypeGetNonces on both the provider and primary-fallback paths, ethrpc.go:2345 and :2367) raises the reported pending nonce to it. With the cache incomplete, that answer is wrong.

Scenario A — nonce reuse (uncached slot)

  1. Wallet sends A at nonce N; the relay accepts it but the fetch-back misses, so A is uncached.
  2. For the first ~1 min, useForNonces routes to the relay, which still counts A pending → getNonces returns N+1 (correct).
  3. After the relay's ~1-minute give-up (it assumes A is underpriced and drops it from the pending count), the relay returns N. The floor finds nothing cached for the sender (found == false), so EthereumTypeGetNonces returns N unchanged.
  4. The wallet uses N for its next send. If A was not actually dead and later mines, the two N-nonce transactions collide — one is dropped and the survivor can be stuck "Unconfirmed".

Scenario B — nonce gap (lower uncached, higher cached)

  1. A at nonce N misses the fetch-back (uncached); B at N+1 is cached normally.
  2. The relay correctly reports pending = N (a gap at N), but the floor computes N+2 from cached B and overrides the relay's answer (ethrpc.go:2345).
  3. The wallet sends C at N+2, which can never mine until N (and N+1) mine. If A is dead/underpriced, C hangs; each retry piles further sends behind the gap.

(A reorg that evicts a lower-nonce private tx as mined and then reverts its block produces the same "floor elevated over a re-exposed gap" state.)

Why block sync does not rescue it

Block sync (GetBlockremoveTransactionFromMempool) only fires when a transaction is included in a connected block. In both scenarios the offending transaction is unmined (A never mines / collides; the gap blocks C), so block sync never touches it. The state self-heals only when the cache entry hits the 5-minute mempoolTxsTimeout and the floor recomputes — and any sends already queued behind the gap stay stuck until N is filled (manually, or by a correctly-nonced resend).

Severity & likelihood

Medium. Both directions are gated on a transient fetch-back miss, whose real frequency against the production relay is currently unknown. The reuse direction additionally needs the relay's "underpriced" assumption about A to be wrong. But it is the one pathway in this subsystem that is genuinely un-rescued-by-block-sync and directly produces the reviewed symptoms (stuck Unconfirmed / queue-behind-dead-nonce). The codebase already acknowledges the fetch-back transient exists — it grants reconcile a skipped_fresh grace for exactly this load-balanced-node race (alternativesendtx.go:552-553) — but only at reconcile time, not at send time.

Proposed fix

  1. Close the hole at the root. On a send-time fetch-back miss, insert a reconcilable placeholder cache entry keyed by txid, carrying the sender/nonce/gen already decoded by registerSuccessfulSend, with the body backfilled by reconcile once the relay node catches up (and/or fall back to the disableMempoolSync own-send add). This gives the entry both floor participation and reconcile eviction, matching a normally-cached tx, so the reuse direction closes.
  2. Clamp the floor to a contiguous run from the backend's reported pending (or relay-confirmed) nonce, so it never advertises above a gap — never a blind max(cached)+1. This closes the gap direction and the reorg variant.

⚠️ Do not simply fold the existing acceptedSlots map into the floor. acceptedSlots is time-swept only (never reconciled on drop/mine), so flooring to it would pin every underpriced / drop-mode send's nonce to N+1 for the full 5-minute slot lifetime and manufacture systematic dead-nonce gaps on the healthy path — worse and more frequent than the bug it targets.

How to measure it now

The observability added in commit 3b76c06 makes both precursors visible before writing the fix, so we can gauge real-world frequency and confirm any fix:

  • blockbook_eth_alternative_send_accepted_but_uncached_total{reason} — increments on exactly the fetch-back miss that opens the hole (the reuse precursor).
  • blockbook_eth_alternative_pending_floor_raised_total{source="provider"} — the floor pinning a nonce above the relay's own (post-1-min-drop) answer (the gap / queue-behind-dead-nonce precursor).

Code references

  • bchain/coins/eth/alternativesendtx.go:339-346handleMempoolTransaction early return on fetch-back miss (no cache, no index)
  • bchain/coins/eth/alternativesendtx.go:166 — caller discards the error return
  • bchain/coins/eth/ethrpc.go:2254SendRawTransaction returns early on relay success, skipping the disableMempoolSync add
  • bchain/coins/eth/alternativesendtx.go:302-321pendingNonceFloor (max(cached)+1, no contiguity check)
  • bchain/coins/eth/ethrpc.go:2345, :2367raiseToPendingFloor application

Related: #1573, #1629. Surfaced by an automated adversarial review of #1638.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions