Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
366b5bb
fix(eth): gate alternative-provider gas estimates to recent private s…
pragmaxim Jul 21, 2026
dd846d9
fix(eth): retire same-nonce predecessors on relay acceptance, not fet…
pragmaxim Jul 21, 2026
803c43f
chore(eth): estimate-gas fallback on malformed result + Grafana panel
pragmaxim Jul 21, 2026
b0ff5a3
fix(eth): fresh estimate-gas fallback deadline + skip cache path on r…
pragmaxim Jul 21, 2026
622dbef
fix(eth): gate alt-mempool lifecycle metrics on the actual cache removal
pragmaxim Jul 21, 2026
43b3988
fix(eth): order RBF cache eviction by send generation, not fetch-back…
pragmaxim Jul 21, 2026
11bc082
fix(eth): clear the wrapped mempool on alt-cache read-path eviction
pragmaxim Jul 21, 2026
b8d7460
docs(eth): document the EVM private-relay broadcast + pending-tx cache
pragmaxim Jul 21, 2026
9bdfd02
fix(eth): don't let an unknown-generation keeper evict a newer replac…
pragmaxim Jul 23, 2026
8485e27
fix(eth): drop the wrapped-mempool re-add orphaned by a concurrent RB…
pragmaxim Jul 23, 2026
fd884a1
refactor(eth): recover the accepted-send sender once, not twice
pragmaxim Jul 23, 2026
9bbfb3b
fix(eth): close four gaps in the private send-tx cache found by review
pragmaxim Jul 28, 2026
a698e2c
docs(eth): map the two EVM pending-transaction stores
pragmaxim Jul 28, 2026
3b76c06
feat(eth): observability for stuck / hanging private transactions
pragmaxim Jul 28, 2026
a130bcd
fix(eth): never lose a relay-accepted send - cache it from its signed…
pragmaxim Aug 5, 2026
40f76d6
perf(eth): keep the private send path inside the wallet's deadline
pragmaxim Aug 5, 2026
69f7249
fix(eth): the post-send fetch-back updates its cache entry, never re-…
pragmaxim Aug 5, 2026
fa3df59
docs(eth): rename the not-surfaced metric and correct what the send d…
pragmaxim Aug 5, 2026
53ee56f
fix(grafana): move this branch's new panel ids above the ones master …
pragmaxim Aug 5, 2026
2f508d9
fix(eth): hand out a copy of the cached tx body, not the cached body
pragmaxim Aug 5, 2026
84a2db2
fix(eth): don't let refresh traffic starve the fetch-back that expose…
pragmaxim Aug 5, 2026
8291c42
fix(eth): retire every same-nonce predecessor, not just the first
pragmaxim Aug 5, 2026
cfa8cfc
fix(eth): don't probe the zero hash after a failed primary send
pragmaxim Aug 5, 2026
390c6c8
fix(eth): the post-send fetch-back only probes the relay, it writes n…
pragmaxim Aug 5, 2026
156614f
docs(eth): correct what the send docs and the metric help now describe
pragmaxim Aug 5, 2026
4ae59d9
style(eth): cut the comment volume this series added
pragmaxim Aug 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
656 changes: 577 additions & 79 deletions bchain/coins/eth/alternativesendtx.go

Large diffs are not rendered by default.

1,531 changes: 1,525 additions & 6 deletions bchain/coins/eth/alternativesendtx_test.go

Large diffs are not rendered by default.

91 changes: 81 additions & 10 deletions bchain/coins/eth/ethrpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,15 @@ func (c *Configuration) AlternativeMempoolTxTimeoutDuration() (time.Duration, er
return defaultAlternativeMempoolTxTimeout, nil
}

// mempoolRetentionInverted reports whether the alternative-provider cache is configured to outlive
// the wrapped Blockbook mempool. Every cache exit clears the wrapped mempool too, but the mempool's
// own timeout sweep is the one exit that does NOT clear the cache: inverted, that sweep drops a
// private transaction's address index while the cache keeps serving its body as pending, and nothing
// reconciles the two. Only an explicit timeout pair can invert it; the defaults cannot.
func mempoolRetentionInverted(alternativeTimeout, mempoolTimeout time.Duration) bool {
return alternativeTimeout >= mempoolTimeout
}

