Skip to content

eth: wallet-declared privatePending hint for nonce + gas routing (backs design gist) - #1639

Open
pragmaxim wants to merge 8 commits into
fix/eth-alt-provider-1573-1629from
feat/eth-private-pending-hint
Open

eth: wallet-declared privatePending hint for nonce + gas routing (backs design gist)#1639
pragmaxim wants to merge 8 commits into
fix/eth-alt-provider-1573-1629from
feat/eth-private-pending-hint

Conversation

@pragmaxim

@pragmaxim pragmaxim commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Draft — proposal, backs the design note: https://gist.github.com/pragmaxim/f71b03a4ed4c0030b6cba7fb0a302e96

Lets a wallet declare its in-flight private (alternative send-tx / relay) transactions on its EVM read requests, so Blockbook routes the nonce lookup and the gas estimate deterministically from authoritative wallet state instead of inferring "did this instance recently accept a send" via the recentSenders heuristic (fragile across restarts and load-balanced replicas).

The field is added in two places — top-level privatePending on getAccountInfo, and inside the existing specific object on estimateFee — because EthereumTypeGetNonces and EthereumTypeEstimateGas are the only two consumers of the recentSenders / useForNonces / nonceURL machinery. Declaring both is the prerequisite for a later change to retire that machinery (not done here; this is additive and keeps the heuristic as the fallback). The contract and its trust boundary are documented in docs/evm-send.md.

Commits

  • ws: accept privatePending on getAccountInfoWsPrivatePending{nonces,txids} + blockbook-api.ts + AddressFilter.PrivatePendingNonces (plumbing, no behavior change).
  • eth: short-circuit nonce routingEthereumTypeGetNonces routes to the relay when declared and raises the pending nonce to max(declared)+1; correct even for a tx this instance never cached.
  • ws: preserve the highest nonce when capping — over-cap collapses to the max instead of first-N truncation.
  • ws: document privatePending on estimateFee — extend the specific ts_type (contract only).
  • eth: short-circuit gas-estimate routingEthereumTypeEstimateGas routes on the declared hint; strips the bookkeeping from the forwarded eth_estimateGas call.
  • docs(eth): document privatePending hint, tighten its contract and tests — review follow-ups (see below).

Notes

  • A declared nonce is the answer (short-circuits the lookup); a declared estimate is only a routing signal — Blockbook still simulates, but against the right pending state.
  • Additive and backward-compatible: absent the field, behavior is exactly today's (useForNonces fallback); older Blockbook ignores the field (json.Unmarshal).
  • Trust boundary (accepted): privatePending is an unauthenticated, per-request client hint. It is never written into recentSenders or the pending-tx cache, so a hostile client can distort only its own request's answer — never shared state — and it does not re-open the eth: every gas estimate is sent to the alternative provider, burning its rate-limit quota #1629 hot-path quota drain, since a normal wallet declares it only when it genuinely has a private tx in flight. Documented in docs/evm-send.md.
  • Suite is not yet wired to send the field; the gist lists the Suite-side edits.

Review follow-ups

A broad multi-angle, adversarially-verified review found no blockers. Applied in the last commit (no behavior change):

  • docs: wrote the "Wallet-declared privatePending hint" section of docs/evm-send.md that the ts_doc/generated contract already pointed to, including the trust-boundary note above.
  • contract: WsEstimateFeeReq.specific now references WsPrivatePending by name instead of duplicating the inline {nonces?; txids?} shape (single source of truth); documented that nonces entries are literal (0 is meaningful, not a sentinel). Both mirrored into the generated blockbook-api.ts and verified to match generator output.
  • tests: added the production confirmedNonce=true + declared-floor combination (floor raises only pending, leaves confirmed untouched), a declared-nonce-0 routing case, the MaxUint64 benign-overflow boundary, and an estimate-side wire-decode test (specific.privatePending through a real WsEstimateFeeReq envelope).
  • known, out of scope: the committed blockbook-api.ts is globally stale vs the generator (2-space vs 4-space indent + a couple of missing Tron fields) — pre-existing on master/base and passing the type-parity CI gate; these additions match generator output modulo that indent. A full regeneration is a separate cleanup.

🤖 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
pragmaxim force-pushed the feat/eth-private-pending-hint branch from b8be7ba to b8093ce Compare July 21, 2026 19:21
@pragmaxim
pragmaxim force-pushed the fix/eth-alt-provider-1573-1629 branch from 696adbf to b8d7460 Compare July 23, 2026 06:08
@pragmaxim
pragmaxim force-pushed the feat/eth-private-pending-hint branch 2 times, most recently from ffe0d7b to ee3c5af Compare July 23, 2026 07:26
@pragmaxim
pragmaxim marked this pull request as ready for review July 23, 2026 08:55
pragmaxim added a commit to trezor/trezor-suite that referenced this pull request Jul 23, 2026
…delivery + prune)

