Skip to content

Commit b8093ce

Browse files
pragmaximclaude
andcommitted
docs(eth): document privatePending hint, tighten its contract and tests
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>
1 parent c71e444 commit b8093ce

6 files changed

Lines changed: 127 additions & 4 deletions

File tree

bchain/coins/eth/ethrpc.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1991,6 +1991,9 @@ func (b *EthereumRPC) EthereumTypeEstimateGas(params map[string]interface{}) (ui
19911991
// estimateFee endpoint does not burn the provider's rate-limit quota (#1629); a missing/empty
19921992
// `from` also takes the primary path. Unlike the nonce hint - which IS the answer and short-
19931993
// circuits the lookup - a declared estimate only selects the backend; Blockbook still simulates.
1994+
// The hint is an unauthenticated, per-request client signal: it can route only the caller's own
1995+
// request to the relay and never touches shared state, so it does not re-open the #1629 hot-path
1996+
// quota drain (accepted trust boundary, see docs/evm-send.md).
19941997
declaredPrivatePending := estimatePrivatePendingDeclared(params)
19951998
if b.alternativeSendTxProvider != nil && msg.From != (ethcommon.Address{}) &&
19961999
(declaredPrivatePending || b.alternativeSendTxProvider.useForNonces(msg.From)) {

bchain/coins/eth/nonce_hint_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
package eth
44

55
import (
6+
"math"
67
"testing"
78
"time"
89

@@ -70,6 +71,54 @@ func TestEthereumTypeGetNonces_PrivatePendingHint_RaisesPrimaryFallback(t *testi
7071
}
7172
}
7273

74+
// TestEthereumTypeGetNonces_PrivatePendingHint_WithConfirmedNonce exercises the exact production
75+
// combination (api/worker.go passes WithConfirmedNonce together with PrivatePendingNonces...): the
76+
// declared floor must raise only the PENDING nonce and leave the confirmed (latest) nonce untouched.
77+
func TestEthereumTypeGetNonces_PrivatePendingHint_WithConfirmedNonce(t *testing.T) {
78+
server := newNonceRPCServer(t, map[string]string{"pending": "0x9", "latest": "0x5"}, nil)
79+
stub := &nonceBatchStub{results: map[string]string{"pending": "0x4", "latest": "0x2"}}
80+
// no recent senders → routed purely by the declared floor
81+
b := &EthereumRPC{RPC: stub, Timeout: time.Second, alternativeSendTxProvider: newRecentSenderProvider(server)}
82+
83+
// declared nonce 42 → pending floor 43, above the provider's pending answer of 9
84+
pending, confirmed, confirmedOK, err := b.EthereumTypeGetNonces(nonceTestAddr, true, 42)
85+
if err != nil {
86+
t.Fatalf("unexpected error: %v", err)
87+
}
88+
if pending != 43 {
89+
t.Errorf("pending = %d, want 43 (declared floor over the provider answer)", pending)
90+
}
91+
if confirmed != 5 || !confirmedOK {
92+
t.Errorf("confirmed = (%d, ok=%v), want (5, true) — the floor must not touch the confirmed nonce", confirmed, confirmedOK)
93+
}
94+
if len(stub.queried) != 0 {
95+
t.Errorf("primary RPC queried tags %v, want none once routed to the provider", stub.queried)
96+
}
97+
}
98+
99+
// TestEthereumTypeGetNonces_PrivatePendingHint_RoutesOnDeclaredZero confirms a declared nonce of 0
100+
// (a wallet's very first tx) still trips the routing guard (declaredFloor 1 > 0) and raises the
101+
// pending nonce to 1 — the boundary the routing tests above (nonce 42) do not exercise.
102+
func TestEthereumTypeGetNonces_PrivatePendingHint_RoutesOnDeclaredZero(t *testing.T) {
103+
server := newNonceRPCServer(t, map[string]string{"pending": "0x0"}, nil)
104+
stub := &nonceBatchStub{results: map[string]string{"pending": "0x0"}}
105+
b := &EthereumRPC{RPC: stub, Timeout: time.Second, alternativeSendTxProvider: newRecentSenderProvider(server)}
106+
107+
pending, _, _, err := b.EthereumTypeGetNonces(nonceTestAddr, false, 0)
108+
if err != nil {
109+
t.Fatalf("unexpected error: %v", err)
110+
}
111+
if pending != 1 {
112+
t.Errorf("pending = %d, want 1 (declared nonce 0 → floor 1)", pending)
113+
}
114+
if got := server.callCount("pending"); got != 1 {
115+
t.Errorf("alternative provider queried %d times, want 1 (declared 0 must still route)", got)
116+
}
117+
if len(stub.queried) != 0 {
118+
t.Errorf("primary RPC queried tags %v, want none once routed to the provider", stub.queried)
119+
}
120+
}
121+
73122
// TestEthereumTypeGetNonces_PrivatePendingHint_IgnoredWithoutProvider confirms the hint is a
74123
// relay-deployment feature: with no alternative provider configured it is ignored and the primary
75124
// answer stands unchanged.
@@ -124,6 +173,11 @@ func TestDeclaredPendingFloor(t *testing.T) {
124173
{[]uint64{0}, 1},
125174
{[]uint64{5}, 6},
126175
{[]uint64{5, 42, 7}, 43},
176+
// n+1 wraps to 0 at MaxUint64; the entry is silently ignored (benign: the floor is only
177+
// ever a max() operand, so a spurious 0 can never lower the reported nonce).
178+
{[]uint64{math.MaxUint64}, 0},
179+
// a physically-unreachable max value co-declared with a real nonce must not corrupt it.
180+
{[]uint64{math.MaxUint64, 43}, 44},
127181
}
128182
for _, c := range cases {
129183
if got := declaredPendingFloor(c.in); got != c.want {

blockbook-api.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -682,7 +682,7 @@ export interface WsRes {
682682
data: any;
683683
}
684684
export interface WsPrivatePending {
685-
/** Account nonces of the wallet's in-flight private transactions for this address. */
685+
/** Account nonces of the wallet's in-flight private transactions for this address. Each entry is a literal in-flight nonce (0 is a valid value, not a sentinel); do not pad or default the array, as any entry raises the reported pending nonce to at least value+1. */
686686
nonces?: number[];
687687
/** Transaction hashes of the in-flight private transactions (reserved for future use). */
688688
txids?: string[];
@@ -819,7 +819,7 @@ export interface WsEstimateFeeReq {
819819
/** Block confirmations targets for which fees should be estimated. */
820820
blocks?: number[];
821821
/** Additional chain-specific parameters (e.g. for Ethereum). privatePending (Ethereum-like) declares the sender's in-flight private transactions so the gas estimate is routed to the alternative send-tx provider; see WsPrivatePending and docs/evm-send.md. */
822-
specific?: {conservative?: boolean; txsize?: number; from?: string; to?: string; data?: string; value?: string; privatePending?: {nonces?: number[]; txids?: string[]};};
822+
specific?: {conservative?: boolean; txsize?: number; from?: string; to?: string; data?: string; value?: string; privatePending?: WsPrivatePending;};
823823
}
824824
export interface Eip1559Fee {
825825
maxFeePerGas?: string;

docs/evm-send.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,47 @@ Key invariants:
8282
pending tx is gone on its next account re-fetch (the initiating device also removes it
8383
optimistically). The cache timeout is the backstop for anything the deterministic evictions miss.
8484

85+
## Wallet-declared `privatePending` hint (nonce + gas routing)
86+
87+
A private relay exposes no mempool, so a transaction pending only there is invisible to the public
88+
backend RPC. Blockbook otherwise *infers* "this sender has a private tx in flight" from the
89+
`recentSenders` map (populated by `registerSuccessfulSend`), which is fragile across restarts and
90+
across load-balanced replicas without request affinity. A wallet already knows this state with
91+
certainty, so it may **declare** it on its read requests via an optional `privatePending` field.
92+
Blockbook then routes deterministically from that declaration instead of guessing.
93+
94+
The field appears in two places, matching the two consumers of the routing machinery:
95+
96+
- **`getAccountInfo` → top-level `privatePending: {nonces, txids}`** drives the pending-**nonce**
97+
lookup. A declared nonce *is* the answer: Blockbook routes the `eth_getTransactionCount` to the
98+
relay and reports at least `max(nonces) + 1`, so the wallet can never reuse the nonce of a private
99+
tx it has in flight — even for a tx this instance never cached (accepted by another replica, or
100+
lost to a restart). The declared floor only ever *raises* the reported nonce; it never lowers a
101+
higher provider/primary answer. The nonce list is capped (see `maxPrivatePendingNonces`) and, past
102+
the cap, collapsed to its single highest entry — only the maximum matters for the floor.
103+
- **`estimateFee``specific.privatePending`** is a **routing signal only**. Unlike a nonce, the
104+
wallet cannot compute gas itself, so Blockbook still simulates the call — the declaration only says
105+
"estimate against the relay's pending-private state" (e.g. a privately-submitted `approve` a
106+
following swap's gas depends on). Presence of a non-empty `nonces` array is all that is read; the
107+
field is stripped before the `eth_estimateGas` call object is forwarded to the relay.
108+
109+
Only `nonces` drives behavior today; `txids` is accepted for forward compatibility (future
110+
pending-tx correlation) and is not yet consumed on any path.
111+
112+
The hint is **additive and backward-compatible**: absent the field, behavior is exactly as before
113+
(the `recentSenders` heuristic remains the fallback, and is still consulted when no hint is
114+
declared), and an older Blockbook simply ignores the unknown field. With no alternative provider
115+
configured the hint is a no-op (there is no private mempool to reconcile against).
116+
117+
**Trust boundary (accepted).** `privatePending` is an *unauthenticated client hint* — Blockbook does
118+
not verify the caller owns the address or that a private tx actually exists. This is safe because the
119+
declaration is per-request only: it is never written into `recentSenders` or the pending-tx cache, so
120+
a hostile client can distort only its **own** request's answer and cannot poison another client's
121+
view or any shared state. Its one outward effect is forcing the read to route to the relay; that is
122+
bounded by the relay's own rate-limit quota and the per-connection pending-requests limit, and — by
123+
design — does **not** re-introduce the #1629 hot-path quota drain, because a normal wallet declares
124+
the field only when it genuinely has a private tx in flight (rare), not on every keystroke.
125+
85126
## Observability
86127

87128
Prometheus counters for the cache lifecycle:

server/websocket_privatepending_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,28 @@ func TestPrivatePendingJSONRoundTripsThroughWsReq(t *testing.T) {
9595
t.Fatalf("privatePending = %+v, want nonces [5]", r.PrivatePending)
9696
}
9797
}
98+
99+
// TestEstimateFeePrivatePendingDecodesIntoSpecific confirms the estimate-side wire contract: a real
100+
// estimateFee envelope carrying specific.privatePending decodes so that r.Specific["privatePending"]
101+
// is the nested object EthereumTypeEstimateGas reads (nonces arriving as JSON float64). Unlike the
102+
// typed getAccountInfo field, the estimate path is an untyped map, so this is the only place the
103+
// nesting is pinned end-to-end.
104+
func TestEstimateFeePrivatePendingDecodesIntoSpecific(t *testing.T) {
105+
var req WsReq
106+
body := `{"id":"1","method":"estimateFee","params":{"blocks":[1],"specific":{"from":"0xabc","privatePending":{"nonces":[42],"txids":["0xdead"]}}}}`
107+
if err := json.Unmarshal([]byte(body), &req); err != nil {
108+
t.Fatalf("outer unmarshal error = %v", err)
109+
}
110+
var r WsEstimateFeeReq
111+
if err := json.Unmarshal(req.Params, &r); err != nil {
112+
t.Fatalf("params unmarshal error = %v", err)
113+
}
114+
pp, ok := r.Specific["privatePending"].(map[string]interface{})
115+
if !ok {
116+
t.Fatalf("specific.privatePending = %#v, want a nested object", r.Specific["privatePending"])
117+
}
118+
nonces, ok := pp["nonces"].([]interface{})
119+
if !ok || len(nonces) != 1 || nonces[0].(float64) != 42 {
120+
t.Fatalf("specific.privatePending.nonces = %#v, want [42]", pp["nonces"])
121+
}
122+
}

server/ws_types.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ type WsAccountInfoReq struct {
5151
// rather than guessing from which addresses recently sent through this instance. Only Nonces drive
5252
// behavior today; Txids are accepted for forward compatibility (future pending-tx correlation).
5353
type WsPrivatePending struct {
54-
Nonces []uint64 `json:"nonces,omitempty" ts_doc:"Account nonces of the wallet's in-flight private transactions for this address."`
54+
Nonces []uint64 `json:"nonces,omitempty" ts_doc:"Account nonces of the wallet's in-flight private transactions for this address. Each entry is a literal in-flight nonce (0 is a valid value, not a sentinel); do not pad or default the array, as any entry raises the reported pending nonce to at least value+1."`
5555
Txids []string `json:"txids,omitempty" ts_doc:"Transaction hashes of the in-flight private transactions (reserved for future use)."`
5656
}
5757

@@ -151,7 +151,7 @@ type WsTransactionSpecificReq struct {
151151
// WsEstimateFeeReq requests an estimation of transaction fees for a set of blocks or with specific parameters.
152152
type WsEstimateFeeReq struct {
153153
Blocks []int `json:"blocks,omitempty" ts_doc:"Block confirmations targets for which fees should be estimated."`
154-
Specific map[string]interface{} `json:"specific,omitempty" ts_type:"{conservative?: boolean; txsize?: number; from?: string; to?: string; data?: string; value?: string; privatePending?: {nonces?: number[]; txids?: string[]};}" ts_doc:"Additional chain-specific parameters (e.g. for Ethereum). privatePending (Ethereum-like) declares the sender's in-flight private transactions so the gas estimate is routed to the alternative send-tx provider; see WsPrivatePending and docs/evm-send.md."`
154+
Specific map[string]interface{} `json:"specific,omitempty" ts_type:"{conservative?: boolean; txsize?: number; from?: string; to?: string; data?: string; value?: string; privatePending?: WsPrivatePending;}" ts_doc:"Additional chain-specific parameters (e.g. for Ethereum). privatePending (Ethereum-like) declares the sender's in-flight private transactions so the gas estimate is routed to the alternative send-tx provider; see WsPrivatePending and docs/evm-send.md."`
155155
}
156156

157157
// WsEstimateFeeRes is returned in response to a fee estimation request.

0 commit comments

Comments
 (0)