// AverageBlockTimeDuration returns AverageBlockTimeMs as a time.Duration.
func (c *Configuration) AverageBlockTimeDuration() (time.Duration, error) {
if c.AverageBlockTimeMs <= 0 {
Expand Down Expand Up @@ -711,6 +720,11 @@ func (b *EthereumRPC) CreateMempool(chain bchain.BlockChain) (bchain.Mempool, er
b.Mempool = bchain.NewMempoolEthereumType(chain, mempoolTxTimeout, b.ChainConfig.QueryBackendOnMempoolResync)
glog.Info("mempool created, MempoolTxTimeout=", mempoolTxTimeout, ", QueryBackendOnMempoolResync=", b.ChainConfig.QueryBackendOnMempoolResync, ", DisableMempoolSync=", b.ChainConfig.DisableMempoolSync)
if b.alternativeSendTxProvider != nil {
// warned here, not in Validate: the effective mempool retention depends on the
// env-configured provider existing
if mempoolRetentionInverted(b.alternativeSendTxProvider.mempoolTxsTimeout, mempoolTxTimeout) {
glog.Warningf("alternativeMempoolTxTimeout=%s is not shorter than mempoolTxTimeout=%s: the wrapped mempool may drop a private transaction's address index while the provider cache still serves it as pending", b.alternativeSendTxProvider.mempoolTxsTimeout, mempoolTxTimeout)
}
b.alternativeSendTxProvider.SetupMempool(b.Mempool, b.removeTransactionFromMempool)
}

Expand Down Expand Up @@ -1954,8 +1968,6 @@ func GetStringFromMap(p string, params map[string]interface{}) (string, bool) {

// EthereumTypeEstimateGas returns estimation of gas consumption for given transaction parameters
func (b *EthereumRPC) EthereumTypeEstimateGas(params map[string]interface{}) (uint64, error) {
ctx, cancel := context.WithTimeout(context.Background(), b.Timeout)
defer cancel()
msg := ethereum.CallMsg{}
if s, ok := GetStringFromMap("from", params); ok && len(s) > 0 {
msg.From = ethcommon.HexToAddress(s)
Expand All @@ -1977,19 +1989,54 @@ func (b *EthereumRPC) EthereumTypeEstimateGas(params map[string]interface{}) (ui
msg.GasPrice, _ = hexutil.DecodeBig(s)
}

if b.alternativeSendTxProvider != nil {
// Route eth_estimateGas through the provider ONLY for a sender that recently sent a private tx
// through it (see useForNonces), which may have pending state the primary RPC does not know about.
// Every other estimate - the overwhelming majority, since the wallet calls estimateFee on each
// send-form keystroke - goes straight to the primary, so the hot endpoint no longer burns the
// provider's rate-limit quota (#1629). A missing `from` takes the primary path too.
if b.alternativeSendTxProvider != nil && msg.From != (ethcommon.Address{}) &&
b.alternativeSendTxProvider.useForNonces(msg.From) {
result, err := b.alternativeSendTxProvider.callHttpStringResult(
b.alternativeSendTxProvider.urls[0],
b.alternativeSendTxProvider.nonceURL(msg.From),
"eth_estimateGas",
params,
)
if err == nil {
return hexutil.DecodeUint64(result)
// Count success only once the result decodes: a malformed hex quantity is a provider
// failure, so fall through to the primary RPC rather than returning the decode error
// to the caller (and keep the "success" label honest for the quota dashboard).
if gas, decErr := hexutil.DecodeUint64(result); decErr == nil {
b.observeAlternativeEstimateGasRequest("success")
return gas, nil
} else {
err = decErr
}
}
}
// Previously the provider error was swallowed silently, hiding quota exhaustion (#1629);
// log it and fall back to the primary RPC below.
b.observeAlternativeEstimateGasRequest("error")
glog.Warningf("Alternative provider failed for eth_estimateGas: %v, falling back to primary RPC", err)
}
// Build the primary-RPC deadline here rather than at function entry: a slow or rate-limited
// alternative-provider round-trip above runs on its own independent context and can consume
// most of b.Timeout, so a context created at entry could already be expired by the time the
// fallback reaches the healthy primary backend (the exact #1629 slow-relay scenario).
ctx, cancel := context.WithTimeout(context.Background(), b.Timeout)
defer cancel()
return b.Client.EstimateGas(ctx, msg)
}

// 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.
func (b *EthereumRPC) observeAlternativeEstimateGasRequest(result string) {
if b.metrics == nil || b.metrics.EthAlternativeEstimateGasRequests == nil {
return
}
b.metrics.EthAlternativeEstimateGasRequests.With(common.Labels{"result": result}).Inc()
}

// 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 @@ -2055,6 +2102,18 @@ func (b *EthereumRPC) observeAlternativeNonceRequest(result string) {
b.metrics.EthAlternativeNonceRequests.With(common.Labels{"result": result}).Inc()
}

// observePendingFloorRaised records that raiseToPendingFloor lifted a getTransactionCount answer above
// the backend's own pending nonce because the cache still holds a higher-nonce private tx, labeled by
// source: provider (the relay's own pending count had already dropped the still-cached tx) or primary
// (the fallback primary RPC never knew the private tx). A sustained rate is the precursor to the
// queue-behind-a-dead-nonce hang (#1638 review).
func (b *EthereumRPC) observePendingFloorRaised(source string) {
if b.metrics == nil || b.metrics.EthAlternativePendingFloorRaised == nil {
return
}
b.metrics.EthAlternativePendingFloorRaised.With(common.Labels{"source": source}).Inc()
}

// eip1559BaseFeeMultiplier is the headroom applied to the projected base fee when deriving
// maxFeePerGas for the on-chain EIP-1559 estimate (maxFeePerGas = multiplier*baseFee + tip).
// 2x is the EIP-1559-standard buffer: it keeps a transaction mineable across ~6 consecutive full
Expand Down Expand Up @@ -2197,8 +2256,12 @@ func (b *EthereumRPC) SendRawTransaction(hex string, disableAlternativeRPC bool)
}

txid, retErr = b.callRpcStringResult("eth_sendRawTransaction", hex)
if b.ChainConfig.DisableMempoolSync {
// add transactions submitted by us to mempool if sync is disabled
if b.ChainConfig.DisableMempoolSync && retErr == nil {
// With no newPendingTransactions feed this add is the only thing that makes an own send visible
// for its addresses. Gated on success: with an empty txid it indexed the zero hash instead - one
// primary eth_getTransactionByHash, then the pruned-index recovery's eth_getTransactionReceipt,
// each on its own b.Timeout - two more rpc_timeouts spent on nothing, on the path where the
// wallet has already waited one for the relay broadcast and one for the primary send.
b.Mempool.AddTransactionToMempool(txid)
}
return txid, retErr
Expand Down Expand Up @@ -2279,7 +2342,11 @@ func (b *EthereumRPC) EthereumTypeGetNonces(addrDesc bchain.AddressDescriptor, w
// 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).
return b.alternativeSendTxProvider.raiseToPendingFloor(ethAddress, pending), confirmed, confirmedOK, nil
raised := b.alternativeSendTxProvider.raiseToPendingFloor(ethAddress, pending)
if raised > pending {
b.observePendingFloorRaised("provider")
}
return raised, confirmed, confirmedOK, nil
}
b.observeAlternativeNonceRequest("error")
glog.Warningf("Alternative provider failed for eth_getTransactionCount: %v, falling back to primary RPC", err)
Expand All @@ -2297,7 +2364,11 @@ func (b *EthereumRPC) EthereumTypeGetNonces(addrDesc bchain.AddressDescriptor, w
// 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.
pending = b.alternativeSendTxProvider.raiseToPendingFloor(ethAddress, pending)
raised := b.alternativeSendTxProvider.raiseToPendingFloor(ethAddress, pending)
if raised > pending {
b.observePendingFloorRaised("primary")
}
pending = raised
}
return pending, confirmed, confirmedOK, nil
}
Expand Down
187 changes: 187 additions & 0 deletions bchain/coins/eth/ethrpc_estimate_gas_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
package eth

import (
"context"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"

ethcommon "github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/ethclient"
"github.com/ethereum/go-ethereum/rpc"
)

// countingEstimateGasServer answers eth_estimateGas with a fixed hex gas value and counts how many
// times it was hit, so a test can assert whether a given path (provider vs. primary) was consulted.
func countingEstimateGasServer(t *testing.T, gasHex string) (*httptest.Server, *int32) {
t.Helper()
var hits int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&hits, 1)
w.Header().Set("Content-Type", "application/json")
if _, err := w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":"` + gasHex + `"}`)); err != nil {
t.Errorf("Write() error = %v", err)
}
}))
t.Cleanup(server.Close)
return server, &hits
}