Second of three stacked changes for the blockbook privatePending hint
(trezor/blockbook#1639). Adds the durable model and the delivery/prune machinery;
no producer writes yet, so the slice stays empty and this is still a functional
no-op end to end.

Why a dedicated slice: the private/relay bit is transient (computed only at
broadcast in getMevProtectedTxData) and lives on neither the fake nor the real tx
object, and blockbook overwrites the fake pending tx with its real (unmarked) tx
mid-flight - so a tx-object marker cannot survive and a tx-list-derived set cannot
tell private from public. A small Redux slice keyed by (accountKey, nonce) is the
sole authoritative source; nonce is invariant across the fake->real swap.

- new suite-common/wallet-core/src/privatePending slice (Redux only for now;
  IndexedDB persistence deferred - Redux already survives WS reconnect, only an
  app restart mid-tx regresses, matching today's heuristic fallback);
- DELIVERY: attach the hint (and confirmedNonce:true) to the UNCONDITIONAL basic
  getAccountInfo call in fetchAndUpdateAccountThunk, gated on there being an
  in-flight private nonce. It must ride the basic call, not the gated details:'txs'
  one, because a blockbook-invisible private tx does not make the account
  "outdated" so the txs call is skipped for exactly the flows that need the hint;
- PRUNE (STOP): drop entries with nonce < confirmedNonce (mined) or past a TTL
  backstop, on every basic refresh - instance-agnostic, so it self-heals across
  load-balanced blockbook replicas.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
pragmaxim added a commit to trezor/trezor-suite that referenced this pull request Jul 23, 2026
… privatePending

Third of three stacked changes for the blockbook privatePending hint
(trezor/blockbook#1639). First change with observable behavior: it fills the slice
from the two flows whose signed nonce is already in scope, and routes their gas
estimates.

- isMevPrivateSend(symbol, isMevProtectionEnabled) predicate (wallet-utils) - the
  exact condition under which getMevProtectedTxData broadcasts through the relay;
- record the private nonce+txid after a successful pushTransaction in the send flow
  (pushSendFormTransactionThunk) and in WalletConnect eth_sendTransaction, gated by
  isMevPrivateSend so public sends never declare;
- adopt buildEvmEstimateSpecific at the send and allowance estimate sites, passing
  the account's private-pending nonces so the estimate simulates against the relay's
  pending-private state - presence-only, so a keystroke with nothing in flight never
  routes to the relay.

Deferred (see PR description): staking / Merkl-claim / stablecoin-yield writes need
signed-nonce capture first (PrecomposedTransactionFinal carries no nonce); until
then those private flows fall back to blockbook's recentSenders heuristic, which is
acceptable because #1639 keeps that heuristic as the fallback. IndexedDB persistence
also deferred.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@pragmaxim
pragmaxim force-pushed the feat/eth-private-pending-hint branch 2 times, most recently from 61976d9 to e8b2293 Compare July 28, 2026 12:50
pragmaxim and others added 8 commits August 5, 2026 06:49
Add an optional privatePending field to the getAccountInfo WebSocket request so
a wallet can declare the in-flight private (alternative send-tx / relay)
transactions it is tracking for an Ethereum-like address. A private relay
exposes no mempool, so a tx pending only there is invisible to the public
backend RPC; today Blockbook infers "this address may have a private pending
tx" from which addresses recently sent through this instance (recentSenders),
which is fragile across restarts and load-balanced replicas. The wallet already
knows this authoritatively.

This commit is plumbing only — no behavior change:
- WsPrivatePending{nonces, txids} + WsAccountInfoReq.PrivatePending (ws_types.go)
  and the matching blockbook-api.ts types (hand-edited to keep the diff minimal,
  as the checked-in file predates the current typescriptify version).
- privatePendingNonces() extracts the declared nonces, capped and defensively
  copied, into a new AddressFilter.PrivatePendingNonces carried to the worker.
- The field is parsed and threaded but not yet consumed; the short-circuit that
  routes the nonce lookup on it follows in the next commit.

Only nonces drive behavior; txids are accepted for forward compatibility. Unknown
request fields remain ignored (json.Unmarshal), so an older Blockbook tolerates a
wallet that sends the field before this ships.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Consume the privatePending nonces declared on getAccountInfo (previous commit).
When a request carries them, EthereumTypeGetNonces routes the lookup to the
alternative send-tx provider deterministically instead of consulting the
recentSenders heuristic, and raises the reported pending nonce to
max(declared nonce)+1. The wallet knows its own in-flight private nonces
authoritatively, so this is correct even for a tx this instance never cached —
one accepted by a different load-balanced replica, or lost to a restart — the
two cases the recentSenders inference cannot cover.

- EthereumTypeGetNonces takes a variadic privatePendingNonces ...uint64, keeping
  every existing caller and test call site source-compatible; only the worker
  passes filter.PrivatePendingNonces... The interface and the base / metrics /
  Tron / fake implementations carry the parameter (Tron has no relay, ignores it).
- declaredPendingFloor computes the floor; raiseToFloor folds it over the
  cache-derived floor on both the provider-success and primary-fallback paths.
- Gated on a configured provider: with no relay the hint is ignored.

The recentSenders path remains as the fallback for requests without the hint, so
this is additive; retiring it can follow once wallet adoption is confirmed.
A metric distinguishing hint-routed from heuristic-routed lookups is left as a
follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 10-agent review of the two feature commits found one latent correctness
smell: privatePendingNonces() capped with n[:maxPrivatePendingNonces], keeping
the FIRST N by position. Only the highest declared nonce drives the pending
floor, and nonces are not guaranteed sorted, so an over-cap request whose
maximum sits past index N would compute the floor too low (weaker protection).
Unreachable for an honest wallet (a handful of sequential in-flight nonces), but
the code then contradicted its own "only the highest matters" comment.

Collapse an over-cap array to its single highest value instead of positional
truncation, so the floor is correct regardless of order. Tests updated to assert
the boundary (==cap kept in full) and that the max survives an over-cap ascending
array. Also tidied a self-correcting comment in nonce_hint_test.go.

Reviewers found no blockers or majors otherwise: correct on all paths, race-free,
fully backward-compatible, the hand-edited blockbook-api.ts matches the generator,
and trezor-suite can populate the field from its own pending-tx set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
estimateFee carries its Ethereum parameters in a freeform `specific` object, so
no struct change is needed to accept the field — but the wire contract should
say it exists. Extend the WsEstimateFeeReq.specific ts_type (and the generated
blockbook-api.ts) with the optional privatePending {nonces, txids}, the same
shape declared top-level on getAccountInfo. Consumption (routing the estimate on
it) follows in the next commit; this commit is contract-only, no behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Consume the privatePending declared on the estimateFee request's specific object
(previous commit). When present, EthereumTypeEstimateGas routes eth_estimateGas
to the alternative send-tx provider deterministically instead of consulting the
recentSenders/useForNonces heuristic — the gas half of the same declared-routing
change already applied to the nonce path. Together the two paths are the only
consumers of the recentSenders machinery, which a later change can then retire.

Unlike the nonce hint, a declared estimate is only a routing signal: the wallet
cannot simulate gas itself, so Blockbook still calls the relay — but now against
the right pending-private state (e.g. a privately-submitted approve a following
swap's gas depends on) rather than guessing from recent sends. The declaration is
authoritative and independent of this instance having accepted the send, so it
covers the restart / load-balanced-replica gaps the heuristic cannot.

- estimatePrivatePendingDeclared reads privatePending.nonces presence from the
  freeform params (no signature change — specific is already map[string]interface{}).
- estimateParamsWithoutPrivatePending strips the bookkeeping field from the
  forwarded eth_estimateGas call object (copy-on-write; zero-cost when absent).
- Non-declaring requests are unchanged: they fall back to useForNonces (#1629),
  and a missing from or no provider still takes the primary backend.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review follow-ups for the wallet-declared privatePending hint; no behavior change.

- docs/evm-send.md: add the "Wallet-declared privatePending hint" section the
  ts_doc/generated contract already point to but that was never written, including
  the accepted trust-boundary rationale (the hint is an unauthenticated, per-request
  signal: it routes only the caller's own request to the relay, never touches shared
  state, and does not re-open the #1629 hot-path quota drain).
- server/ws_types.go: reference WsPrivatePending by name in WsEstimateFeeReq.specific
  instead of duplicating the inline {nonces?;txids?} shape (single source of truth);
  document that Nonces entries are literal (0 is meaningful, not a sentinel). Mirror
  both edits into the generated blockbook-api.ts by hand (matching the file's style).
- bchain/coins/eth/ethrpc.go: note the trust boundary at the estimate routing gate.
- tests: add the production confirmedNonce=true + declared-floor combination (floor
  raises only pending, leaves confirmed untouched), a declared-nonce-0 routing case,
  the MaxUint64 benign-overflow boundary, and an estimate-side wire-decode test
  (specific.privatePending through a real WsEstimateFeeReq envelope).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
raiseToFloor(pending, floor) is exactly max(pending, floor); go.mod declares
go 1.25.0 and the builtin max is already used elsewhere in the tree. Drop the
helper, use max at both nonce-floor call sites, and collapse declaredPendingFloor's
loop to floor = max(floor, n+1). Pure refactor, no behavior change.

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

Document two previously-implicit contract points on the privatePending hint:
- estimateFee URL selection is best-effort (nonceURL, else urls[0]); unlike the
  nonce floor, gas has no client value to compensate a wrong relay node, so a
  declared-but-unknown sender may miss a predecessor held by another relay.
- the deliberate trade-off vs pre-#1629 behavior: hint-less senders whose private
  tx was accepted by another replica are estimated on the primary RPC; widening
  routing for them would reopen the #1629 drain, so declaring the hint is the fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@pragmaxim
pragmaxim force-pushed the feat/eth-private-pending-hint branch from e8b2293 to fbf7d53 Compare August 5, 2026 06:53
@pragmaxim
pragmaxim requested a review from cranycrane August 5, 2026 11:09
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.

1 participant