From b76e2eb8830a5e4981afc95d2c320192d29bb6f2 Mon Sep 17 00:00:00 2001 From: pragmaxim Date: Tue, 21 Jul 2026 07:40:56 +0000 Subject: [PATCH 1/8] feat(ws): accept privatePending hint on getAccountInfo (wire plumbing) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- api/types.go | 5 ++ blockbook-api.ts | 8 +++ server/websocket.go | 39 +++++++++-- server/websocket_privatepending_test.go | 86 +++++++++++++++++++++++++ server/ws_types.go | 36 +++++++---- 5 files changed, 155 insertions(+), 19 deletions(-) create mode 100644 server/websocket_privatepending_test.go diff --git a/api/types.go b/api/types.go index a6d6fdade7..c9380cd567 100644 --- a/api/types.go +++ b/api/types.go @@ -405,6 +405,11 @@ type AddressFilter struct { // WithConfirmedNonce set to true makes the Ethereum-like address response include the confirmed nonce, // which requires an extra eth_getTransactionCount("latest") backend call; off by default to avoid that cost. WithConfirmedNonce bool `ts_doc:"If true, additionally fetch and return the confirmed nonce for Ethereum-like addresses (extra backend call)."` + // PrivatePendingNonces carries the account nonces of the caller's in-flight private (alternative + // send-tx) transactions when the request declared them (see server.WsPrivatePending). For + // Ethereum-like nonce lookups it routes the query to the alternative provider and raises the + // reported pending nonce above these values, from authoritative wallet state; empty otherwise. + PrivatePendingNonces []uint64 `ts_doc:"Ethereum-like: account nonces of the caller's in-flight private transactions, if the request declared them."` } // StakingPool holds data about address participation in a staking pool contract diff --git a/blockbook-api.ts b/blockbook-api.ts index 14a015c897..b87fddb6fe 100644 --- a/blockbook-api.ts +++ b/blockbook-api.ts @@ -683,6 +683,12 @@ export interface WsRes { /** Payload of the response, structure depends on the request. */ data: any; } +export interface WsPrivatePending { + /** Account nonces of the wallet's in-flight private transactions for this address. */ + nonces?: number[]; + /** Transaction hashes of the in-flight private transactions (reserved for future use). */ + txids?: string[]; +} export interface WsAccountInfoReq { /** Address or XPUB descriptor to query. */ descriptor: string; @@ -708,6 +714,8 @@ export interface WsAccountInfoReq { gap?: number; /** If true, additionally return the confirmed nonce for Ethereum-like addresses (extra backend call). */ confirmedNonce?: boolean; + /** Ethereum-like only: the sender's in-flight private (alternative send-tx / relay) transactions the wallet is tracking for this address. When present, Blockbook answers the pending-nonce lookup from this authoritative wallet state instead of inferring it from recently accepted sends (see docs/evm-send.md). */ + privatePending?: WsPrivatePending; } export interface WsContractInfoReq { /** Contract address to query. */ diff --git a/server/websocket.go b/server/websocket.go index 034566edd1..cf3a3c73da 100644 --- a/server/websocket.go +++ b/server/websocket.go @@ -31,6 +31,7 @@ const outChannelSize = 500 const defaultTimeout = 60 * time.Second const unknownMethodLabel = "unknown" const maxWebsocketMessageBytes int64 = 4 * 1024 * 1024 + // defaultWsPendingRequestsLimit is the default per-connection cap on // concurrently executing requests; override with // _WS_PENDING_REQUESTS_LIMIT (0 disables), see docs/env.md. @@ -47,6 +48,12 @@ const defaultWsPendingRequestsLimit = 48 const maxWebsocketMempoolFiltersResponses = 4 const maxWebsocketActiveRequests = 2048 const maxWebsocketEstimateFeeBlocks = 32 + +// maxPrivatePendingNonces bounds how many declared in-flight private nonces a getAccountInfo +// request may contribute (see WsPrivatePending). An address realistically has a handful of +// in-flight transactions; the cap keeps a malformed or hostile request from forcing unbounded +// work, and only the highest nonce is needed to raise the pending floor anyway. +const maxPrivatePendingNonces = 64 const maxWebsocketSubscribeAddresses = 1000 const maxWebsocketSubscribeAddressesWithNewBlockTxs = 100 const maxWebsocketSubscribeFiatRatesTokens = 1000 @@ -977,6 +984,23 @@ func unmarshalGetAccountInfoRequest(params []byte) (*WsAccountInfoReq, error) { return &r, nil } +// privatePendingNonces extracts the declared in-flight private-transaction nonces from a +// getAccountInfo request, capped at maxPrivatePendingNonces, returning a defensive copy (or nil +// when none are declared). The copy prevents the request struct's backing array from being +// retained past the request and keeps the downstream filter independent of it. +func privatePendingNonces(p *WsPrivatePending) []uint64 { + if p == nil || len(p.Nonces) == 0 { + return nil + } + n := p.Nonces + if len(n) > maxPrivatePendingNonces { + n = n[:maxPrivatePendingNonces] + } + out := make([]uint64, len(n)) + copy(out, n) + return out +} + func (s *WebsocketServer) getAccountInfo(req *WsAccountInfoReq) (res *api.Address, err error) { if err := s.api.ValidateProtocolsForChain(req.Protocols); err != nil { return nil, err @@ -1006,13 +1030,14 @@ func (s *WebsocketServer) getAccountInfo(req *WsAccountInfoReq) (res *api.Addres tokensToReturn = api.TokensToReturnDerived } filter := api.AddressFilter{ - FromHeight: uint32(req.FromHeight), - ToHeight: uint32(req.ToHeight), - Contract: req.ContractFilter, - Vout: api.AddressFilterVoutOff, - TokensToReturn: tokensToReturn, - Protocols: req.Protocols, - WithConfirmedNonce: req.ConfirmedNonce, + FromHeight: uint32(req.FromHeight), + ToHeight: uint32(req.ToHeight), + Contract: req.ContractFilter, + Vout: api.AddressFilterVoutOff, + TokensToReturn: tokensToReturn, + Protocols: req.Protocols, + WithConfirmedNonce: req.ConfirmedNonce, + PrivatePendingNonces: privatePendingNonces(req.PrivatePending), } req.Page, req.PageSize = sanitizeAccountPagingParams(req.Page, req.PageSize, txsOnPage, txsInAPI) req.Gap = validateIntValue(req.Gap, 0, 0, maxGapValue) diff --git a/server/websocket_privatepending_test.go b/server/websocket_privatepending_test.go new file mode 100644 index 0000000000..c9282d143f --- /dev/null +++ b/server/websocket_privatepending_test.go @@ -0,0 +1,86 @@ +//go:build unittest +// +build unittest + +package server + +import ( + "encoding/json" + "reflect" + "testing" +) + +// TestUnmarshalGetAccountInfoRequestPrivatePending verifies the optional privatePending field is +// parsed when present and simply absent otherwise, and that an unknown extra field does not break +// parsing (forward compatibility). +func TestUnmarshalGetAccountInfoRequestPrivatePending(t *testing.T) { + t.Run("present", func(t *testing.T) { + r, err := unmarshalGetAccountInfoRequest([]byte(`{"descriptor":"0xabc","privatePending":{"nonces":[42,43],"txids":["0xdead"]}}`)) + if err != nil { + t.Fatalf("unmarshal error = %v", err) + } + if r.PrivatePending == nil { + t.Fatal("privatePending not parsed") + } + if !reflect.DeepEqual(r.PrivatePending.Nonces, []uint64{42, 43}) { + t.Errorf("nonces = %v, want [42 43]", r.PrivatePending.Nonces) + } + if !reflect.DeepEqual(r.PrivatePending.Txids, []string{"0xdead"}) { + t.Errorf("txids = %v, want [0xdead]", r.PrivatePending.Txids) + } + }) + + t.Run("absent", func(t *testing.T) { + r, err := unmarshalGetAccountInfoRequest([]byte(`{"descriptor":"0xabc"}`)) + if err != nil || r.PrivatePending != nil { + t.Fatalf("got (%+v, %v), want nil privatePending and no error", r.PrivatePending, err) + } + }) + + t.Run("unknown field ignored", func(t *testing.T) { + if _, err := unmarshalGetAccountInfoRequest([]byte(`{"descriptor":"0xabc","somethingNew":123}`)); err != nil { + t.Fatalf("unknown field broke parsing: %v", err) + } + }) +} + +// TestPrivatePendingNonces covers the extraction helper: nil-safe, defensive copy, and the cap. +func TestPrivatePendingNonces(t *testing.T) { + if got := privatePendingNonces(nil); got != nil { + t.Errorf("nil input = %v, want nil", got) + } + if got := privatePendingNonces(&WsPrivatePending{}); got != nil { + t.Errorf("empty nonces = %v, want nil", got) + } + + src := &WsPrivatePending{Nonces: []uint64{7, 8, 9}} + got := privatePendingNonces(src) + if !reflect.DeepEqual(got, []uint64{7, 8, 9}) { + t.Fatalf("got %v, want [7 8 9]", got) + } + // mutating the returned slice must not affect the request struct (defensive copy) + got[0] = 0 + if src.Nonces[0] != 7 { + t.Error("returned slice aliases the request's backing array") + } + + over := make([]uint64, maxPrivatePendingNonces+10) + if capped := privatePendingNonces(&WsPrivatePending{Nonces: over}); len(capped) != maxPrivatePendingNonces { + t.Errorf("capped length = %d, want %d", len(capped), maxPrivatePendingNonces) + } +} + +// TestPrivatePendingJSONRoundTripsThroughWsReq confirms the field survives the two-stage decode +// (outer WsReq envelope, then params) the server actually uses. +func TestPrivatePendingJSONRoundTripsThroughWsReq(t *testing.T) { + var req WsReq + if err := json.Unmarshal([]byte(`{"id":"1","method":"getAccountInfo","params":{"descriptor":"0xabc","privatePending":{"nonces":[5]}}}`), &req); err != nil { + t.Fatalf("outer unmarshal error = %v", err) + } + r, err := unmarshalGetAccountInfoRequest(req.Params) + if err != nil { + t.Fatalf("params unmarshal error = %v", err) + } + if r.PrivatePending == nil || !reflect.DeepEqual(r.PrivatePending.Nonces, []uint64{5}) { + t.Fatalf("privatePending = %+v, want nonces [5]", r.PrivatePending) + } +} diff --git a/server/ws_types.go b/server/ws_types.go index 5c5767aca7..3c44b0d5e6 100644 --- a/server/ws_types.go +++ b/server/ws_types.go @@ -29,18 +29,30 @@ type resultError struct { // WsAccountInfoReq carries parameters for the 'getAccountInfo' method. type WsAccountInfoReq struct { - Descriptor string `json:"descriptor" ts_doc:"Address or XPUB descriptor to query."` - Details string `json:"details,omitempty" ts_type:"'basic' | 'tokens' | 'tokenBalances' | 'txids' | 'txslight' | 'txs'" ts_doc:"Level of detail to retrieve about the account."` - Tokens string `json:"tokens,omitempty" ts_type:"'derived' | 'used' | 'nonzero'" ts_doc:"Which tokens to include in the account info."` - Protocols []string `json:"protocols,omitempty" ts_doc:"Optional protocol enrichments to include. Supported values currently include 'erc4626'."` - PageSize int `json:"pageSize,omitempty" ts_doc:"Number of items per page, if paging is used."` - Page int `json:"page,omitempty" ts_doc:"Requested page index, if paging is used."` - FromHeight int `json:"from,omitempty" ts_doc:"Starting block height for transaction filtering."` - ToHeight int `json:"to,omitempty" ts_doc:"Ending block height for transaction filtering."` - ContractFilter string `json:"contractFilter,omitempty" ts_doc:"Filter by specific contract address (for token data)."` - SecondaryCurrency string `json:"secondaryCurrency,omitempty" ts_doc:"Currency code to convert values into (e.g. 'USD')."` - Gap int `json:"gap,omitempty" ts_doc:"Gap limit for XPUB scanning, if relevant."` - ConfirmedNonce bool `json:"confirmedNonce,omitempty" ts_doc:"If true, additionally return the confirmed nonce for Ethereum-like addresses (extra backend call)."` + Descriptor string `json:"descriptor" ts_doc:"Address or XPUB descriptor to query."` + Details string `json:"details,omitempty" ts_type:"'basic' | 'tokens' | 'tokenBalances' | 'txids' | 'txslight' | 'txs'" ts_doc:"Level of detail to retrieve about the account."` + Tokens string `json:"tokens,omitempty" ts_type:"'derived' | 'used' | 'nonzero'" ts_doc:"Which tokens to include in the account info."` + Protocols []string `json:"protocols,omitempty" ts_doc:"Optional protocol enrichments to include. Supported values currently include 'erc4626'."` + PageSize int `json:"pageSize,omitempty" ts_doc:"Number of items per page, if paging is used."` + Page int `json:"page,omitempty" ts_doc:"Requested page index, if paging is used."` + FromHeight int `json:"from,omitempty" ts_doc:"Starting block height for transaction filtering."` + ToHeight int `json:"to,omitempty" ts_doc:"Ending block height for transaction filtering."` + ContractFilter string `json:"contractFilter,omitempty" ts_doc:"Filter by specific contract address (for token data)."` + SecondaryCurrency string `json:"secondaryCurrency,omitempty" ts_doc:"Currency code to convert values into (e.g. 'USD')."` + Gap int `json:"gap,omitempty" ts_doc:"Gap limit for XPUB scanning, if relevant."` + ConfirmedNonce bool `json:"confirmedNonce,omitempty" ts_doc:"If true, additionally return the confirmed nonce for Ethereum-like addresses (extra backend call)."` + PrivatePending *WsPrivatePending `json:"privatePending,omitempty" ts_doc:"Ethereum-like only: the sender's in-flight private (alternative send-tx / relay) transactions the wallet is tracking for this address. When present, Blockbook answers the pending-nonce lookup from this authoritative wallet state instead of inferring it from recently accepted sends (see docs/evm-send.md)."` +} + +// WsPrivatePending declares the private (alternative send-tx / relay) transactions a wallet knows +// are in flight for the queried Ethereum-like address. A private relay exposes no mempool, so a tx +// pending only there is invisible to the public backend RPC; declaring it lets Blockbook route the +// nonce lookup to the relay and raise the reported pending nonce above these nonces deterministically, +// rather than guessing from which addresses recently sent through this instance. Only Nonces drive +// behavior today; Txids are accepted for forward compatibility (future pending-tx correlation). +type WsPrivatePending struct { + Nonces []uint64 `json:"nonces,omitempty" ts_doc:"Account nonces of the wallet's in-flight private transactions for this address."` + Txids []string `json:"txids,omitempty" ts_doc:"Transaction hashes of the in-flight private transactions (reserved for future use)."` } // WsContractInfoReq carries parameters for the 'getContractInfo' method. From 9cc25168fdc592db0693a3092ad7ebf9f03e6942 Mon Sep 17 00:00:00 2001 From: pragmaxim Date: Tue, 21 Jul 2026 07:46:41 +0000 Subject: [PATCH 2/8] feat(eth): short-circuit nonce routing on the privatePending hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- api/worker.go | 2 +- bchain/basechain.go | 2 +- bchain/coins/blockchain.go | 4 +- bchain/coins/eth/ethrpc.go | 68 ++++++++--- bchain/coins/eth/nonce_hint_test.go | 133 +++++++++++++++++++++ bchain/coins/tron/tronrpc.go | 3 +- bchain/types.go | 2 +- tests/dbtestdata/fakechain_ethereumtype.go | 2 +- 8 files changed, 192 insertions(+), 24 deletions(-) create mode 100644 bchain/coins/eth/nonce_hint_test.go diff --git a/api/worker.go b/api/worker.go index ccb98a7eb5..aa23559c9d 100644 --- a/api/worker.go +++ b/api/worker.go @@ -1270,7 +1270,7 @@ func (w *Worker) getEthereumTypeAddressBalances(addrDesc bchain.AddressDescripto if b != nil { ba.BalanceSat = *b } - nPending, nConfirmed, confirmedNonceOK, err = w.chain.EthereumTypeGetNonces(addrDesc, filter.WithConfirmedNonce) + nPending, nConfirmed, confirmedNonceOK, err = w.chain.EthereumTypeGetNonces(addrDesc, filter.WithConfirmedNonce, filter.PrivatePendingNonces...) if err != nil { return nil, nil, errors.Annotatef(err, "EthereumTypeGetNonces %v", addrDesc) } diff --git a/bchain/basechain.go b/bchain/basechain.go index 5304822b37..721886f3fb 100644 --- a/bchain/basechain.go +++ b/bchain/basechain.go @@ -56,7 +56,7 @@ func (b *BaseChain) EthereumTypeGetBalance(addrDesc AddressDescriptor) (*big.Int } // EthereumTypeGetNonces is not supported -func (b *BaseChain) EthereumTypeGetNonces(addrDesc AddressDescriptor, withConfirmed bool) (uint64, uint64, bool, error) { +func (b *BaseChain) EthereumTypeGetNonces(addrDesc AddressDescriptor, withConfirmed bool, privatePendingNonces ...uint64) (uint64, uint64, bool, error) { return 0, 0, false, errors.New("not supported") } diff --git a/bchain/coins/blockchain.go b/bchain/coins/blockchain.go index 2dbb08f906..0987780b5b 100644 --- a/bchain/coins/blockchain.go +++ b/bchain/coins/blockchain.go @@ -336,9 +336,9 @@ func (c *blockChainWithMetrics) EthereumTypeGetBalance(addrDesc bchain.AddressDe return c.b.EthereumTypeGetBalance(addrDesc) } -func (c *blockChainWithMetrics) EthereumTypeGetNonces(addrDesc bchain.AddressDescriptor, withConfirmed bool) (pending uint64, confirmed uint64, confirmedOK bool, err error) { +func (c *blockChainWithMetrics) EthereumTypeGetNonces(addrDesc bchain.AddressDescriptor, withConfirmed bool, privatePendingNonces ...uint64) (pending uint64, confirmed uint64, confirmedOK bool, err error) { defer func(s time.Time) { c.observeRPCLatency("EthereumTypeGetNonces", s, err) }(time.Now()) - return c.b.EthereumTypeGetNonces(addrDesc, withConfirmed) + return c.b.EthereumTypeGetNonces(addrDesc, withConfirmed, privatePendingNonces...) } func (c *blockChainWithMetrics) EthereumTypeEstimateGas(params map[string]interface{}) (v uint64, err error) { diff --git a/bchain/coins/eth/ethrpc.go b/bchain/coins/eth/ethrpc.go index 29a133ea5b..819309f57f 100644 --- a/bchain/coins/eth/ethrpc.go +++ b/bchain/coins/eth/ethrpc.go @@ -2331,25 +2331,36 @@ func (b *EthereumRPC) EthereumTypeGetBalance(addrDesc bchain.AddressDescriptor) // lookup fails, the pending nonce is still returned with confirmedOK=false so the caller // can omit it rather than failing the whole request. When confirmedOK is false the returned // confirmed value is 0 and must be ignored. -func (b *EthereumRPC) EthereumTypeGetNonces(addrDesc bchain.AddressDescriptor, withConfirmed bool) (uint64, uint64, bool, error) { +func (b *EthereumRPC) EthereumTypeGetNonces(addrDesc bchain.AddressDescriptor, withConfirmed bool, privatePendingNonces ...uint64) (uint64, uint64, bool, error) { ethAddress := ethcommon.BytesToAddress(addrDesc) + // The caller may declare the account nonces of its in-flight private transactions (see + // server.WsPrivatePending). declaredFloor is the lowest pending nonce consistent with them, + // or 0 when none were declared. + declaredFloor := declaredPendingFloor(privatePendingNonces) - if b.alternativeSendTxProvider != nil && b.alternativeSendTxProvider.useForNonces(ethAddress) { - pending, confirmed, confirmedOK, err := b.alternativeSendTxProvider.getNonces(ethAddress, withConfirmed) - if err == nil { - b.observeAlternativeNonceRequest("success") - // Even the provider's own answer can fall below Blockbook's advertised pending - // view: Blink-style relays stop counting a still-pending tx at the pending tag - // while Blockbook keeps exposing it until the cache timeout (see - // reconcileMempoolTxs). - raised := b.alternativeSendTxProvider.raiseToPendingFloor(ethAddress, pending) - if raised > pending { - b.observePendingFloorRaised("provider") + if b.alternativeSendTxProvider != nil { + // Route to the provider when the caller declared in-flight private txs (a deterministic + // short-circuit: the wallet knows this authoritatively) OR when our own heuristic says the + // sender recently sent privately through this instance. The declared hint bypasses the + // recentSenders guess and its restart / load-balanced-replica fragility (#1629 rationale). + if declaredFloor > 0 || b.alternativeSendTxProvider.useForNonces(ethAddress) { + pending, confirmed, confirmedOK, err := b.alternativeSendTxProvider.getNonces(ethAddress, withConfirmed) + if err == nil { + b.observeAlternativeNonceRequest("success") + // Even the provider's own answer can fall below Blockbook's advertised pending + // view: Blink-style relays stop counting a still-pending tx at the pending tag + // while Blockbook keeps exposing it until the cache timeout (see + // reconcileMempoolTxs). The declared floor covers the same gap for a tx this + // instance never cached (accepted by another replica, or lost to a restart). + raised := b.alternativeSendTxProvider.raiseToPendingFloor(ethAddress, pending) + if raised > pending { + b.observePendingFloorRaised("provider") + } + return raiseToFloor(raised, declaredFloor), confirmed, confirmedOK, nil } - return raised, confirmed, confirmedOK, nil + b.observeAlternativeNonceRequest("error") + glog.Warningf("Alternative provider failed for eth_getTransactionCount: %v, falling back to primary RPC", err) } - b.observeAlternativeNonceRequest("error") - glog.Warningf("Alternative provider failed for eth_getTransactionCount: %v, falling back to primary RPC", err) } pending, confirmed, confirmedOK, err := b.getNoncesRPC(ethAddress, withConfirmed) @@ -2363,16 +2374,39 @@ func (b *EthereumRPC) EthereumTypeGetNonces(addrDesc bchain.AddressDescriptor, w // until fetch-back time + timeout (plus reconcile granularity), and in that window a // primary answer below the floor would contradict the pending tx Blockbook still // displays. The floor is a local scan of a usually-empty map, so it costs nothing on - // the hot path. + // the hot path. The caller-declared floor is folded in for the same reason. raised := b.alternativeSendTxProvider.raiseToPendingFloor(ethAddress, pending) if raised > pending { b.observePendingFloorRaised("primary") } - pending = raised + pending = raiseToFloor(raised, declaredFloor) } return pending, confirmed, confirmedOK, nil } +// declaredPendingFloor returns the lowest pending nonce consistent with the caller-declared +// in-flight private transactions (highest declared nonce + 1), or 0 when none were declared. The +// wallet knows its own submitted nonces authoritatively, so honoring this keeps the reported +// pending nonce from falling below a private transaction the wallet has in flight even when this +// Blockbook instance never saw it (a different replica accepted it, or a restart cleared the cache). +func declaredPendingFloor(nonces []uint64) uint64 { + var floor uint64 + for _, n := range nonces { + if n+1 > floor { + floor = n + 1 + } + } + return floor +} + +// raiseToFloor returns the larger of pending and floor. +func raiseToFloor(pending, floor uint64) uint64 { + if floor > pending { + return floor + } + return pending +} + // getNoncesRPC fetches the pending account nonce from the primary RPC, plus the confirmed // (latest) nonce when withConfirmed is set. When both are requested and the client supports // JSON-RPC batching, they are fetched in a single round-trip; otherwise the calls are made diff --git a/bchain/coins/eth/nonce_hint_test.go b/bchain/coins/eth/nonce_hint_test.go new file mode 100644 index 0000000000..c2df72211b --- /dev/null +++ b/bchain/coins/eth/nonce_hint_test.go @@ -0,0 +1,133 @@ +//go:build unittest + +package eth + +import ( + "testing" + "time" + + ethcommon "github.com/ethereum/go-ethereum/common" + "github.com/trezor/blockbook/bchain" +) + +// TestEthereumTypeGetNonces_PrivatePendingHint_RoutesUnknownAddress verifies the declared +// private-pending hint short-circuits routing: an address that is NOT a recent private sender (so +// useForNonces is false and it would otherwise go to the primary RPC) is routed to the alternative +// provider purely because the request declared an in-flight private nonce. +func TestEthereumTypeGetNonces_PrivatePendingHint_RoutesUnknownAddress(t *testing.T) { + server := newNonceRPCServer(t, map[string]string{"pending": "0x9"}, nil) + stub := &nonceBatchStub{results: map[string]string{"pending": "0x4"}} + // no recent senders → without the hint this address would be served by the primary RPC + b := &EthereumRPC{RPC: stub, Timeout: time.Second, alternativeSendTxProvider: newRecentSenderProvider(server)} + + // declared nonce 42 → floor 43, which exceeds the provider's answer of 9 + pending, _, _, err := b.EthereumTypeGetNonces(nonceTestAddr, false, 42) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pending != 43 { + t.Errorf("pending = %d, want 43 (declared floor over the provider answer)", pending) + } + if got := server.callCount("pending"); got != 1 { + t.Errorf("alternative provider queried %d times, want 1 (hint must route despite no recent send)", got) + } + if len(stub.queried) != 0 { + t.Errorf("primary RPC queried tags %v, want none once routed to the provider", stub.queried) + } +} + +// TestEthereumTypeGetNonces_PrivatePendingHint_ProviderAnswerWins confirms the provider's own +// answer is used when it already exceeds the declared floor (the floor only raises, never lowers). +func TestEthereumTypeGetNonces_PrivatePendingHint_ProviderAnswerWins(t *testing.T) { + server := newNonceRPCServer(t, map[string]string{"pending": "0x64"}, nil) // 100 + stub := &nonceBatchStub{results: map[string]string{"pending": "0x4"}} + b := &EthereumRPC{RPC: stub, Timeout: time.Second, alternativeSendTxProvider: newRecentSenderProvider(server)} + + pending, _, _, err := b.EthereumTypeGetNonces(nonceTestAddr, false, 42) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pending != 100 { + t.Errorf("pending = %d, want 100 (provider answer exceeds the declared floor)", pending) + } +} + +// TestEthereumTypeGetNonces_PrivatePendingHint_RaisesPrimaryFallback confirms the declared floor is +// applied on the primary fallback path too: the hint routes to the provider, the provider fails, and +// the primary answer (4) is raised to the declared floor (43) so the wallet cannot reuse the nonce +// of the private tx it just declared. +func TestEthereumTypeGetNonces_PrivatePendingHint_RaisesPrimaryFallback(t *testing.T) { + server := newNonceRPCServer(t, nil, map[string]bool{"pending": true}) // provider errors + stub := &nonceBatchStub{results: map[string]string{"pending": "0x4"}} + b := &EthereumRPC{RPC: stub, Timeout: time.Second, alternativeSendTxProvider: newRecentSenderProvider(server)} + + pending, _, _, err := b.EthereumTypeGetNonces(nonceTestAddr, false, 42) + if err != nil { + t.Fatalf("provider failure must fall back to the primary RPC, got error: %v", err) + } + if pending != 43 { + t.Errorf("pending = %d, want 43 (declared floor over the primary fallback answer)", pending) + } +} + +// TestEthereumTypeGetNonces_PrivatePendingHint_IgnoredWithoutProvider confirms the hint is a +// relay-deployment feature: with no alternative provider configured it is ignored and the primary +// answer stands unchanged. +func TestEthereumTypeGetNonces_PrivatePendingHint_IgnoredWithoutProvider(t *testing.T) { + stub := &nonceBatchStub{results: map[string]string{"pending": "0x4"}} + b := &EthereumRPC{RPC: stub, Timeout: time.Second} // no alternativeSendTxProvider + + pending, _, _, err := b.EthereumTypeGetNonces(nonceTestAddr, false, 42) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pending != 4 { + t.Errorf("pending = %d, want 4 (hint ignored without a provider)", pending) + } +} + +// TestEthereumTypeGetNonces_PrivatePendingHint_FoldsCacheAndDeclaredFloor confirms the reported +// nonce is raised to the maximum of the cache floor and the declared floor. Here the declared floor +// (from nonce 9 → 10) is lower than the cache floor (from cached nonce 0x7 → 8)? No — declared wins: +// cached nonce 0x7 → floor 8, declared nonce 9 → floor 10, so the answer is 10. +func TestEthereumTypeGetNonces_PrivatePendingHint_FoldsCacheAndDeclaredFloor(t *testing.T) { + server := newNonceRPCServer(t, map[string]string{"pending": "0x2"}, nil) + stub := &nonceBatchStub{results: map[string]string{"pending": "0x1"}} + sender := ethcommon.BytesToAddress(nonceTestAddr) + provider := newRecentSenderProvider(server, sender) + provider.fetchMempoolTx = true + provider.mempoolTxs = map[string]storedTx{ + testAlternativeTxID: { + tx: &bchain.RpcTransaction{Hash: testAlternativeTxID, From: sender.Hex(), AccountNonce: "0x7"}, + time: uint32(time.Now().Unix()), + }, + } + b := &EthereumRPC{RPC: stub, Timeout: time.Second, alternativeSendTxProvider: provider} + + pending, _, _, err := b.EthereumTypeGetNonces(nonceTestAddr, false, 9) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pending != 10 { + t.Errorf("pending = %d, want 10 (declared floor 10 over cache floor 8 and provider answer 2)", pending) + } +} + +// TestDeclaredPendingFloor covers the floor helper directly. +func TestDeclaredPendingFloor(t *testing.T) { + cases := []struct { + in []uint64 + want uint64 + }{ + {nil, 0}, + {[]uint64{}, 0}, + {[]uint64{0}, 1}, + {[]uint64{5}, 6}, + {[]uint64{5, 42, 7}, 43}, + } + for _, c := range cases { + if got := declaredPendingFloor(c.in); got != c.want { + t.Errorf("declaredPendingFloor(%v) = %d, want %d", c.in, got, c.want) + } + } +} diff --git a/bchain/coins/tron/tronrpc.go b/bchain/coins/tron/tronrpc.go index cd9299a208..8b662b9786 100644 --- a/bchain/coins/tron/tronrpc.go +++ b/bchain/coins/tron/tronrpc.go @@ -1164,7 +1164,8 @@ func (b *TronRPC) EthereumTypeRpcCall(data, to, from string) (string, error) { // EthereumTypeGetNonces returns the account nonce. Tron exposes only the latest // (confirmed) nonce via NonceAt in a single call, so the pending and confirmed // values are identical and the withConfirmed flag carries no extra cost here. -func (b *TronRPC) EthereumTypeGetNonces(addrDesc bchain.AddressDescriptor, withConfirmed bool) (uint64, uint64, bool, error) { +// Tron has no alternative send-tx relay, so the privatePendingNonces hint is ignored. +func (b *TronRPC) EthereumTypeGetNonces(addrDesc bchain.AddressDescriptor, withConfirmed bool, privatePendingNonces ...uint64) (uint64, uint64, bool, error) { ctx, cancel := context.WithTimeout(b.requestContext(), b.Timeout) defer cancel() n, err := b.Client.NonceAt(ctx, addrDesc, nil) diff --git a/bchain/types.go b/bchain/types.go index ca215464a9..a0b3e8d068 100644 --- a/bchain/types.go +++ b/bchain/types.go @@ -352,7 +352,7 @@ type BlockChain interface { GetChainParser() BlockChainParser // EthereumType specific EthereumTypeGetBalance(addrDesc AddressDescriptor) (*big.Int, error) - EthereumTypeGetNonces(addrDesc AddressDescriptor, withConfirmed bool) (pending uint64, confirmed uint64, confirmedOK bool, err error) + EthereumTypeGetNonces(addrDesc AddressDescriptor, withConfirmed bool, privatePendingNonces ...uint64) (pending uint64, confirmed uint64, confirmedOK bool, err error) EthereumTypeEstimateGas(params map[string]interface{}) (uint64, error) EthereumTypeGetEip1559Fees() (*Eip1559Fees, error) EthereumTypeGetErc20ContractBalance(addrDesc, contractDesc AddressDescriptor) (*big.Int, error) diff --git a/tests/dbtestdata/fakechain_ethereumtype.go b/tests/dbtestdata/fakechain_ethereumtype.go index bffa322bfb..846c178c25 100644 --- a/tests/dbtestdata/fakechain_ethereumtype.go +++ b/tests/dbtestdata/fakechain_ethereumtype.go @@ -115,7 +115,7 @@ func (c *fakeBlockChainEthereumType) EthereumTypeGetBalance(addrDesc bchain.Addr return big.NewInt(123450000 + int64(addrDesc[0])), nil } -func (c *fakeBlockChainEthereumType) EthereumTypeGetNonces(addrDesc bchain.AddressDescriptor, withConfirmed bool) (uint64, uint64, bool, error) { +func (c *fakeBlockChainEthereumType) EthereumTypeGetNonces(addrDesc bchain.AddressDescriptor, withConfirmed bool, privatePendingNonces ...uint64) (uint64, uint64, bool, error) { // pending and confirmed are equal in the fake; production fetches them from // distinct block tags ("pending" vs "latest"), and only fetches confirmed when requested. return uint64(addrDesc[0]), uint64(addrDesc[0]), withConfirmed, nil From 2c7c4a8ca61c84277095e6f716868c829fead692 Mon Sep 17 00:00:00 2001 From: pragmaxim Date: Tue, 21 Jul 2026 07:57:44 +0000 Subject: [PATCH 3/8] fix(ws): preserve the highest nonce when capping privatePending 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 --- bchain/coins/eth/nonce_hint_test.go | 6 +++--- server/websocket.go | 23 ++++++++++++++++------- server/websocket_privatepending_test.go | 15 +++++++++++++-- 3 files changed, 32 insertions(+), 12 deletions(-) diff --git a/bchain/coins/eth/nonce_hint_test.go b/bchain/coins/eth/nonce_hint_test.go index c2df72211b..10d18aa155 100644 --- a/bchain/coins/eth/nonce_hint_test.go +++ b/bchain/coins/eth/nonce_hint_test.go @@ -87,9 +87,9 @@ func TestEthereumTypeGetNonces_PrivatePendingHint_IgnoredWithoutProvider(t *test } // TestEthereumTypeGetNonces_PrivatePendingHint_FoldsCacheAndDeclaredFloor confirms the reported -// nonce is raised to the maximum of the cache floor and the declared floor. Here the declared floor -// (from nonce 9 → 10) is lower than the cache floor (from cached nonce 0x7 → 8)? No — declared wins: -// cached nonce 0x7 → floor 8, declared nonce 9 → floor 10, so the answer is 10. +// nonce is the maximum of the provider answer, the cache floor, and the declared floor. Here: +// provider answer 2, cache floor 8 (cached nonce 0x7 + 1), declared floor 10 (declared nonce 9 + 1) +// → the declared floor wins at 10. func TestEthereumTypeGetNonces_PrivatePendingHint_FoldsCacheAndDeclaredFloor(t *testing.T) { server := newNonceRPCServer(t, map[string]string{"pending": "0x2"}, nil) stub := &nonceBatchStub{results: map[string]string{"pending": "0x1"}} diff --git a/server/websocket.go b/server/websocket.go index cf3a3c73da..7d753081e7 100644 --- a/server/websocket.go +++ b/server/websocket.go @@ -992,13 +992,22 @@ func privatePendingNonces(p *WsPrivatePending) []uint64 { if p == nil || len(p.Nonces) == 0 { return nil } - n := p.Nonces - if len(n) > maxPrivatePendingNonces { - n = n[:maxPrivatePendingNonces] - } - out := make([]uint64, len(n)) - copy(out, n) - return out + if len(p.Nonces) <= maxPrivatePendingNonces { + out := make([]uint64, len(p.Nonces)) + copy(out, p.Nonces) + return out + } + // More declared nonces than we carry downstream — a malformed or hostile request, since a + // real wallet has only a handful of sequential in-flight nonces. Only the highest matters + // for the pending floor, so collapse to it rather than positionally truncating (which would + // drop the true maximum for an unsorted array and under-compute the floor). + var highest uint64 + for _, n := range p.Nonces { + if n > highest { + highest = n + } + } + return []uint64{highest} } func (s *WebsocketServer) getAccountInfo(req *WsAccountInfoReq) (res *api.Address, err error) { diff --git a/server/websocket_privatepending_test.go b/server/websocket_privatepending_test.go index c9282d143f..5dadebf648 100644 --- a/server/websocket_privatepending_test.go +++ b/server/websocket_privatepending_test.go @@ -63,9 +63,20 @@ func TestPrivatePendingNonces(t *testing.T) { t.Error("returned slice aliases the request's backing array") } + // exactly at the cap: kept in full, no truncation + atCap := make([]uint64, maxPrivatePendingNonces) + if got := privatePendingNonces(&WsPrivatePending{Nonces: atCap}); len(got) != maxPrivatePendingNonces { + t.Errorf("at-cap length = %d, want %d (no truncation at the boundary)", len(got), maxPrivatePendingNonces) + } + // over the cap: collapses to the single highest nonce, so the pending floor is still correct + // even when the maximum is not at a positional index the old first-N truncation would keep. over := make([]uint64, maxPrivatePendingNonces+10) - if capped := privatePendingNonces(&WsPrivatePending{Nonces: over}); len(capped) != maxPrivatePendingNonces { - t.Errorf("capped length = %d, want %d", len(capped), maxPrivatePendingNonces) + for i := range over { + over[i] = uint64(i) // ascending: the true max sits at the last index, beyond the cap + } + overResult := privatePendingNonces(&WsPrivatePending{Nonces: over}) + if len(overResult) != 1 || overResult[0] != uint64(len(over)-1) { + t.Errorf("over-cap result = %v, want [%d] (highest nonce preserved)", overResult, len(over)-1) } } From 9f49dd09c00819baa3acdf9e0068ceaf34bcdaea Mon Sep 17 00:00:00 2001 From: pragmaxim Date: Tue, 21 Jul 2026 08:44:26 +0000 Subject: [PATCH 4/8] feat(ws): document privatePending on the estimateFee request (contract) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- blockbook-api.ts | 4 ++-- server/ws_types.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/blockbook-api.ts b/blockbook-api.ts index b87fddb6fe..8013c51a47 100644 --- a/blockbook-api.ts +++ b/blockbook-api.ts @@ -820,8 +820,8 @@ export interface WsTransactionSpecificReq { export interface WsEstimateFeeReq { /** Block confirmations targets for which fees should be estimated. */ blocks?: number[]; - /** Additional chain-specific parameters (e.g. for Ethereum). */ - specific?: {conservative?: boolean; txsize?: number; from?: string; to?: string; data?: string; value?: string;}; + /** 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. */ + specific?: {conservative?: boolean; txsize?: number; from?: string; to?: string; data?: string; value?: string; privatePending?: {nonces?: number[]; txids?: string[]};}; } export interface Eip1559Fee { maxFeePerGas?: string; diff --git a/server/ws_types.go b/server/ws_types.go index 3c44b0d5e6..7b3a31155e 100644 --- a/server/ws_types.go +++ b/server/ws_types.go @@ -151,7 +151,7 @@ type WsTransactionSpecificReq struct { // WsEstimateFeeReq requests an estimation of transaction fees for a set of blocks or with specific parameters. type WsEstimateFeeReq struct { Blocks []int `json:"blocks,omitempty" ts_doc:"Block confirmations targets for which fees should be estimated."` - Specific map[string]interface{} `json:"specific,omitempty" ts_type:"{conservative?: boolean; txsize?: number; from?: string; to?: string; data?: string; value?: string;}" ts_doc:"Additional chain-specific parameters (e.g. for Ethereum)."` + 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."` } // WsEstimateFeeRes is returned in response to a fee estimation request. From c7eab04ccff03fdc64147f91f04cefa7d54b7691 Mon Sep 17 00:00:00 2001 From: pragmaxim Date: Tue, 21 Jul 2026 08:48:16 +0000 Subject: [PATCH 5/8] feat(eth): short-circuit gas-estimate routing on the privatePending hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- bchain/coins/eth/ethrpc.go | 63 +++++++-- .../eth/ethrpc_estimate_gas_hint_test.go | 122 ++++++++++++++++++ 2 files changed, 172 insertions(+), 13 deletions(-) create mode 100644 bchain/coins/eth/ethrpc_estimate_gas_hint_test.go diff --git a/bchain/coins/eth/ethrpc.go b/bchain/coins/eth/ethrpc.go index 819309f57f..773c2a18aa 100644 --- a/bchain/coins/eth/ethrpc.go +++ b/bchain/coins/eth/ethrpc.go @@ -1989,21 +1989,24 @@ func (b *EthereumRPC) EthereumTypeEstimateGas(params map[string]interface{}) (ui msg.GasPrice, _ = hexutil.DecodeBig(s) } - // Route eth_estimateGas through the alternative provider ONLY for a sender that recently - // sent a private transaction through it (see useForNonces) - that sender may have a pending - // private tx the primary RPC does not know about, so estimating against the provider's state - // is what the provider path exists for. Every other estimate (the overwhelming majority - the - // wallet calls estimateFee on every send-form keystroke, see trezor-suite - // sendFormEthereumThunks) goes straight to the primary backend, so the hot estimateFee - // endpoint no longer burns the provider's rate-limit quota (#1629). A missing/empty `from` - // also takes the primary path: without a sender the gate cannot apply and the primary is - // authoritative for gas estimation anyway. + // Route eth_estimateGas through the alternative provider when the request declares in-flight + // private transactions for the sender (privatePending), or - failing that - when our own + // heuristic says the sender recently sent privately (see useForNonces). Both mean the same + // thing: the estimate must run against the relay's pending-private state the primary RPC cannot + // see (e.g. a privately-submitted approve that a following swap's gas depends on). The declared + // signal is authoritative and does not depend on this instance having accepted the send, so it + // bypasses the recentSenders heuristic and its restart / load-balanced-replica gaps. Every other + // estimate (the overwhelming majority) goes straight to the primary backend, so the hot + // estimateFee endpoint does not burn the provider's rate-limit quota (#1629); a missing/empty + // `from` also takes the primary path. Unlike the nonce hint - which IS the answer and short- + // circuits the lookup - a declared estimate only selects the backend; Blockbook still simulates. + declaredPrivatePending := estimatePrivatePendingDeclared(params) if b.alternativeSendTxProvider != nil && msg.From != (ethcommon.Address{}) && - b.alternativeSendTxProvider.useForNonces(msg.From) { + (declaredPrivatePending || b.alternativeSendTxProvider.useForNonces(msg.From)) { result, err := b.alternativeSendTxProvider.callHttpStringResult( b.alternativeSendTxProvider.nonceURL(msg.From), "eth_estimateGas", - params, + estimateParamsWithoutPrivatePending(params), ) if err == nil { // Count success only once the result decodes: a malformed hex quantity is a provider @@ -2032,8 +2035,9 @@ func (b *EthereumRPC) EthereumTypeEstimateGas(params map[string]interface{}) (ui // observeAlternativeEstimateGasRequest records an eth_estimateGas call routed to the alternative // send-tx provider, labeled by result: success (provider answered) or error (provider failed and -// the estimate fell back to the primary RPC). Only recent private senders are routed here (see -// useForNonces), so this counts the gated subset rather than every estimateFee request. +// the estimate fell back to the primary RPC). Only senders that declared private-pending state or +// recently sent privately (see useForNonces) are routed here, so this counts the gated subset +// rather than every estimateFee request. func (b *EthereumRPC) observeAlternativeEstimateGasRequest(result string) { if b.metrics == nil || b.metrics.EthAlternativeEstimateGasRequests == nil { return @@ -2041,6 +2045,39 @@ func (b *EthereumRPC) observeAlternativeEstimateGasRequest(result string) { b.metrics.EthAlternativeEstimateGasRequests.With(common.Labels{"result": result}).Inc() } +// estimatePrivatePendingDeclared reports whether an estimateFee request declared in-flight private +// transactions for the sender via privatePending.nonces in its specific params (see +// server.WsPrivatePending). It is only a routing signal - unlike a nonce, the wallet cannot compute +// gas itself, so Blockbook must still simulate the call; the declaration just says "estimate against +// the relay's pending-private state". Only presence matters, not the values. params is a decoded +// JSON object, so nested objects/arrays are map[string]interface{} / []interface{}. +func estimatePrivatePendingDeclared(params map[string]interface{}) bool { + pp, ok := params["privatePending"].(map[string]interface{}) + if !ok { + return false + } + nonces, ok := pp["nonces"].([]interface{}) + return ok && len(nonces) > 0 +} + +// estimateParamsWithoutPrivatePending returns params with the privatePending key removed, so the +// wallet's bookkeeping is not forwarded as part of the eth_estimateGas call object. It copies only +// when the key is present, so the common (no-hint) path is zero-cost and never mutates the caller's +// map. +func estimateParamsWithoutPrivatePending(params map[string]interface{}) map[string]interface{} { + if _, ok := params["privatePending"]; !ok { + return params + } + out := make(map[string]interface{}, len(params)) + for k, v := range params { + if k == "privatePending" { + continue + } + out[k] = v + } + return out +} + // bigIntToFloat converts a wei amount to float64 for gauge export. float64 holds integers // exactly up to 2^53 (~9e15 wei), far above any realistic gas price, so no precision is lost; // keeping the metric in raw wei (base units) matches the repo convention and Grafana divides diff --git a/bchain/coins/eth/ethrpc_estimate_gas_hint_test.go b/bchain/coins/eth/ethrpc_estimate_gas_hint_test.go new file mode 100644 index 0000000000..c06fed67d7 --- /dev/null +++ b/bchain/coins/eth/ethrpc_estimate_gas_hint_test.go @@ -0,0 +1,122 @@ +package eth + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "reflect" + "sync/atomic" + "testing" +) + +// TestEthereumTypeEstimateGasRoutesOnPrivatePendingHint verifies the declared privatePending hint +// short-circuits routing: a sender that is NOT a recent private sender (so useForNonces is false and +// the estimate would otherwise go to the primary backend) is routed to the alternative provider +// purely because the request declared an in-flight private nonce. +func TestEthereumTypeEstimateGasRoutesOnPrivatePendingHint(t *testing.T) { + primary, primaryHits := countingEstimateGasServer(t, "0x5208") + provider, providerHits := countingEstimateGasServer(t, "0x9999") + b := newEstimateGasTestRPC(t, primary.URL, provider.URL) + + gas, err := b.EthereumTypeEstimateGas(map[string]interface{}{ + "from": "0x2222222222222222222222222222222222222222", + "to": "0x3333333333333333333333333333333333333333", + "privatePending": map[string]interface{}{"nonces": []interface{}{float64(42)}}, + }) + if err != nil { + t.Fatalf("EthereumTypeEstimateGas() error = %v", err) + } + if gas != 0x9999 { + t.Fatalf("gas = %#x, want 0x9999 (provider value)", gas) + } + if got := atomic.LoadInt32(providerHits); got != 1 { + t.Errorf("provider hits = %d, want 1 (hint must route despite no recent send)", got) + } + if got := atomic.LoadInt32(primaryHits); got != 0 { + t.Errorf("primary hits = %d, want 0", got) + } +} + +// TestEthereumTypeEstimateGasHintStripsPrivatePendingFromRelayCall confirms the wallet's +// privatePending bookkeeping is not forwarded as part of the eth_estimateGas call object sent to the +// relay - only the real tx-call fields (from/to/…) are. +func TestEthereumTypeEstimateGasHintStripsPrivatePendingFromRelayCall(t *testing.T) { + primary, _ := countingEstimateGasServer(t, "0x5208") + + var gotParams map[string]interface{} + provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var req struct { + Params []map[string]interface{} `json:"params"` + } + _ = json.Unmarshal(body, &req) + if len(req.Params) > 0 { + gotParams = req.Params[0] + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":"0x9999"}`)) + })) + t.Cleanup(provider.Close) + b := newEstimateGasTestRPC(t, primary.URL, provider.URL) + + if _, err := b.EthereumTypeEstimateGas(map[string]interface{}{ + "from": "0x2222222222222222222222222222222222222222", + "privatePending": map[string]interface{}{"nonces": []interface{}{float64(42)}, "txids": []interface{}{"0xdead"}}, + }); err != nil { + t.Fatalf("EthereumTypeEstimateGas() error = %v", err) + } + if gotParams == nil { + t.Fatal("provider was not called") + } + if _, present := gotParams["privatePending"]; present { + t.Errorf("privatePending was forwarded to eth_estimateGas: %v", gotParams) + } + if gotParams["from"] != "0x2222222222222222222222222222222222222222" { + t.Errorf("from not forwarded to the relay call: %v", gotParams["from"]) + } +} + +func TestEstimatePrivatePendingDeclared(t *testing.T) { + cases := []struct { + name string + params map[string]interface{} + want bool + }{ + {"absent", map[string]interface{}{"from": "0x1"}, false}, + {"empty object", map[string]interface{}{"privatePending": map[string]interface{}{}}, false}, + {"empty nonces", map[string]interface{}{"privatePending": map[string]interface{}{"nonces": []interface{}{}}}, false}, + {"declared", map[string]interface{}{"privatePending": map[string]interface{}{"nonces": []interface{}{float64(42)}}}, true}, + {"wrong type", map[string]interface{}{"privatePending": "nope"}, false}, + {"nonces wrong type", map[string]interface{}{"privatePending": map[string]interface{}{"nonces": "nope"}}, false}, + } + for _, c := range cases { + if got := estimatePrivatePendingDeclared(c.params); got != c.want { + t.Errorf("%s: estimatePrivatePendingDeclared = %v, want %v", c.name, got, c.want) + } + } +} + +func TestEstimateParamsWithoutPrivatePending(t *testing.T) { + // absent: returns the same map, no copy + noHint := map[string]interface{}{"from": "0x1", "to": "0x2"} + if got := estimateParamsWithoutPrivatePending(noHint); !reflect.DeepEqual(got, noHint) { + t.Errorf("no-hint result = %v, want unchanged %v", got, noHint) + } + + // present: privatePending removed, other fields kept, input not mutated + withHint := map[string]interface{}{ + "from": "0x1", + "privatePending": map[string]interface{}{"nonces": []interface{}{float64(1)}}, + } + got := estimateParamsWithoutPrivatePending(withHint) + if _, present := got["privatePending"]; present { + t.Error("privatePending not removed from returned params") + } + if got["from"] != "0x1" { + t.Errorf("from = %v, want 0x1 (other fields must be kept)", got["from"]) + } + if _, present := withHint["privatePending"]; !present { + t.Error("input map was mutated (privatePending removed from caller's map)") + } +} From 634e45e43b23e94f115dc1d89eb65d013ec19d8d Mon Sep 17 00:00:00 2001 From: pragmaxim Date: Tue, 21 Jul 2026 11:01:26 +0000 Subject: [PATCH 6/8] 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) --- bchain/coins/eth/ethrpc.go | 3 ++ bchain/coins/eth/nonce_hint_test.go | 54 +++++++++++++++++++++++++ blockbook-api.ts | 4 +- docs/evm-send.md | 41 +++++++++++++++++++ server/websocket_privatepending_test.go | 25 ++++++++++++ server/ws_types.go | 4 +- 6 files changed, 127 insertions(+), 4 deletions(-) diff --git a/bchain/coins/eth/ethrpc.go b/bchain/coins/eth/ethrpc.go index 773c2a18aa..61a8a7cbb1 100644 --- a/bchain/coins/eth/ethrpc.go +++ b/bchain/coins/eth/ethrpc.go @@ -2000,6 +2000,9 @@ func (b *EthereumRPC) EthereumTypeEstimateGas(params map[string]interface{}) (ui // estimateFee endpoint does not burn the provider's rate-limit quota (#1629); a missing/empty // `from` also takes the primary path. Unlike the nonce hint - which IS the answer and short- // circuits the lookup - a declared estimate only selects the backend; Blockbook still simulates. + // The hint is an unauthenticated, per-request client signal: it can route only the caller's own + // request to the relay and never touches shared state, so it does not re-open the #1629 hot-path + // quota drain (accepted trust boundary, see docs/evm-send.md). declaredPrivatePending := estimatePrivatePendingDeclared(params) if b.alternativeSendTxProvider != nil && msg.From != (ethcommon.Address{}) && (declaredPrivatePending || b.alternativeSendTxProvider.useForNonces(msg.From)) { diff --git a/bchain/coins/eth/nonce_hint_test.go b/bchain/coins/eth/nonce_hint_test.go index 10d18aa155..59052f556b 100644 --- a/bchain/coins/eth/nonce_hint_test.go +++ b/bchain/coins/eth/nonce_hint_test.go @@ -3,6 +3,7 @@ package eth import ( + "math" "testing" "time" @@ -70,6 +71,54 @@ func TestEthereumTypeGetNonces_PrivatePendingHint_RaisesPrimaryFallback(t *testi } } +// TestEthereumTypeGetNonces_PrivatePendingHint_WithConfirmedNonce exercises the exact production +// combination (api/worker.go passes WithConfirmedNonce together with PrivatePendingNonces...): the +// declared floor must raise only the PENDING nonce and leave the confirmed (latest) nonce untouched. +func TestEthereumTypeGetNonces_PrivatePendingHint_WithConfirmedNonce(t *testing.T) { + server := newNonceRPCServer(t, map[string]string{"pending": "0x9", "latest": "0x5"}, nil) + stub := &nonceBatchStub{results: map[string]string{"pending": "0x4", "latest": "0x2"}} + // no recent senders → routed purely by the declared floor + b := &EthereumRPC{RPC: stub, Timeout: time.Second, alternativeSendTxProvider: newRecentSenderProvider(server)} + + // declared nonce 42 → pending floor 43, above the provider's pending answer of 9 + pending, confirmed, confirmedOK, err := b.EthereumTypeGetNonces(nonceTestAddr, true, 42) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pending != 43 { + t.Errorf("pending = %d, want 43 (declared floor over the provider answer)", pending) + } + if confirmed != 5 || !confirmedOK { + t.Errorf("confirmed = (%d, ok=%v), want (5, true) — the floor must not touch the confirmed nonce", confirmed, confirmedOK) + } + if len(stub.queried) != 0 { + t.Errorf("primary RPC queried tags %v, want none once routed to the provider", stub.queried) + } +} + +// TestEthereumTypeGetNonces_PrivatePendingHint_RoutesOnDeclaredZero confirms a declared nonce of 0 +// (a wallet's very first tx) still trips the routing guard (declaredFloor 1 > 0) and raises the +// pending nonce to 1 — the boundary the routing tests above (nonce 42) do not exercise. +func TestEthereumTypeGetNonces_PrivatePendingHint_RoutesOnDeclaredZero(t *testing.T) { + server := newNonceRPCServer(t, map[string]string{"pending": "0x0"}, nil) + stub := &nonceBatchStub{results: map[string]string{"pending": "0x0"}} + b := &EthereumRPC{RPC: stub, Timeout: time.Second, alternativeSendTxProvider: newRecentSenderProvider(server)} + + pending, _, _, err := b.EthereumTypeGetNonces(nonceTestAddr, false, 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pending != 1 { + t.Errorf("pending = %d, want 1 (declared nonce 0 → floor 1)", pending) + } + if got := server.callCount("pending"); got != 1 { + t.Errorf("alternative provider queried %d times, want 1 (declared 0 must still route)", got) + } + if len(stub.queried) != 0 { + t.Errorf("primary RPC queried tags %v, want none once routed to the provider", stub.queried) + } +} + // TestEthereumTypeGetNonces_PrivatePendingHint_IgnoredWithoutProvider confirms the hint is a // relay-deployment feature: with no alternative provider configured it is ignored and the primary // answer stands unchanged. @@ -124,6 +173,11 @@ func TestDeclaredPendingFloor(t *testing.T) { {[]uint64{0}, 1}, {[]uint64{5}, 6}, {[]uint64{5, 42, 7}, 43}, + // n+1 wraps to 0 at MaxUint64; the entry is silently ignored (benign: the floor is only + // ever a max() operand, so a spurious 0 can never lower the reported nonce). + {[]uint64{math.MaxUint64}, 0}, + // a physically-unreachable max value co-declared with a real nonce must not corrupt it. + {[]uint64{math.MaxUint64, 43}, 44}, } for _, c := range cases { if got := declaredPendingFloor(c.in); got != c.want { diff --git a/blockbook-api.ts b/blockbook-api.ts index 8013c51a47..c0043bd0d7 100644 --- a/blockbook-api.ts +++ b/blockbook-api.ts @@ -684,7 +684,7 @@ export interface WsRes { data: any; } export interface WsPrivatePending { - /** Account nonces of the wallet's in-flight private transactions for this address. */ + /** 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. */ nonces?: number[]; /** Transaction hashes of the in-flight private transactions (reserved for future use). */ txids?: string[]; @@ -821,7 +821,7 @@ export interface WsEstimateFeeReq { /** Block confirmations targets for which fees should be estimated. */ blocks?: number[]; /** 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. */ - specific?: {conservative?: boolean; txsize?: number; from?: string; to?: string; data?: string; value?: string; privatePending?: {nonces?: number[]; txids?: string[]};}; + specific?: {conservative?: boolean; txsize?: number; from?: string; to?: string; data?: string; value?: string; privatePending?: WsPrivatePending;}; } export interface Eip1559Fee { maxFeePerGas?: string; diff --git a/docs/evm-send.md b/docs/evm-send.md index 1db20d20fa..51dcc47853 100644 --- a/docs/evm-send.md +++ b/docs/evm-send.md @@ -107,6 +107,47 @@ Key invariants: pending tx is gone on its next account re-fetch (the initiating device also removes it optimistically). The cache timeout is the backstop for anything the deterministic evictions miss. +## Wallet-declared `privatePending` hint (nonce + gas routing) + +A private relay exposes no mempool, so a transaction pending only there is invisible to the public +backend RPC. Blockbook otherwise *infers* "this sender has a private tx in flight" from the +`recentSenders` map (populated by `registerSuccessfulSend`), which is fragile across restarts and +across load-balanced replicas without request affinity. A wallet already knows this state with +certainty, so it may **declare** it on its read requests via an optional `privatePending` field. +Blockbook then routes deterministically from that declaration instead of guessing. + +The field appears in two places, matching the two consumers of the routing machinery: + +- **`getAccountInfo` → top-level `privatePending: {nonces, txids}`** drives the pending-**nonce** + lookup. A declared nonce *is* the answer: Blockbook routes the `eth_getTransactionCount` to the + relay and reports at least `max(nonces) + 1`, so the wallet can never reuse the nonce of a private + tx it has in flight — even for a tx this instance never cached (accepted by another replica, or + lost to a restart). The declared floor only ever *raises* the reported nonce; it never lowers a + higher provider/primary answer. The nonce list is capped (see `maxPrivatePendingNonces`) and, past + the cap, collapsed to its single highest entry — only the maximum matters for the floor. +- **`estimateFee` → `specific.privatePending`** is a **routing signal only**. Unlike a nonce, the + wallet cannot compute gas itself, so Blockbook still simulates the call — the declaration only says + "estimate against the relay's pending-private state" (e.g. a privately-submitted `approve` a + following swap's gas depends on). Presence of a non-empty `nonces` array is all that is read; the + field is stripped before the `eth_estimateGas` call object is forwarded to the relay. + +Only `nonces` drives behavior today; `txids` is accepted for forward compatibility (future +pending-tx correlation) and is not yet consumed on any path. + +The hint is **additive and backward-compatible**: absent the field, behavior is exactly as before +(the `recentSenders` heuristic remains the fallback, and is still consulted when no hint is +declared), and an older Blockbook simply ignores the unknown field. With no alternative provider +configured the hint is a no-op (there is no private mempool to reconcile against). + +**Trust boundary (accepted).** `privatePending` is an *unauthenticated client hint* — Blockbook does +not verify the caller owns the address or that a private tx actually exists. This is safe because the +declaration is per-request only: it is never written into `recentSenders` or the pending-tx cache, so +a hostile client can distort only its **own** request's answer and cannot poison another client's +view or any shared state. Its one outward effect is forcing the read to route to the relay; that is +bounded by the relay's own rate-limit quota and the per-connection pending-requests limit, and — by +design — does **not** re-introduce the #1629 hot-path quota drain, because a normal wallet declares +the field only when it genuinely has a private tx in flight (rare), not on every keystroke. + ## Observability Prometheus counters for the cache lifecycle: diff --git a/server/websocket_privatepending_test.go b/server/websocket_privatepending_test.go index 5dadebf648..5e552b7130 100644 --- a/server/websocket_privatepending_test.go +++ b/server/websocket_privatepending_test.go @@ -95,3 +95,28 @@ func TestPrivatePendingJSONRoundTripsThroughWsReq(t *testing.T) { t.Fatalf("privatePending = %+v, want nonces [5]", r.PrivatePending) } } + +// TestEstimateFeePrivatePendingDecodesIntoSpecific confirms the estimate-side wire contract: a real +// estimateFee envelope carrying specific.privatePending decodes so that r.Specific["privatePending"] +// is the nested object EthereumTypeEstimateGas reads (nonces arriving as JSON float64). Unlike the +// typed getAccountInfo field, the estimate path is an untyped map, so this is the only place the +// nesting is pinned end-to-end. +func TestEstimateFeePrivatePendingDecodesIntoSpecific(t *testing.T) { + var req WsReq + body := `{"id":"1","method":"estimateFee","params":{"blocks":[1],"specific":{"from":"0xabc","privatePending":{"nonces":[42],"txids":["0xdead"]}}}}` + if err := json.Unmarshal([]byte(body), &req); err != nil { + t.Fatalf("outer unmarshal error = %v", err) + } + var r WsEstimateFeeReq + if err := json.Unmarshal(req.Params, &r); err != nil { + t.Fatalf("params unmarshal error = %v", err) + } + pp, ok := r.Specific["privatePending"].(map[string]interface{}) + if !ok { + t.Fatalf("specific.privatePending = %#v, want a nested object", r.Specific["privatePending"]) + } + nonces, ok := pp["nonces"].([]interface{}) + if !ok || len(nonces) != 1 || nonces[0].(float64) != 42 { + t.Fatalf("specific.privatePending.nonces = %#v, want [42]", pp["nonces"]) + } +} diff --git a/server/ws_types.go b/server/ws_types.go index 7b3a31155e..c1d8843294 100644 --- a/server/ws_types.go +++ b/server/ws_types.go @@ -51,7 +51,7 @@ type WsAccountInfoReq struct { // rather than guessing from which addresses recently sent through this instance. Only Nonces drive // behavior today; Txids are accepted for forward compatibility (future pending-tx correlation). type WsPrivatePending struct { - Nonces []uint64 `json:"nonces,omitempty" ts_doc:"Account nonces of the wallet's in-flight private transactions for this address."` + 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."` Txids []string `json:"txids,omitempty" ts_doc:"Transaction hashes of the in-flight private transactions (reserved for future use)."` } @@ -151,7 +151,7 @@ type WsTransactionSpecificReq struct { // WsEstimateFeeReq requests an estimation of transaction fees for a set of blocks or with specific parameters. type WsEstimateFeeReq struct { Blocks []int `json:"blocks,omitempty" ts_doc:"Block confirmations targets for which fees should be estimated."` - 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."` + 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."` } // WsEstimateFeeRes is returned in response to a fee estimation request. From 9b9e17ca6bb389fc881f652024f358c9e4173ae4 Mon Sep 17 00:00:00 2001 From: pragmaxim Date: Thu, 23 Jul 2026 07:12:58 +0000 Subject: [PATCH 7/8] refactor(eth): replace the hand-rolled raiseToFloor with the builtin max 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) --- bchain/coins/eth/ethrpc.go | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/bchain/coins/eth/ethrpc.go b/bchain/coins/eth/ethrpc.go index 61a8a7cbb1..4cd7b71a01 100644 --- a/bchain/coins/eth/ethrpc.go +++ b/bchain/coins/eth/ethrpc.go @@ -2396,7 +2396,7 @@ func (b *EthereumRPC) EthereumTypeGetNonces(addrDesc bchain.AddressDescriptor, w if raised > pending { b.observePendingFloorRaised("provider") } - return raiseToFloor(raised, declaredFloor), confirmed, confirmedOK, nil + return max(raised, declaredFloor), confirmed, confirmedOK, nil } b.observeAlternativeNonceRequest("error") glog.Warningf("Alternative provider failed for eth_getTransactionCount: %v, falling back to primary RPC", err) @@ -2419,7 +2419,7 @@ func (b *EthereumRPC) EthereumTypeGetNonces(addrDesc bchain.AddressDescriptor, w if raised > pending { b.observePendingFloorRaised("primary") } - pending = raiseToFloor(raised, declaredFloor) + pending = max(raised, declaredFloor) } return pending, confirmed, confirmedOK, nil } @@ -2432,21 +2432,11 @@ func (b *EthereumRPC) EthereumTypeGetNonces(addrDesc bchain.AddressDescriptor, w func declaredPendingFloor(nonces []uint64) uint64 { var floor uint64 for _, n := range nonces { - if n+1 > floor { - floor = n + 1 - } + floor = max(floor, n+1) } return floor } -// raiseToFloor returns the larger of pending and floor. -func raiseToFloor(pending, floor uint64) uint64 { - if floor > pending { - return floor - } - return pending -} - // getNoncesRPC fetches the pending account nonce from the primary RPC, plus the confirmed // (latest) nonce when withConfirmed is set. When both are requested and the client supports // JSON-RPC batching, they are fetched in a single round-trip; otherwise the calls are made From fbf7d53a2a88a06c87ffbd2378da3db238729bc0 Mon Sep 17 00:00:00 2001 From: pragmaxim Date: Thu, 23 Jul 2026 07:13:37 +0000 Subject: [PATCH 8/8] docs(eth): clarify best-effort estimate routing and the hint-less trade-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) --- docs/evm-send.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/docs/evm-send.md b/docs/evm-send.md index 51dcc47853..e527d19b35 100644 --- a/docs/evm-send.md +++ b/docs/evm-send.md @@ -129,7 +129,13 @@ The field appears in two places, matching the two consumers of the routing machi wallet cannot compute gas itself, so Blockbook still simulates the call — the declaration only says "estimate against the relay's pending-private state" (e.g. a privately-submitted `approve` a following swap's gas depends on). Presence of a non-empty `nonces` array is all that is read; the - field is stripped before the `eth_estimateGas` call object is forwarded to the relay. + field is stripped before the `eth_estimateGas` call object is forwarded to the relay. URL selection + is best-effort: the estimate goes to the provider that accepted this sender's most recent send + (`nonceURL`), or `urls[0]` when this instance never saw a send from the address (another replica + accepted it, or a restart cleared `recentSenders`). Unlike the nonce floor, gas has no + client-supplied value to fall back on, so a declared-but-unknown sender simulates against `urls[0]` + and may miss a private predecessor held only by a different relay node — an accepted limit of a + multi-URL relay without sender affinity, not compensable the way the nonce floor is. Only `nonces` drives behavior today; `txids` is accepted for forward compatibility (future pending-tx correlation) and is not yet consumed on any path. @@ -139,6 +145,13 @@ The hint is **additive and backward-compatible**: absent the field, behavior is declared), and an older Blockbook simply ignores the unknown field. With no alternative provider configured the hint is a no-op (there is no private mempool to reconcile against). +Note the deliberate trade-off versus pre-#1629 behavior: `estimateFee` is no longer routed to the +relay for *every* sender, so a wallet that sent privately, omitted the hint, and is served by a +different replica than the one that accepted the send has its estimate simulated on the primary RPC +without the private predecessor. Declaring `privatePending` closes that gap deterministically; +widening routing for hint-less senders is intentionally avoided because it is indistinguishable from +the #1629 hot-path drain. + **Trust boundary (accepted).** `privatePending` is an *unauthenticated client hint* — Blockbook does not verify the caller owns the address or that a private tx actually exists. This is safe because the declaration is per-request only: it is never written into `recentSenders` or the pending-tx cache, so