// newEstimateGasTestRPC wires an EthereumRPC whose primary Client points at primaryURL and whose
// alternative send-tx provider points at providerURL, so a routing decision is observable by which
// server receives the eth_estimateGas hit.
func newEstimateGasTestRPC(t *testing.T, primaryURL, providerURL string) *EthereumRPC {
t.Helper()
primaryRPC, err := rpc.DialContext(context.Background(), primaryURL)
if err != nil {
t.Fatalf("dial primary: %v", err)
}
t.Cleanup(primaryRPC.Close)
return &EthereumRPC{
Client: &EthereumClient{Client: ethclient.NewClient(primaryRPC)},
Timeout: 2 * time.Second,
alternativeSendTxProvider: &AlternativeSendTxProvider{
urls: []string{providerURL},
mempoolTxsTimeout: time.Hour,
rpcTimeout: 2 * time.Second,
recentSenders: map[ethcommon.Address]recentSender{},
},
}
}

// TestEthereumTypeEstimateGasSkipsProviderForNonRecentSender is the core of #1629: a sender that has
// not recently sent a private transaction through the alternative provider must not have its gas
// estimate routed there - it goes straight to the primary backend, so the hot estimateFee endpoint
// does not burn the provider's rate-limit quota.
func TestEthereumTypeEstimateGasSkipsProviderForNonRecentSender(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",
})
if err != nil {
t.Fatalf("EthereumTypeEstimateGas() error = %v", err)
}
if gas != 0x5208 {
t.Fatalf("gas = %#x, want 0x5208 (primary backend value)", gas)
}
if got := atomic.LoadInt32(providerHits); got != 0 {
t.Fatalf("provider hits = %d, want 0 (non-recent sender must not touch the provider)", got)
}
if got := atomic.LoadInt32(primaryHits); got != 1 {
t.Fatalf("primary hits = %d, want 1", got)
}
}

