Skip to content
Open
5 changes: 5 additions & 0 deletions api/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion api/worker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
2 changes: 1 addition & 1 deletion bchain/basechain.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}

Expand Down
4 changes: 2 additions & 2 deletions bchain/coins/blockchain.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
124 changes: 94 additions & 30 deletions bchain/coins/eth/ethrpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -1989,21 +1989,27 @@ 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.
// 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{}) &&
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
Expand Down Expand Up @@ -2032,15 +2038,49 @@ 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
}
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
Expand Down Expand Up @@ -2331,25 +2371,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 max(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)
Expand All @@ -2363,16 +2414,29 @@ 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 = max(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 {
floor = max(floor, n+1)
}
return floor
}

// 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
Expand Down
122 changes: 122 additions & 0 deletions bchain/coins/eth/ethrpc_estimate_gas_hint_test.go
Original file line number Diff line number Diff line change
@@ -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)")
}
}
Loading