// TestEthereumTypeEstimateGasRoutesRecentSenderToProvider confirms the provider path is preserved
// for the case it exists for: a sender with a recent private transaction is routed to the provider
// URL that accepted its send (see nonceURL), which may know a pending tx the primary does not.
func TestEthereumTypeEstimateGasRoutesRecentSenderToProvider(t *testing.T) {
primary, primaryHits := countingEstimateGasServer(t, "0x5208")
provider, providerHits := countingEstimateGasServer(t, "0x9999")
b := newEstimateGasTestRPC(t, primary.URL, provider.URL)

sender := ethcommon.HexToAddress("0x2222222222222222222222222222222222222222")
b.alternativeSendTxProvider.recentSenders[sender] = recentSender{
time: time.Now(),
url: provider.URL,
gen: 1,
}

gas, err := b.EthereumTypeEstimateGas(map[string]interface{}{
"from": sender.Hex(),
"to": "0x3333333333333333333333333333333333333333",
})
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.Fatalf("provider hits = %d, want 1 (recent sender must be routed to the provider)", got)
}
if got := atomic.LoadInt32(primaryHits); got != 0 {
t.Fatalf("primary hits = %d, want 0", got)
}
}

// TestEthereumTypeEstimateGasFallsBackWhenProviderFails checks that a provider error is not fatal:
// a recent sender whose provider call fails still gets an estimate from the primary backend.
func TestEthereumTypeEstimateGasFallsBackWhenProviderFails(t *testing.T) {
primary, primaryHits := countingEstimateGasServer(t, "0x5208")
// a provider server that always errors the JSON-RPC call
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"error":{"code":-32005,"message":"rate limited"}}`))
}))
t.Cleanup(provider.Close)
b := newEstimateGasTestRPC(t, primary.URL, provider.URL)

sender := ethcommon.HexToAddress("0x2222222222222222222222222222222222222222")
b.alternativeSendTxProvider.recentSenders[sender] = recentSender{time: time.Now(), url: provider.URL, gen: 1}

gas, err := b.EthereumTypeEstimateGas(map[string]interface{}{"from": sender.Hex()})
if err != nil {
t.Fatalf("EthereumTypeEstimateGas() error = %v", err)
}
if gas != 0x5208 {
t.Fatalf("gas = %#x, want 0x5208 (primary fallback value)", gas)
}
if got := atomic.LoadInt32(primaryHits); got != 1 {
t.Fatalf("primary hits = %d, want 1 (must fall back after provider error)", got)
}
}

// TestEthereumTypeEstimateGasFallsBackWhenProviderReturnsMalformedResult confirms a provider that
// answers without a transport error but with a non-decodable gas value is treated as a failure:
// the estimate falls back to the primary backend rather than surfacing the decode error.
func TestEthereumTypeEstimateGasFallsBackWhenProviderReturnsMalformedResult(t *testing.T) {
primary, primaryHits := countingEstimateGasServer(t, "0x5208")
// a provider that returns a non-hex string result for eth_estimateGas
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":"not-a-hex-quantity"}`))
}))
t.Cleanup(provider.Close)
b := newEstimateGasTestRPC(t, primary.URL, provider.URL)

sender := ethcommon.HexToAddress("0x2222222222222222222222222222222222222222")
b.alternativeSendTxProvider.recentSenders[sender] = recentSender{time: time.Now(), url: provider.URL, gen: 1}

gas, err := b.EthereumTypeEstimateGas(map[string]interface{}{"from": sender.Hex()})
if err != nil {
t.Fatalf("EthereumTypeEstimateGas() error = %v, want fallback to primary", err)
}
if gas != 0x5208 {
t.Fatalf("gas = %#x, want 0x5208 (primary fallback value)", gas)
}
if got := atomic.LoadInt32(primaryHits); got != 1 {
t.Fatalf("primary hits = %d, want 1 (malformed provider result must fall back)", got)
}
}

// TestEthereumTypeEstimateGasNoFromUsesPrimary confirms an estimate without a sender takes the
// primary path - the gate cannot apply without a from address.
func TestEthereumTypeEstimateGasNoFromUsesPrimary(t *testing.T) {
primary, primaryHits := countingEstimateGasServer(t, "0x5208")
provider, providerHits := countingEstimateGasServer(t, "0x9999")
b := newEstimateGasTestRPC(t, primary.URL, provider.URL)

if _, err := b.EthereumTypeEstimateGas(map[string]interface{}{
"to": "0x3333333333333333333333333333333333333333",
}); err != nil {
t.Fatalf("EthereumTypeEstimateGas() error = %v", err)
}
if got := atomic.LoadInt32(providerHits); got != 0 {
t.Fatalf("provider hits = %d, want 0", got)
}
if got := atomic.LoadInt32(primaryHits); got != 1 {
t.Fatalf("primary hits = %d, want 1", got)
}
}
44 changes: 44 additions & 0 deletions bchain/coins/eth/ethrpc_mempool_timeout_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,50 @@ func TestConfigurationAlternativeMempoolTxTimeoutDuration(t *testing.T) {
}
}

// TestMempoolRetentionInverted covers the retention-order check: the provider cache must expire
// before the wrapped mempool, whose timeout sweep is the only exit that does not clear the cache.
func TestMempoolRetentionInverted(t *testing.T) {
tests := []struct {
name string
alternative time.Duration
mempool time.Duration
want bool
}{
{
name: "defaults are ordered correctly",
alternative: defaultAlternativeMempoolTxTimeout,
mempool: defaultMempoolTxTimeoutWithAlternativeProvider,
want: false,
},
{
name: "cache outliving the mempool is inverted",
alternative: 30 * time.Minute,
mempool: 10 * time.Minute,
want: true,
},
{
name: "equal retentions are inverted",
alternative: 10 * time.Minute,
mempool: 10 * time.Minute,
want: true,
},
{
name: "a zero mempool retention is inverted",
alternative: 5 * time.Minute,
mempool: 0,
want: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := mempoolRetentionInverted(tt.alternative, tt.mempool); got != tt.want {
t.Fatalf("mempoolRetentionInverted(%s, %s) = %v, want %v", tt.alternative, tt.mempool, got, tt.want)
}
})
}
}

func TestNewEthereumRPCRejectsInvalidMempoolTimeouts(t *testing.T) {
tests := []struct {
name string
Expand Down
Loading