From 457d7d4a57ad232f68a28b6c9502eafb74ef2a60 Mon Sep 17 00:00:00 2001 From: Vikram Bhattacharjee Date: Tue, 20 Oct 2026 21:21:16 +0530 Subject: [PATCH 01/15] core/txpool, eth, internal/ethapi: improve transaction propagation reliability --- core/txpool/legacypool/legacypool.go | 136 ++------ core/txpool/legacypool/legacypool_stranded.go | 2 +- core/txpool/legacypool/rebroadcast.go | 18 + .../legacypool/rebroadcast_boundaries_test.go | 159 +++++++++ .../legacypool/rebroadcast_cleanup_test.go | 119 +++++++ .../legacypool/rebroadcast_fairness_test.go | 154 +++++++++ .../legacypool/rebroadcast_scheduling_test.go | 192 +++++++++++ .../legacypool/rebroadcast_selection.go | 136 ++++++++ core/txpool/legacypool/rebroadcast_test.go | 11 +- eth/api_backend.go | 10 + eth/api_backend_private_lifecycle_test.go | 153 +++++++++ eth/api_backend_private_test.go | 96 ++++++ eth/downloader/bor_fetch_helpers_test.go | 10 +- eth/downloader/bor_fetchers_concurrent.go | 6 +- .../bor_fetchers_concurrent_test.go | 7 +- eth/downloader/peer.go | 5 +- eth/downloader/peer_response.go | 2 +- eth/downloader/peer_response_test.go | 8 +- eth/downloader/peer_timeout_test.go | 153 +++++++++ eth/downloader/skeleton.go | 11 +- eth/fetcher/block_fetcher.go | 255 +++----------- eth/fetcher/block_fetcher_admission.go | 139 ++++++++ eth/fetcher/block_fetcher_admission_test.go | 176 ++++++++++ .../block_fetcher_body_request_test.go | 192 +++++++++++ eth/fetcher/block_fetcher_bounds_test.go | 315 +++++++++++++++++ eth/fetcher/block_fetcher_pending.go | 44 +++ eth/fetcher/block_fetcher_pending_test.go | 319 +++++++++++++++++ eth/fetcher/block_fetcher_release.go | 74 ++++ eth/fetcher/block_fetcher_release_test.go | 321 ++++++++++++++++++ eth/fetcher/block_fetcher_request.go | 156 +++++++++ eth/fetcher/block_fetcher_schedule.go | 231 +++++++++++++ .../block_fetcher_schedule_edges_test.go | 92 +++++ eth/fetcher/block_fetcher_schedule_test.go | 248 ++++++++++++++ .../block_fetcher_timeout_recovery_test.go | 272 +++++++++++++++ eth/filters/IBackend.go | 14 + eth/handler.go | 18 +- eth/rebroadcast_delivery_test.go | 2 +- eth/relay/private_tx_store.go | 41 +-- eth/relay/private_tx_store_submission.go | 49 +++ eth/relay/private_tx_store_submission_test.go | 198 +++++++++++ eth/relay/private_tx_store_test.go | 165 ++++++--- eth/relay/relay.go | 22 +- eth/sync.go | 18 +- eth/sync_completion_test.go | 260 ++++++++++++++ internal/ethapi/api.go | 16 +- internal/ethapi/api_private_test.go | 193 +++++++++++ internal/ethapi/api_test.go | 42 ++- internal/ethapi/backend.go | 1 + internal/ethapi/transaction_args_test.go | 1 + 49 files changed, 4805 insertions(+), 457 deletions(-) create mode 100644 core/txpool/legacypool/rebroadcast_boundaries_test.go create mode 100644 core/txpool/legacypool/rebroadcast_cleanup_test.go create mode 100644 core/txpool/legacypool/rebroadcast_fairness_test.go create mode 100644 core/txpool/legacypool/rebroadcast_scheduling_test.go create mode 100644 core/txpool/legacypool/rebroadcast_selection.go create mode 100644 eth/api_backend_private_lifecycle_test.go create mode 100644 eth/api_backend_private_test.go create mode 100644 eth/downloader/peer_timeout_test.go create mode 100644 eth/fetcher/block_fetcher_admission.go create mode 100644 eth/fetcher/block_fetcher_admission_test.go create mode 100644 eth/fetcher/block_fetcher_body_request_test.go create mode 100644 eth/fetcher/block_fetcher_bounds_test.go create mode 100644 eth/fetcher/block_fetcher_pending.go create mode 100644 eth/fetcher/block_fetcher_pending_test.go create mode 100644 eth/fetcher/block_fetcher_release.go create mode 100644 eth/fetcher/block_fetcher_release_test.go create mode 100644 eth/fetcher/block_fetcher_request.go create mode 100644 eth/fetcher/block_fetcher_schedule.go create mode 100644 eth/fetcher/block_fetcher_schedule_edges_test.go create mode 100644 eth/fetcher/block_fetcher_schedule_test.go create mode 100644 eth/fetcher/block_fetcher_timeout_recovery_test.go create mode 100644 eth/relay/private_tx_store_submission.go create mode 100644 eth/relay/private_tx_store_submission_test.go create mode 100644 eth/sync_completion_test.go create mode 100644 internal/ethapi/api_private_test.go diff --git a/core/txpool/legacypool/legacypool.go b/core/txpool/legacypool/legacypool.go index 41c3d9eef7..8c2be7388f 100644 --- a/core/txpool/legacypool/legacypool.go +++ b/core/txpool/legacypool/legacypool.go @@ -338,10 +338,11 @@ type LegacyPool struct { filteredAddrs map[common.Address]struct{} // Map of addresses to filter // Rebroadcast tracking - rebroadcastTxFeed event.Feed // Feed for stuck transaction events - rebroadcastAckFeed event.Feed - lastRebroadcast map[common.Hash]time.Time // Track last rebroadcast time per tx hash - isTxPrivate func(common.Hash) bool + rebroadcastTxFeed event.Feed // Feed for stuck transaction events + rebroadcastAckFeed event.Feed + lastRebroadcast map[common.Hash]time.Time // Track last queue acceptance per tx hash + lastRebroadcastAttempt map[common.Hash]time.Time + isTxPrivate func(common.Hash) bool stranded strandedState // Pending chains blocked by an unminable head } @@ -359,22 +360,23 @@ func New(config Config, chain BlockChain, options ...func(pool *LegacyPool)) *Le // Create the transaction pool with its initial settings signer := types.LatestSigner(chain.Config()) pool := &LegacyPool{ - config: config, - chain: chain, - chainconfig: chain.Config(), - signer: signer, - pending: make(map[common.Address]*list), - queue: newQueue(config, signer), - all: newLookup(), - reqResetCh: make(chan *txpoolResetRequest), - reqPromoteCh: make(chan *accountSet), - queueTxEventCh: make(chan *types.Transaction), - reorgDoneCh: make(chan chan struct{}), - reorgShutdownCh: make(chan struct{}), - initDoneCh: make(chan struct{}), - filteredAddrs: make(map[common.Address]struct{}), - lastRebroadcast: make(map[common.Hash]time.Time), - stranded: newStrandedState(config), + config: config, + chain: chain, + chainconfig: chain.Config(), + signer: signer, + pending: make(map[common.Address]*list), + queue: newQueue(config, signer), + all: newLookup(), + reqResetCh: make(chan *txpoolResetRequest), + reqPromoteCh: make(chan *accountSet), + queueTxEventCh: make(chan *types.Transaction), + reorgDoneCh: make(chan chan struct{}), + reorgShutdownCh: make(chan struct{}), + initDoneCh: make(chan struct{}), + filteredAddrs: make(map[common.Address]struct{}), + lastRebroadcast: make(map[common.Hash]time.Time), + lastRebroadcastAttempt: make(map[common.Hash]time.Time), + stranded: newStrandedState(config), } pool.priced = newPricedList(pool.all) @@ -550,78 +552,6 @@ func (pool *LegacyPool) SubscribeRebroadcastTransactions(ch chan<- core.StuckTxs return pool.rebroadcastTxFeed.Subscribe(ch) } -// identifyStuckTransactions identifies pending transactions that may be stuck -// and need rebroadcasting. It returns transactions that: -// - Have been pending longer than RebroadcastInterval but less than RebroadcastMaxAge -// - Have not been rebroadcast recently (within RebroadcastInterval) -// - Are immediately executable (gas price meets current requirements) -// -// Must be called with pool.mu.RLock held (read lock only - does not modify pool state). -func (pool *LegacyPool) identifyStuckTransactions() []*types.Transaction { - now := time.Now() - head := pool.currentHead.Load() - if head == nil { - return nil - } - - // Calculate base fee only if London is enabled and header has base fee - var baseFee *big.Int - if pool.chainconfig.IsLondon(head.Number) && head.BaseFee != nil { - baseFee = eip1559.CalcBaseFee(pool.chainconfig, head) - } - minTip := pool.gasTip.Load().ToBig() - - var stuckTxs []*types.Transaction - - for _, list := range pool.pending { - for _, tx := range list.Flatten() { - hash := tx.Hash() - age := now.Sub(tx.Time()) - - // Skip if too young (hasn't had time to propagate yet) - if age < pool.config.RebroadcastInterval { - continue - } - - // Check rebroadcast history - lastTime, wasRebroadcast := pool.lastRebroadcast[hash] - if wasRebroadcast { - // Skip if recently rebroadcast - if now.Sub(lastTime) < pool.config.RebroadcastInterval { - continue - } - // Skip if we've been rebroadcasting this tx for too long (max age applies - // only to previously rebroadcast txs - new txs that just became executable - // after a base fee drop should still be considered) - if pool.config.RebroadcastMaxAge > 0 && age > pool.config.RebroadcastMaxAge { - continue - } - } - - // Skip if not immediately executable (gas price too low for current conditions) - // For EIP-1559 transactions, check gas fee cap against base fee - if baseFee != nil && tx.GasFeeCap().Cmp(baseFee) < 0 { - continue - } - if tx.GasTipCap().Cmp(minTip) < 0 { - continue - } - if !pool.canRebroadcast(tx) { - continue - } - - stuckTxs = append(stuckTxs, tx) - - // Enforce batch limit - if len(stuckTxs) >= pool.config.RebroadcastBatchSize { - return stuckTxs - } - } - } - - return stuckTxs -} - // SetGasTip updates the minimum gas tip required by the transaction pool for a // new transaction, and drops all transactions below this threshold. func (pool *LegacyPool) SetGasTip(tip *big.Int) { @@ -1145,7 +1075,7 @@ func (pool *LegacyPool) add(tx *types.Transaction, async bool) (replaced bool, e pool.priced.Removed(1) } pendingReplaceMeter.Mark(1) - delete(pool.lastRebroadcast, old.Hash()) + pool.forgetRebroadcast(old.Hash()) } pool.all.Add(tx) reheapCount := pool.priced.reheaps.Load() @@ -1240,7 +1170,7 @@ func (pool *LegacyPool) promoteTx(addr common.Address, hash common.Hash, tx *typ pool.all.Remove(hash) pool.priced.Removed(1) pendingDiscardMeter.Mark(1) - delete(pool.lastRebroadcast, hash) + pool.forgetRebroadcast(hash) return false } // Otherwise discard any previous transaction and mark this @@ -1248,7 +1178,7 @@ func (pool *LegacyPool) promoteTx(addr common.Address, hash common.Hash, tx *typ pool.all.Remove(old.Hash()) pool.priced.Removed(1) pendingReplaceMeter.Mark(1) - delete(pool.lastRebroadcast, old.Hash()) + pool.forgetRebroadcast(old.Hash()) } else { // Nothing was replaced, bump the pending counter pendingGauge.Inc(1) @@ -1466,7 +1396,7 @@ func (pool *LegacyPool) removeTx(hash common.Hash, outofbound bool, unreserve bo pool.priced.Removed(1) } // Clean up rebroadcast tracking - delete(pool.lastRebroadcast, hash) + pool.forgetRebroadcast(hash) // Remove the transaction from the pending lists and reset the account nonce if pending := pool.pending[addr]; pending != nil { if removed, invalids := pending.Remove(tx); removed { @@ -1852,7 +1782,7 @@ func (pool *LegacyPool) promoteExecutables(accounts []common.Address) []*types.T // remove all removable transactions for _, hash := range dropped { pool.all.Remove(hash) - delete(pool.lastRebroadcast, hash) + pool.forgetRebroadcast(hash) } pool.priced.Removed(len(dropped)) @@ -1909,7 +1839,7 @@ func (pool *LegacyPool) truncatePending() { // Drop the transaction from the global pools too hash := tx.Hash() pool.all.Remove(hash) - delete(pool.lastRebroadcast, hash) + pool.forgetRebroadcast(hash) // Update the account nonce to the dropped transaction pool.pendingNonces.setIfLower(offenders[i], tx.Nonce()) @@ -1935,7 +1865,7 @@ func (pool *LegacyPool) truncatePending() { // Drop the transaction from the global pools too hash := tx.Hash() pool.all.Remove(hash) - delete(pool.lastRebroadcast, hash) + pool.forgetRebroadcast(hash) // Update the account nonce to the dropped transaction pool.pendingNonces.setIfLower(addr, tx.Nonce()) @@ -1957,7 +1887,7 @@ func (pool *LegacyPool) truncateQueue() { // Remove all removable transactions from the lookup and global price list for _, hash := range removed { pool.all.Remove(hash) - delete(pool.lastRebroadcast, hash) + pool.forgetRebroadcast(hash) } pool.priced.Removed(len(removed)) @@ -1990,7 +1920,7 @@ func (pool *LegacyPool) demoteUnexecutables() { for _, tx := range olds { hash := tx.Hash() pool.all.Remove(hash) - delete(pool.lastRebroadcast, hash) + pool.forgetRebroadcast(hash) log.Trace("Removed old pending transaction", "hash", hash) } // Drop all transactions that are too costly (low balance or out of gas), and queue any invalids back for later @@ -1998,7 +1928,7 @@ func (pool *LegacyPool) demoteUnexecutables() { for _, tx := range drops { hash := tx.Hash() pool.all.Remove(hash) - delete(pool.lastRebroadcast, hash) + pool.forgetRebroadcast(hash) log.Trace("Removed unpayable pending transaction", "hash", hash) } pendingNofundsMeter.Mark(int64(len(drops))) @@ -2016,7 +1946,7 @@ func (pool *LegacyPool) demoteUnexecutables() { for _, tx := range txConditionalsRemoved { hash := tx.Hash() pool.all.Remove(hash) - delete(pool.lastRebroadcast, hash) + pool.forgetRebroadcast(hash) log.Trace("Removed invalid conditional transaction", "hash", hash) } diff --git a/core/txpool/legacypool/legacypool_stranded.go b/core/txpool/legacypool/legacypool_stranded.go index 5f5d717107..95ee2287a7 100644 --- a/core/txpool/legacypool/legacypool_stranded.go +++ b/core/txpool/legacypool/legacypool_stranded.go @@ -129,7 +129,7 @@ func (pool *LegacyPool) dropPendingAccount(addr common.Address, list *list) { for _, tx := range drops { hash := tx.Hash() pool.all.Remove(hash) - delete(pool.lastRebroadcast, hash) + pool.forgetRebroadcast(hash) } delete(pool.pending, addr) pool.pendingNonces.setIfLower(addr, first) diff --git a/core/txpool/legacypool/rebroadcast.go b/core/txpool/legacypool/rebroadcast.go index e22644d4a1..3b7ab3dea6 100644 --- a/core/txpool/legacypool/rebroadcast.go +++ b/core/txpool/legacypool/rebroadcast.go @@ -42,6 +42,7 @@ func (pool *LegacyPool) canRebroadcast(tx *types.Transaction) bool { } func (pool *LegacyPool) publishRebroadcastTransactions(txs []*types.Transaction) { + pool.recordRebroadcastAttempts(txs) explicit := pool.rebroadcastAckFeed.Send(core.StuckTxsEvent{Txs: txs}) legacy := pool.rebroadcastTxFeed.Send(core.StuckTxsEvent{Txs: txs}) if explicit == 0 || legacy > 0 { @@ -90,3 +91,20 @@ func (pool *LegacyPool) rebroadcastAcknowledgement(txs []*types.Transaction, cou } } } + +// Must be called with pool.mu held, alongside removal from the pool. +func (pool *LegacyPool) forgetRebroadcast(hash common.Hash) { + delete(pool.lastRebroadcast, hash) + delete(pool.lastRebroadcastAttempt, hash) +} + +func (pool *LegacyPool) recordRebroadcastAttempts(txs []*types.Transaction) { + pool.mu.Lock() + defer pool.mu.Unlock() + now := time.Now() + for _, tx := range txs { + if pool.all.Get(tx.Hash()) == tx { + pool.lastRebroadcastAttempt[tx.Hash()] = now + } + } +} diff --git a/core/txpool/legacypool/rebroadcast_boundaries_test.go b/core/txpool/legacypool/rebroadcast_boundaries_test.go new file mode 100644 index 0000000000..8abd9c6970 --- /dev/null +++ b/core/txpool/legacypool/rebroadcast_boundaries_test.go @@ -0,0 +1,159 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package legacypool + +import ( + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" +) + +func TestRebroadcastEligibilityBoundaries(t *testing.T) { + pool := &LegacyPool{config: Config{RebroadcastInterval: time.Minute, RebroadcastMaxAge: time.Hour}} + now := time.Now() + for _, tc := range []struct { + name string + age, sinceAttempt time.Duration + maxAge time.Duration + feeCap, tip, baseFee, minTip int64 + want bool + }{ + {"young", time.Minute - time.Nanosecond, 0, time.Hour, 10, 2, 10, 2, false}, + {"minimum age", time.Minute, 0, time.Hour, 10, 2, 10, 2, true}, + {"recent attempt", time.Minute, time.Minute - time.Nanosecond, time.Hour, 10, 2, 10, 2, false}, + {"retry boundary", time.Minute, time.Minute, time.Hour, 10, 2, 10, 2, true}, + {"maximum age", time.Hour, time.Minute, time.Hour, 10, 2, 10, 2, true}, + {"expired", time.Hour + time.Nanosecond, time.Minute, time.Hour, 10, 2, 10, 2, false}, + {"expiry disabled", 2 * time.Hour, time.Minute, 0, 10, 2, 10, 2, true}, + {"below base fee", time.Minute, 0, time.Hour, 9, 2, 10, 2, false}, + {"below tip", time.Minute, 0, time.Hour, 10, 1, 10, 2, false}, + } { + t.Run(tc.name, func(t *testing.T) { + pool.config.RebroadcastMaxAge = tc.maxAge + tx := types.NewTx(&types.DynamicFeeTx{GasFeeCap: big.NewInt(tc.feeCap), GasTipCap: big.NewInt(tc.tip)}) + tx.SetTime(now.Add(-tc.age)) + var last time.Time + if tc.sinceAttempt > 0 { + last = now.Add(-tc.sinceAttempt) + } + if got := pool.rebroadcastEligible(tx, last, now, big.NewInt(tc.baseFee), big.NewInt(tc.minTip)); got != tc.want { + t.Fatalf("eligible=%t, want %t", got, tc.want) + } + }) + } +} + +func TestRebroadcastSelectionWithoutCapacity(t *testing.T) { + pool, _, from, _ := setupRebroadcastTest(t, time.Hour, 24*time.Hour, 200) + defer pool.Close() + pool.mu.Lock() + defer pool.mu.Unlock() + setTxAge(pool, from, 2*time.Hour) + pool.config.RebroadcastBatchSize = 0 + if len(pool.identifyStuckTransactions()) != 0 { + t.Fatal("zero capacity returned candidates") + } + pool.config.RebroadcastBatchSize = 200 + pool.currentHead.Store(nil) + if len(pool.identifyStuckTransactions()) != 0 { + t.Fatal("missing head returned candidates") + } +} + +func TestRebroadcastSelectionFollowsBaseFee(t *testing.T) { + pool, _, from, tx := setupRebroadcastTest(t, time.Hour, 24*time.Hour, 200) + defer pool.Close() + pool.mu.Lock() + defer pool.mu.Unlock() + setTxAge(pool, from, 2*time.Hour) + for _, tc := range []struct { + name string + baseFee *big.Int + want int + }{ + {"above fee cap", new(big.Int).Mul(tx.GasFeeCap(), big.NewInt(2)), 0}, + {"fee recovered", new(big.Int).Div(tx.GasFeeCap(), big.NewInt(2)), 1}, + {"missing fee", nil, 1}, + } { + t.Run(tc.name, func(t *testing.T) { + head := types.CopyHeader(pool.currentHead.Load()) + head.BaseFee = tc.baseFee + pool.currentHead.Store(head) + if got := len(pool.identifyStuckTransactions()); got != tc.want { + t.Fatalf("selected %d transactions, want %d", got, tc.want) + } + if pool.all.Get(tx.Hash()) == nil { + t.Fatal("fee filtering removed transaction from pool") + } + }) + } +} + +func TestRebroadcastCandidateOrdering(t *testing.T) { + now := time.Now() + makeCandidate := func(nonce uint64, admitted, attempted time.Time) rebroadcastCandidate { + tx := types.NewTransaction(nonce, common.Address{}, new(big.Int), 21000, big.NewInt(1), nil) + tx.SetTime(admitted) + return rebroadcastCandidate{tx: tx, last: attempted} + } + for _, tc := range []struct { + name string + older, newer rebroadcastCandidate + }{ + {"attempt", makeCandidate(2, now, now.Add(-time.Minute)), makeCandidate(1, now, now)}, + {"admission", makeCandidate(2, now.Add(-time.Minute), now), makeCandidate(1, now, now)}, + {"nonce", makeCandidate(1, now, now), makeCandidate(2, now, now)}, + {"retry before later admission", makeCandidate(1, now.Add(-2*time.Minute), now.Add(-time.Minute)), makeCandidate(2, now, time.Time{})}, + {"first admission before recent retry", makeCandidate(2, now.Add(-2*time.Minute), time.Time{}), makeCandidate(1, now.Add(-3*time.Minute), now.Add(-time.Minute))}, + {"first attempts", makeCandidate(2, now.Add(-time.Minute), time.Time{}), makeCandidate(1, now, time.Time{})}, + {"equal priority", makeCandidate(2, now.Add(-time.Minute), now), makeCandidate(1, now, time.Time{})}, + } { + t.Run(tc.name, func(t *testing.T) { + if !tc.older.before(tc.newer) || tc.newer.before(tc.older) || tc.older.before(tc.older) { + t.Fatal("candidate order is not strict") + } + batch := rebroadcastCandidates{} + batch.consider(tc.newer, 1) + batch.consider(tc.older, 1) + if len(batch) != 1 || batch[0].tx != tc.older.tx { + t.Fatal("bounded batch discarded preferred candidate") + } + }) + } +} + +func TestRebroadcastSelectionKeepsOldestAttempts(t *testing.T) { + now := time.Now() + candidates := rebroadcastCandidates{} + for i := 5; i >= 1; i-- { + tx := types.NewTransaction(uint64(i), common.Address{}, new(big.Int), 21000, big.NewInt(1), nil) + candidates.consider(rebroadcastCandidate{tx: tx, last: now.Add(time.Duration(i) * time.Minute)}, 2) + } + if len(candidates) != 2 { + t.Fatal("selection exceeded batch limit") + } + seen := map[uint64]bool{} + for _, candidate := range candidates { + seen[candidate.tx.Nonce()] = true + } + if !seen[1] || !seen[2] { + t.Fatalf("retained nonces=%v; want 1 and 2", seen) + } +} diff --git a/core/txpool/legacypool/rebroadcast_cleanup_test.go b/core/txpool/legacypool/rebroadcast_cleanup_test.go new file mode 100644 index 0000000000..29d8bc1f45 --- /dev/null +++ b/core/txpool/legacypool/rebroadcast_cleanup_test.go @@ -0,0 +1,119 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package legacypool + +import ( + "math/big" + "testing" + "time" + + "github.com/holiman/uint256" + + "github.com/ethereum/go-ethereum/core/tracing" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/params" +) + +func TestRebroadcastRemovalDuringPromotion(t *testing.T) { + pool, key, from, tx := setupRebroadcastTest(t, time.Hour, 24*time.Hour, 200) + defer pool.Close() + pool.recordRebroadcastAttempts([]*types.Transaction{tx}) + replacement := pricedTransaction(0, 100000, big.NewInt(2*params.BorDefaultTxPoolPriceLimit), key) + pool.mu.Lock() + defer pool.mu.Unlock() + pool.all.Add(replacement) + pool.priced.Put(replacement, pool.priced.reheaps.Load()) + if !pool.promoteTx(from, replacement.Hash(), replacement) { + t.Fatal("promotion failed") + } + if len(pool.lastRebroadcastAttempt) != 0 { + t.Fatal("promoted replacement left old attempt history") + } +} + +func TestRebroadcastRemovalDuringDemotion(t *testing.T) { + for _, mode := range []string{"balance", "conditional"} { + t.Run(mode, func(t *testing.T) { + pool, _, from, tx := setupRebroadcastTest(t, time.Hour, 24*time.Hour, 200) + defer pool.Close() + pool.recordRebroadcastAttempts([]*types.Transaction{tx}) + pool.mu.Lock() + defer pool.mu.Unlock() + if mode == "balance" { + pool.currentState.SetBalance(from, new(uint256.Int), tracing.BalanceChangeUnspecified) + } else { + tx.PutOptions(&types.OptionsPIP15{BlockNumberMax: big.NewInt(-1)}) + } + pool.demoteUnexecutables() + if pool.all.Get(tx.Hash()) != nil || len(pool.lastRebroadcastAttempt) != 0 { + t.Fatal("demotion left removed transaction or attempt history") + } + }) + } +} + +func TestRebroadcastRemovalDuringQueueTruncation(t *testing.T) { + pool, key, _, _ := setupRebroadcastTest(t, time.Hour, 24*time.Hour, 200) + defer pool.Close() + queued := pricedTransaction(5, 100000, big.NewInt(params.BorDefaultTxPoolPriceLimit), key) + if err := pool.addRemoteSync(queued); err != nil { + t.Fatal(err) + } + pool.recordRebroadcastAttempts([]*types.Transaction{queued}) + pool.mu.Lock() + defer pool.mu.Unlock() + pool.queue.config.GlobalQueue = 0 + pool.truncateQueue() + if pool.all.Get(queued.Hash()) != nil || len(pool.lastRebroadcastAttempt) != 0 { + t.Fatal("queue truncation left removed transaction or attempt history") + } +} + +func TestRebroadcastRemovalDuringPendingTruncation(t *testing.T) { + pool, _ := setupPool() + defer pool.Close() + var txs []*types.Transaction + for _, count := range []int{8, 4} { + key, err := crypto.GenerateKey() + if err != nil { + t.Fatal(err) + } + addr := crypto.PubkeyToAddress(key.PublicKey) + testAddBalance(pool, addr, new(big.Int).Exp(big.NewInt(10), big.NewInt(24), nil)) + for nonce := 0; nonce < count; nonce++ { + tx := pricedTransaction(uint64(nonce), 100000, big.NewInt(params.BorDefaultTxPoolPriceLimit), key) + if err := pool.addRemoteSync(tx); err != nil { + t.Fatal(err) + } + txs = append(txs, tx) + } + } + pool.recordRebroadcastAttempts(txs) + pool.mu.Lock() + defer pool.mu.Unlock() + pool.config.AccountSlots, pool.config.GlobalSlots = 1, 4 + pool.truncatePending() + if pool.all.Count() != 4 || len(pool.lastRebroadcastAttempt) != 4 { + t.Fatalf("remaining transactions=%d; attempt records=%d", pool.all.Count(), len(pool.lastRebroadcastAttempt)) + } + for hash := range pool.lastRebroadcastAttempt { + if pool.all.Get(hash) == nil { + t.Fatal("pending truncation left removed transaction's history") + } + } +} diff --git a/core/txpool/legacypool/rebroadcast_fairness_test.go b/core/txpool/legacypool/rebroadcast_fairness_test.go new file mode 100644 index 0000000000..58bf493886 --- /dev/null +++ b/core/txpool/legacypool/rebroadcast_fairness_test.go @@ -0,0 +1,154 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package legacypool + +import ( + "crypto/ecdsa" + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/params" +) + +func TestRebroadcastRetriesAcrossNewBatches(t *testing.T) { + for _, tc := range []struct { + name string + partial bool + }{{"no acceptance", false}, {"partial acceptance", true}} { + t.Run(tc.name, func(t *testing.T) { + testRebroadcastRetriesAcrossNewBatches(t, tc.partial) + }) + } +} + +func testRebroadcastRetriesAcrossNewBatches(t *testing.T, partial bool) { + const interval = time.Hour + pool, key, from, head := setupRebroadcastTest(t, interval, 10*interval, 2) + defer pool.Close() + testAddBalance(pool, from, new(big.Int).Exp(big.NewInt(10), big.NewInt(24), nil)) + ch := make(chan core.StuckTxsEvent, 1) + sub := pool.SubscribeRebroadcastTransactionsWithAcknowledgement(ch) + defer sub.Unsubscribe() + addRebroadcastArrivals(t, pool, key, 1, 1, interval) + pool.mu.Lock() + setTxAge(pool, from, interval+interval/2) + initial := pool.identifyStuckTransactions() + pool.mu.Unlock() + if len(initial) != 2 || initial[0] != head { + t.Fatal("initial batch did not include the lowest nonce first") + } + publishRebroadcastFairnessBatch(t, pool, ch, initial, partial) + retries := 0 + for round := 1; round <= 5; round++ { + advanceRebroadcastTime(pool, from, interval+interval/2) + addRebroadcastArrivals(t, pool, key, uint64(2*round), 2, interval+interval/10) + batch := selectRebroadcastRetryBatch(t, pool, head) + for _, tx := range batch { + if tx == head { + retries++ + } + } + publishRebroadcastFairnessBatch(t, pool, ch, batch, partial) + } + if retries == 0 { + t.Error("new arrivals prevented every retry of the eligible lowest nonce") + } + advanceRebroadcastTime(pool, from, interval+interval/2) + assertRebroadcastRetryExpiry(t, pool, from, head) +} + +func addRebroadcastArrivals(t *testing.T, pool *LegacyPool, key *ecdsa.PrivateKey, first uint64, count int, age time.Duration) { + t.Helper() + for i := 0; i < count; i++ { + tx := pricedTransaction(first+uint64(i), 100000, big.NewInt(params.BorDefaultTxPoolPriceLimit), key) + if err := pool.addRemoteSync(tx); err != nil { + t.Fatal(err) + } + pool.mu.Lock() + tx.SetTime(time.Now().Add(-age)) + pool.mu.Unlock() + } +} + +func advanceRebroadcastTime(pool *LegacyPool, from common.Address, elapsed time.Duration) { + pool.mu.Lock() + defer pool.mu.Unlock() + for _, tx := range pool.pending[from].Flatten() { + tx.SetTime(tx.Time().Add(-elapsed)) + } + for hash, attempted := range pool.lastRebroadcastAttempt { + pool.lastRebroadcastAttempt[hash] = attempted.Add(-elapsed) + } + for hash, accepted := range pool.lastRebroadcast { + pool.lastRebroadcast[hash] = accepted.Add(-elapsed) + } +} + +func selectRebroadcastRetryBatch(t *testing.T, pool *LegacyPool, head *types.Transaction) []*types.Transaction { + t.Helper() + pool.mu.RLock() + defer pool.mu.RUnlock() + last := pool.rebroadcastLastActivity(head.Hash()) + if !pool.rebroadcastEligible(head, last, time.Now(), pool.strandedBaseFee(), pool.gasTip.Load().ToBig()) { + t.Fatal("lowest nonce became ineligible before its retry window ended") + } + batch := pool.identifyStuckTransactions() + if len(batch) != pool.config.RebroadcastBatchSize { + t.Fatalf("selected %d transactions, want a full batch", len(batch)) + } + return batch +} + +func publishRebroadcastFairnessBatch(t *testing.T, pool *LegacyPool, ch <-chan core.StuckTxsEvent, batch []*types.Transaction, partial bool) { + t.Helper() + pool.publishRebroadcastTransactions(batch) + delivered := receiveRebroadcastEvent(t, ch) + var accepted []common.Hash + if partial { + for _, tx := range delivered.Txs { + if tx.Nonce() != 0 { + accepted = append(accepted, tx.Hash()) + break + } + } + } + pool.RebroadcastAcknowledgement(delivered.Txs)(accepted) +} + +func assertRebroadcastRetryExpiry(t *testing.T, pool *LegacyPool, from common.Address, head *types.Transaction) { + t.Helper() + pool.mu.RLock() + defer pool.mu.RUnlock() + if pool.rebroadcastEligible(head, pool.rebroadcastLastActivity(head.Hash()), time.Now(), pool.strandedBaseFee(), pool.gasTip.Load().ToBig()) { + t.Error("retried transaction remained eligible beyond maximum age") + } + if pool.all.Get(head.Hash()) != head || !pool.pending[from].Contains(head.Nonce()) { + t.Error("rebroadcast expiry removed the transaction from pending") + } + if !pool.lastRebroadcast[head.Hash()].IsZero() { + t.Error("unaccepted lowest nonce was recorded as queue-accepted") + } + for _, tx := range pool.identifyStuckTransactions() { + if tx == head { + t.Error("expired transaction was selected again") + } + } +} diff --git a/core/txpool/legacypool/rebroadcast_scheduling_test.go b/core/txpool/legacypool/rebroadcast_scheduling_test.go new file mode 100644 index 0000000000..08c36c919f --- /dev/null +++ b/core/txpool/legacypool/rebroadcast_scheduling_test.go @@ -0,0 +1,192 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package legacypool + +import ( + "fmt" + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/params" +) + +func TestRebroadcastAttemptWithoutQueueAcceptance(t *testing.T) { + for _, age := range []time.Duration{2 * time.Hour, 48 * time.Hour} { + t.Run(age.String(), func(t *testing.T) { + pool, _, from, tx := setupRebroadcastTest(t, time.Hour, 24*time.Hour, 200) + defer pool.Close() + ch := make(chan core.StuckTxsEvent, 1) + sub := pool.SubscribeRebroadcastTransactionsWithAcknowledgement(ch) + defer sub.Unsubscribe() + pool.mu.Lock() + setTxAge(pool, from, age) + batch := pool.identifyStuckTransactions() + pool.mu.Unlock() + if len(batch) != 1 { + t.Fatal("first eligible attempt missing") + } + pool.publishRebroadcastTransactions(batch) + <-ch + pool.RebroadcastAcknowledgement(batch)(nil) + pool.mu.Lock() + defer pool.mu.Unlock() + if len(pool.lastRebroadcast) != 0 || pool.lastRebroadcastAttempt[tx.Hash()].IsZero() { + t.Fatal("attempt and queue acceptance must be recorded separately") + } + if len(pool.identifyStuckTransactions()) != 0 { + t.Fatal("failed queue acceptance bypassed retry interval") + } + pool.lastRebroadcastAttempt[tx.Hash()] = time.Now().Add(-2 * time.Hour) + want := 1 + if age > pool.config.RebroadcastMaxAge { + want = 0 + } + if got := len(pool.identifyStuckTransactions()); got != want { + t.Fatalf("after cooldown: got %d candidates, want %d", got, want) + } + if pool.all.Get(tx.Hash()) == nil { + t.Fatal("rebroadcast expiry removed transaction from pool") + } + }) + } +} + +func TestRebroadcastBatchProgressWithoutQueueAcceptance(t *testing.T) { + pool, key, from, _ := setupRebroadcastTest(t, time.Hour, 24*time.Hour, 200) + defer pool.Close() + testAddBalance(pool, from, new(big.Int).Exp(big.NewInt(10), big.NewInt(24), nil)) + for i := 1; i < 201; i++ { + tx := pricedTransaction(uint64(i), 100000, big.NewInt(params.BorDefaultTxPoolPriceLimit), key) + if err := pool.addRemoteSync(tx); err != nil { + t.Fatal(err) + } + } + pool.mu.Lock() + setTxAge(pool, from, 2*time.Hour) + first := pool.identifyStuckTransactions() + pool.mu.Unlock() + if len(first) != 200 { + t.Fatalf("first batch: got %d", len(first)) + } + pool.recordRebroadcastAttempts(first) + pool.mu.Lock() + defer pool.mu.Unlock() + for hash := range pool.lastRebroadcastAttempt { + pool.lastRebroadcastAttempt[hash] = time.Now().Add(-2 * time.Hour) + } + second := pool.identifyStuckTransactions() + seen := make(map[common.Hash]bool) + for _, batch := range [][]*types.Transaction{first, second} { + for _, tx := range batch { + seen[tx.Hash()] = true + } + } + if len(second) != 200 || len(seen) != 201 { + t.Fatalf("second batch=%d; covered=%d/201", len(second), len(seen)) + } + if len(pool.lastRebroadcast) != 0 || pool.pending[from].Len() != 201 { + t.Fatal("selection changed queue accounting or pool contents") + } +} + +func TestRebroadcastAttemptsCleanedUp(t *testing.T) { + for _, mode := range []string{"removed", "replaced", "stranded"} { + t.Run(mode, func(t *testing.T) { + pool, key, from, tx := setupRebroadcastTest(t, time.Hour, 24*time.Hour, 200) + defer pool.Close() + pool.recordRebroadcastAttempts([]*types.Transaction{tx}) + if mode == "replaced" { + replacement := pricedTransaction(0, 100000, big.NewInt(2*params.BorDefaultTxPoolPriceLimit), key) + if err := pool.addRemoteSync(replacement); err != nil { + t.Fatal(err) + } + } else { + pool.mu.Lock() + if mode == "stranded" { + pool.dropPendingAccount(from, pool.pending[from]) + } else { + pool.removeTx(tx.Hash(), true, false) + } + pool.mu.Unlock() + } + pool.recordRebroadcastAttempts([]*types.Transaction{tx}) + pool.mu.RLock() + defer pool.mu.RUnlock() + if len(pool.lastRebroadcastAttempt) != 0 { + t.Fatal("removed transaction retained attempt history") + } + }) + } +} + +func TestRebroadcastQueueRecovery(t *testing.T) { + pool, _, from, tx := setupRebroadcastTest(t, time.Hour, 24*time.Hour, 200) + defer pool.Close() + pool.mu.Lock() + setTxAge(pool, from, 2*time.Hour) + pool.mu.Unlock() + pool.recordRebroadcastAttempts([]*types.Transaction{tx}) + pool.mu.Lock() + pool.lastRebroadcastAttempt[tx.Hash()] = time.Now().Add(-2 * time.Hour) + batch := pool.identifyStuckTransactions() + pool.mu.Unlock() + if len(batch) != 1 { + t.Fatal("retry missing after cooldown") + } + pool.RebroadcastAcknowledgement(batch)([]common.Hash{tx.Hash()}) + pool.mu.RLock() + defer pool.mu.RUnlock() + if pool.lastRebroadcast[tx.Hash()].IsZero() || len(pool.identifyStuckTransactions()) != 0 { + t.Fatal("queue recovery did not update acceptance and suppress immediate retry") + } +} + +func BenchmarkRebroadcastSelection(b *testing.B) { + for _, count := range []int{200, 16384} { + b.Run(fmt.Sprint(count), func(b *testing.B) { + pool, _ := setupPoolWithConfig(params.TestChainConfig, func(pool *LegacyPool) { + pool.config.Rebroadcast = false + }) + defer pool.Close() + pool.mu.Lock() + pending := newList(true) + for i := 0; i < count; i++ { + tx := types.NewTransaction(uint64(i), common.Address{}, new(big.Int), 21000, big.NewInt(params.BorDefaultTxPoolPriceLimit), nil) + tx.SetTime(time.Now().Add(-time.Minute)) + if added, _ := pending.Add(tx, 10); !added { + b.Fatal("benchmark transaction was not added") + } + pool.all.Add(tx) + } + pool.pending[common.Address{}] = pending + pool.mu.Unlock() + pool.mu.RLock() + defer pool.mu.RUnlock() + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + if len(pool.identifyStuckTransactions()) != 200 { + b.Fatal("unexpected benchmark batch size") + } + } + }) + } +} diff --git a/core/txpool/legacypool/rebroadcast_selection.go b/core/txpool/legacypool/rebroadcast_selection.go new file mode 100644 index 0000000000..b1ff0ae321 --- /dev/null +++ b/core/txpool/legacypool/rebroadcast_selection.go @@ -0,0 +1,136 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package legacypool + +import ( + "container/heap" + "math/big" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/consensus/misc/eip1559" + "github.com/ethereum/go-ethereum/core/types" +) + +// identifyStuckTransactions orders transactions by their last attempt or admission. +// Selection is read-only; publication records attempts independently of queue acceptance. +// Must be called with pool.mu.RLock held. +func (pool *LegacyPool) identifyStuckTransactions() []*types.Transaction { + head := pool.currentHead.Load() + if head == nil || pool.config.RebroadcastBatchSize <= 0 { + return nil + } + var baseFee *big.Int + if pool.chainconfig.IsLondon(head.Number) && head.BaseFee != nil { + baseFee = eip1559.CalcBaseFee(pool.chainconfig, head) + } + now, minTip := time.Now(), pool.gasTip.Load().ToBig() + candidates := make(rebroadcastCandidates, 0, min(pool.config.RebroadcastBatchSize, pool.all.Count())) + for _, list := range pool.pending { + for _, tx := range list.txs.items { + last := pool.rebroadcastLastActivity(tx.Hash()) + if !pool.rebroadcastEligible(tx, last, now, baseFee, minTip) { + continue + } + candidates.consider(rebroadcastCandidate{tx: tx, last: last}, pool.config.RebroadcastBatchSize) + } + } + return candidates.popTransactions() +} + +func (pool *LegacyPool) rebroadcastEligible(tx *types.Transaction, last, now time.Time, baseFee, minTip *big.Int) bool { + age := now.Sub(tx.Time()) + if age < pool.config.RebroadcastInterval { + return false + } + if !last.IsZero() { + if now.Sub(last) < pool.config.RebroadcastInterval { + return false + } + // Allow one first attempt after a late fee drop. Further attempts obey + // the age limit even when no peer has accepted the transaction. + if pool.config.RebroadcastMaxAge > 0 && age > pool.config.RebroadcastMaxAge { + return false + } + } + return (baseFee == nil || tx.GasFeeCapIntCmp(baseFee) >= 0) && + tx.GasTipCapIntCmp(minTip) >= 0 && pool.canRebroadcast(tx) +} + +type rebroadcastCandidate struct { + tx *types.Transaction + last time.Time +} + +func (c rebroadcastCandidate) before(other rebroadcastCandidate) bool { + // First attempts join at admission time so later arrivals cannot indefinitely delay retries. + priority, otherPriority := c.last, other.last + if priority.IsZero() { + priority = c.tx.Time() + } + if otherPriority.IsZero() { + otherPriority = other.tx.Time() + } + if !priority.Equal(otherPriority) { + return priority.Before(otherPriority) + } + if !c.tx.Time().Equal(other.tx.Time()) { + return c.tx.Time().Before(other.tx.Time()) + } + return c.tx.Nonce() < other.tx.Nonce() +} + +// Keep the least preferred candidate at the root so selection retains only +// one batch while scanning the pool, rather than sorting every pending transaction. +type rebroadcastCandidates []rebroadcastCandidate + +func (h *rebroadcastCandidates) consider(candidate rebroadcastCandidate, limit int) { + if len(*h) < limit { + heap.Push(h, candidate) + } else if candidate.before((*h)[0]) { + (*h)[0] = candidate + heap.Fix(h, 0) + } +} + +func (h rebroadcastCandidates) Len() int { return len(h) } +func (h rebroadcastCandidates) Less(i, j int) bool { return h[j].before(h[i]) } +func (h rebroadcastCandidates) Swap(i, j int) { h[i], h[j] = h[j], h[i] } +func (h *rebroadcastCandidates) Push(x any) { *h = append(*h, x.(rebroadcastCandidate)) } +func (h *rebroadcastCandidates) Pop() any { + last := len(*h) - 1 + x := (*h)[last] + (*h)[last] = rebroadcastCandidate{} + *h = (*h)[:last] + return x +} + +func (pool *LegacyPool) rebroadcastLastActivity(hash common.Hash) time.Time { + last := pool.lastRebroadcastAttempt[hash] + if accepted := pool.lastRebroadcast[hash]; accepted.After(last) { + return accepted + } + return last +} + +func (h *rebroadcastCandidates) popTransactions() []*types.Transaction { + txs := make([]*types.Transaction, len(*h)) + for i := len(txs) - 1; i >= 0; i-- { + txs[i] = heap.Pop(h).(rebroadcastCandidate).tx + } + return txs +} diff --git a/core/txpool/legacypool/rebroadcast_test.go b/core/txpool/legacypool/rebroadcast_test.go index 9064cdc662..45936c605d 100644 --- a/core/txpool/legacypool/rebroadcast_test.go +++ b/core/txpool/legacypool/rebroadcast_test.go @@ -59,11 +59,12 @@ func TestRebroadcastSelectionDoesNotRecordSend(t *testing.T) { if tracked { t.Fatal("selecting a batch must not record a rebroadcast") } - for i := 0; i < 3; i++ { - batch = receiveRebroadcastEvent(t, ch) - if len(batch.Txs) != 1 || batch.Txs[0].Hash() != tx.Hash() { - t.Fatal("unsent transaction must remain eligible past max age") - } + pool.mu.RLock() + attempted := !pool.lastRebroadcastAttempt[tx.Hash()].IsZero() + stillEligible := pool.identifyStuckTransactions() + pool.mu.RUnlock() + if !attempted || len(stillEligible) != 0 { + t.Fatal("an unacknowledged attempt must still be bounded") } pool.RebroadcastAcknowledgement(batch.Txs)([]common.Hash{tx.Hash()}) pool.mu.RLock() diff --git a/eth/api_backend.go b/eth/api_backend.go index 29609f3b70..b4ccf6519b 100644 --- a/eth/api_backend.go +++ b/eth/api_backend.go @@ -415,6 +415,12 @@ func (b *EthAPIBackend) SendTx(ctx context.Context, signedTx *types.Transaction) if b.eth.localTxTracker == nil { return err } + if b.relay != nil { + if getter := b.relay.GetPrivateTxGetter(); getter != nil && getter.IsTxPrivate(signedTx.Hash()) { + // The ordinary retry journal cannot preserve private transaction metadata. + return err + } + } // If the transaction fails with an error indicating it is invalid, or if there is // very little chance it will be accepted later (e.g., the gas price is below the // configured minimum, or the sender has insufficient funds to cover the cost), @@ -887,6 +893,10 @@ func (b *EthAPIBackend) RecordPrivateTx(hash common.Hash) { b.relay.RecordPrivateTx(hash) } +func (b *EthAPIBackend) BeginPrivateTx(hash common.Hash) func(bool) { + return b.relay.BeginPrivateTx(hash) +} + func (b *EthAPIBackend) PurgePrivateTx(hash common.Hash) { b.relay.PurgePrivateTx(hash) } diff --git a/eth/api_backend_private_lifecycle_test.go b/eth/api_backend_private_lifecycle_test.go new file mode 100644 index 0000000000..318bc9e022 --- /dev/null +++ b/eth/api_backend_private_lifecycle_test.go @@ -0,0 +1,153 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package eth + +import ( + "errors" + "slices" + "testing" + + "github.com/holiman/uint256" + + "github.com/ethereum/go-ethereum/core/txpool" + "github.com/ethereum/go-ethereum/internal/ethapi" + "github.com/ethereum/go-ethereum/params" +) + +func TestSendTxPrivatePublicDuplicate(t *testing.T) { + b := privateSubmissionBackend(t, false) + tx := makeTx(0, nil, nil, key) + if err := b.SendTx(t.Context(), tx); err != nil { + t.Fatal(err) + } + if err := b.eth.txPool.Sync(); err != nil { + t.Fatal(err) + } + initial := &handler{txpool: b.eth.txPool, privateTxGetter: b.relay.GetPrivateTxGetter()} + if !slices.Contains(initial.pendingTransactionHashes(), tx.Hash()) { + t.Fatal("fixture transaction was not publicly announceable before private call") + } + raw, err := tx.MarshalBinary() + if err != nil { + t.Fatal(err) + } + api := ethapi.NewTransactionAPI(b, new(ethapi.AddrLocker)) + _, err = api.SendRawTransactionPrivate(t.Context(), raw) + if !errors.Is(err, txpool.ErrAlreadyKnown) { + t.Fatalf("private resubmit error = %v", err) + } + if b.relay.GetPrivateTxGetter().IsTxPrivate(tx.Hash()) { + t.Error("failed private resubmit marked an already-public transaction private") + } + h := &handler{txpool: b.eth.txPool, privateTxGetter: b.relay.GetPrivateTxGetter()} + if !slices.Contains(h.pendingTransactionHashes(), tx.Hash()) { + t.Error("already-public transaction disappeared from production pending announcements") + } +} + +func TestSendTxPrivateRejectedThenPublic(t *testing.T) { + b := privateSubmissionBackend(t, true) + first := pricedSetCodeTx(0, 250000, uint256.NewInt(25*params.GWei), uint256.NewInt(25*params.GWei), key, []unsignedAuth{{nonce: 0, key: key}}) + if err := b.SendTx(t.Context(), first); err != nil { + t.Fatal(err) + } + tx := makeTx(1, nil, nil, key) + raw, err := tx.MarshalBinary() + if err != nil { + t.Fatal(err) + } + api := ethapi.NewTransactionAPI(b, new(ethapi.AddrLocker)) + _, err = api.SendRawTransactionPrivate(t.Context(), raw) + if !errors.Is(err, txpool.ErrInflightTxLimitReached) { + t.Fatalf("private submission error = %v", err) + } + if b.eth.txPool.Has(tx.Hash()) { + t.Fatal("rejected transaction entered pool") + } + if b.relay.GetPrivateTxGetter().IsTxPrivate(tx.Hash()) { + t.Error("rejected transaction left a private marker") + } + b.eth.txPool.Clear() + if err := b.SendTx(t.Context(), makeTx(0, nil, nil, key)); err != nil { + t.Fatal(err) + } + if err := b.SendTx(t.Context(), tx); err != nil { + t.Fatal(err) + } + if err := b.eth.txPool.Sync(); err != nil { + t.Fatal(err) + } + if !b.eth.txPool.Has(tx.Hash()) { + t.Fatal("later public submission was not admitted") + } + h := &handler{txpool: b.eth.txPool, privateTxGetter: b.relay.GetPrivateTxGetter()} + if !slices.Contains(h.pendingTransactionHashes(), tx.Hash()) { + t.Error("later public submission was suppressed by failed private marker") + } +} + +func TestSendTxPrivateRejectedMarkers(t *testing.T) { + b := privateSubmissionBackend(t, true) + first := pricedSetCodeTx(0, 250000, uint256.NewInt(25*params.GWei), uint256.NewInt(25*params.GWei), key, []unsignedAuth{{nonce: 0, key: key}}) + if err := b.SendTx(t.Context(), first); err != nil { + t.Fatal(err) + } + api := ethapi.NewTransactionAPI(b, new(ethapi.AddrLocker)) + retained := 0 + for nonce := uint64(1); nonce <= 32; nonce++ { + tx := makeTx(nonce, nil, nil, key) + raw, err := tx.MarshalBinary() + if err != nil { + t.Fatal(err) + } + _, err = api.SendRawTransactionPrivate(t.Context(), raw) + if err == nil { + t.Fatalf("submission %d unexpectedly admitted", nonce) + } + if b.eth.txPool.Has(tx.Hash()) { + t.Fatal("rejected transaction entered pool") + } + if b.relay.GetPrivateTxGetter().IsTxPrivate(tx.Hash()) { + retained++ + } + } + if retained != 0 { + t.Fatalf("%d of 32 failed submissions retained markers", retained) + } +} + +func TestSendTxPrivateExistingCommitment(t *testing.T) { + b := privateSubmissionBackend(t, false) + tx := makeTx(0, nil, nil, key) + raw, err := tx.MarshalBinary() + if err != nil { + t.Fatal(err) + } + api := ethapi.NewTransactionAPI(b, new(ethapi.AddrLocker)) + if _, err = api.SendRawTransactionPrivate(t.Context(), raw); err != nil { + t.Fatal(err) + } + if !b.relay.GetPrivateTxGetter().IsTxPrivate(tx.Hash()) { + t.Fatal("successful private submission did not commit its marker") + } + if _, err = api.SendRawTransactionPrivate(t.Context(), raw); !errors.Is(err, txpool.ErrAlreadyKnown) { + t.Fatalf("repeat private error = %v", err) + } + if !b.relay.GetPrivateTxGetter().IsTxPrivate(tx.Hash()) { + t.Fatal("already-known private retry removed its previous commitment") + } +} diff --git a/eth/api_backend_private_test.go b/eth/api_backend_private_test.go new file mode 100644 index 0000000000..c6c9a9dd53 --- /dev/null +++ b/eth/api_backend_private_test.go @@ -0,0 +1,96 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package eth + +import ( + "io" + "os" + "path/filepath" + "testing" + "time" + + "github.com/holiman/uint256" + "github.com/stretchr/testify/require" + + "github.com/ethereum/go-ethereum/core/txpool" + "github.com/ethereum/go-ethereum/core/txpool/locals" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/eth/relay" + "github.com/ethereum/go-ethereum/params" + "github.com/ethereum/go-ethereum/rlp" +) + +func privateSubmissionBackend(t *testing.T, withLocal bool) *EthAPIBackend { + t.Helper() + b := initBackend(withLocal) + b.relay = relay.Init(false, false, false, true, nil) + b.relay.SetTxPoolChecker(b.eth.txPool.Has) + t.Cleanup(func() { + b.relay.Close() + require.NoError(t, b.eth.txPool.Close()) + b.eth.blockchain.Stop() + }) + return b +} + +func TestSendTxPrivateAdmission(t *testing.T) { + for _, private := range []bool{false, true} { + t.Run(map[bool]string{false: "public", true: "private"}[private], func(t *testing.T) { + b := privateSubmissionBackend(t, true) + first := pricedSetCodeTx(0, 250000, uint256.NewInt(25*params.GWei), uint256.NewInt(25*params.GWei), key, []unsignedAuth{{nonce: 0, key: key}}) + require.NoError(t, b.SendTx(t.Context(), first)) + tx := makeTx(1, nil, nil, key) + if private { + b.RecordPrivateTx(tx.Hash()) + } + err := b.SendTx(t.Context(), tx) + if private { + require.ErrorIs(t, err, txpool.ErrInflightTxLimitReached) + } else { + require.NoError(t, err) + } + require.False(t, b.eth.txPool.Has(tx.Hash())) + }) + } +} + +func TestSendTxPrivateJournal(t *testing.T) { + b := privateSubmissionBackend(t, false) + path := filepath.Join(t.TempDir(), "transactions.rlp") + tracker := locals.New(path, time.Minute, gspec.Config, b.eth.txPool) + b.eth.localTxTracker = tracker + require.NoError(t, tracker.Start()) + t.Cleanup(func() { require.NoError(t, tracker.Stop()) }) + require.Eventually(t, func() bool { + _, err := os.Stat(path) + return err == nil + }, time.Second, time.Millisecond) + private, public := makeTx(0, nil, nil, key), makeTx(1, nil, nil, key) + b.RecordPrivateTx(private.Hash()) + require.NoError(t, b.SendTx(t.Context(), private)) + require.NoError(t, b.SendTx(t.Context(), public)) + require.True(t, b.eth.txPool.Has(private.Hash())) + require.True(t, b.eth.txPool.Has(public.Hash())) + file, err := os.Open(path) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, file.Close()) }) + stream := rlp.NewStream(file, 0) + var saved types.Transaction + require.NoError(t, stream.Decode(&saved)) + require.Equal(t, public.Hash(), saved.Hash(), "only public transactions belong in the ordinary retry journal") + require.ErrorIs(t, stream.Decode(&saved), io.EOF) +} diff --git a/eth/downloader/bor_fetch_helpers_test.go b/eth/downloader/bor_fetch_helpers_test.go index 8c9e5e4aae..1cb09b0cbd 100644 --- a/eth/downloader/bor_fetch_helpers_test.go +++ b/eth/downloader/bor_fetch_helpers_test.go @@ -385,7 +385,7 @@ func TestCollectIdlePeersWithinGraceKeepsWaiting(t *testing.T) { } } -func TestGradeStalePeerClearsEntryAndStrikesOnce(t *testing.T) { +func TestGradeStalePeerClearsEntryWithoutStrikes(t *testing.T) { t.Parallel() d := &Downloader{peers: newPeerSet(), dropPeer: func(string) {}} @@ -402,8 +402,8 @@ func TestGradeStalePeerClearsEntryAndStrikesOnce(t *testing.T) { if _, ok := stales["peer"]; ok { t.Fatal("a graded stale entry must be removed so an expired backoff does not re-grade the same timeout") } - if got := softStrikeTally(d.peers, "peer"); got != 1 { - t.Fatalf("grading a stale entry once should record exactly one soft strike, have %d", got) + if got := softStrikeTally(d.peers, "peer"); got != 0 { + t.Fatalf("grading a stale timeout should not record soft strikes, have %d", got) } if !pc.backedOff() { t.Fatal("a graded staller should be benched") @@ -414,8 +414,8 @@ func TestGradeStalePeerClearsEntryAndStrikesOnce(t *testing.T) { pc.lock.Unlock() d.classifyPeer(queue, pc, nil, stales) - if got := softStrikeTally(d.peers, "peer"); got != 1 { - t.Fatalf("a peer with no outstanding stale entry must not accrue a second strike, have %d", got) + if got := softStrikeTally(d.peers, "peer"); got != 0 { + t.Fatalf("a peer with no outstanding stale entry must not accrue a strike, have %d", got) } } diff --git a/eth/downloader/bor_fetchers_concurrent.go b/eth/downloader/bor_fetchers_concurrent.go index 822d116003..86ca6b0880 100644 --- a/eth/downloader/bor_fetchers_concurrent.go +++ b/eth/downloader/bor_fetchers_concurrent.go @@ -182,8 +182,8 @@ func (d *Downloader) gradeStalePeer(queue typedQueue, peer *peerConnection, stal if peer.backedOff() { return false } - peer.log.Warn("Peer stalling, backing off", "waited", common.PrettyDuration(waited), "graceperiod", timeoutGracePeriod, "queueKind", queue.kind()) - d.respondToPeer(peer, peerFailureStalling, fmt.Errorf("%w: stalled for %s", errStallingPeer, common.PrettyDuration(waited))) + peer.log.Warn("Peer request timed out, backing off", "waited", common.PrettyDuration(waited), "graceperiod", timeoutGracePeriod, "queueKind", queue.kind()) + d.respondToPeer(peer, peerFailureTimeout, fmt.Errorf("%w: no response for %s", errTimeout, common.PrettyDuration(waited))) return true } @@ -421,7 +421,7 @@ func (d *Downloader) concurrentFetch(queue typedQueue, beaconMode bool) error { } if req, ok := stales[peerid]; ok { - d.respondToPeer(event.peer, peerFailureStalling, fmt.Errorf("%w: peer left with a stale request", errStallingPeer)) + d.respondToPeer(event.peer, peerFailureTimeout, fmt.Errorf("%w: peer left with a stale request", errTimeout)) delete(stales, peerid) req.Close() } diff --git a/eth/downloader/bor_fetchers_concurrent_test.go b/eth/downloader/bor_fetchers_concurrent_test.go index bbc19fd8c5..3e12ed7144 100644 --- a/eth/downloader/bor_fetchers_concurrent_test.go +++ b/eth/downloader/bor_fetchers_concurrent_test.go @@ -207,14 +207,17 @@ func TestConcurrentFetchGradesDepartedStalePeer(t *testing.T) { t.Fatalf("failed to unregister peer: %v", err) } - if !waitFor(func() bool { return softStrikeTally(d.peers, peer.id) >= 1 }) { + if !waitFor(func() bool { return d.peers.persistentBackoff(peer.id) > 0 }) { close(d.cancelCh) <-done - t.Fatal("a peer that left with a stale request must be graded, not silently dropped") + t.Fatal("a peer that left with a stale request should retain its timeout backoff") } close(d.cancelCh) <-done + if got := softStrikeTally(d.peers, peer.id); got != 0 { + t.Fatalf("a stale timeout should not record soft strikes, have %d", got) + } } func TestConcurrentFetchMasterTimeoutAborts(t *testing.T) { diff --git a/eth/downloader/peer.go b/eth/downloader/peer.go index aef25ecfa4..0e9d701abb 100644 --- a/eth/downloader/peer.go +++ b/eth/downloader/peer.go @@ -330,11 +330,12 @@ func evictSoonestJail(m map[string]time.Time) { } } -func (ps *peerSet) backoffSoftFailure(peer *peerConnection) (penalized, jailed bool) { +func (ps *peerSet) backoffSoftFailure(peer *peerConnection, reason peerFailureReason) (penalized, jailed bool) { if !peer.backoffForClaim(peerSoftBackoff) { return false, false } - if ps.recordSoftFailure(peer.id, time.Now()) >= softFailureJailThreshold { + // Timeouts can reflect local load or network delays, so they don't establish peer misbehavior. + if reason != peerFailureTimeout && ps.recordSoftFailure(peer.id, time.Now()) >= softFailureJailThreshold { ps.clearSoftFailures(peer.id) peer.backoffFor(peerJailBackoff) jailed = true diff --git a/eth/downloader/peer_response.go b/eth/downloader/peer_response.go index f659d99a5f..9fb86347e0 100644 --- a/eth/downloader/peer_response.go +++ b/eth/downloader/peer_response.go @@ -191,7 +191,7 @@ func (d *Downloader) jailPeer(peer *peerConnection, backoff time.Duration, reaso } func (d *Downloader) backoffPeer(peer *peerConnection, decision peerResponseDecision, err error) { - penalized, jailed := d.peers.backoffSoftFailure(peer) + penalized, jailed := d.peers.backoffSoftFailure(peer, decision.reason) if !penalized { return } diff --git a/eth/downloader/peer_response_test.go b/eth/downloader/peer_response_test.go index b962754c95..716b9ad15e 100644 --- a/eth/downloader/peer_response_test.go +++ b/eth/downloader/peer_response_test.go @@ -506,14 +506,14 @@ func TestBackoffEscalatesToJailAfterRepeatStrikes(t *testing.T) { } for i := 0; i < softFailureJailThreshold-1; i++ { - d.respondToPeer(peer, peerFailureTimeout, errTimeout) + d.respondToPeer(peer, peerFailureStalling, errStallingPeer) if remaining := peer.backoffRemaining(); remaining > peerSoftBackoff { t.Fatalf("soft strike %d should stay within soft backoff: have %v, want <= %v", i+1, remaining, peerSoftBackoff) } clearPeerBackoff(peer) } - d.respondToPeer(peer, peerFailureTimeout, errTimeout) + d.respondToPeer(peer, peerFailureStalling, errStallingPeer) if remaining := peer.backoffRemaining(); remaining <= peerSoftBackoff { t.Fatalf("the escalating strike should jail the peer: have %v, want > %v", remaining, peerSoftBackoff) } @@ -560,10 +560,10 @@ func TestBackoffSoftFailureDedupsWhileBenched(t *testing.T) { t.Fatalf("failed to register peer: %v", err) } - if penalized, _ := ps.backoffSoftFailure(peer); !penalized { + if penalized, _ := ps.backoffSoftFailure(peer, peerFailureStalling); !penalized { t.Fatal("the first soft failure should penalize the peer") } - if penalized, _ := ps.backoffSoftFailure(peer); penalized { + if penalized, _ := ps.backoffSoftFailure(peer, peerFailureStalling); penalized { t.Fatal("a peer already benched must not be penalized again for a concurrent failure") } if got := ps.softStrikes[peer.id].count; got != 1 { diff --git a/eth/downloader/peer_timeout_test.go b/eth/downloader/peer_timeout_test.go new file mode 100644 index 0000000000..f4511b11d8 --- /dev/null +++ b/eth/downloader/peer_timeout_test.go @@ -0,0 +1,153 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package downloader + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/ethereum/go-ethereum/eth/protocols/eth" + "github.com/ethereum/go-ethereum/log" +) + +func TestTimeoutsOnlyBackOff(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + err error + }{ + {"timeout", errTimeout}, + {"deadline", context.DeadlineExceeded}, + {"wrapped timeout", fmt.Errorf("%w: %w", errInvalidChain, errTimeout)}, + {"wrapped deadline", fmt.Errorf("%w: %w", errBadPeer, context.DeadlineExceeded)}, + {"flattened deadline", fmt.Errorf("header request: %s", context.DeadlineExceeded)}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := &Downloader{peers: newPeerSet(), dropPeer: func(id string) { t.Errorf("timeout dropped peer %q", id) }} + peer := newPeerConnection("peer", eth.ETH69, nil, log.New()) + if err := d.peers.Register(peer); err != nil { + t.Fatal(err) + } + for i := 0; i < 2*softFailureJailThreshold; i++ { + clearPeerBackoff(peer) + if !d.handleSyncFailure(peer, peer.id, tt.err) { + t.Fatal("timeout was not handled") + } + checkTimeoutBackoff(t, d.peers, peer, 0) + } + }) + } +} + +func TestTimeoutsPreserveOtherFailureStrikes(t *testing.T) { + t.Parallel() + + d := &Downloader{peers: newPeerSet()} + peer := newPeerConnection("peer", eth.ETH69, nil, log.New()) + if err := d.peers.Register(peer); err != nil { + t.Fatal(err) + } + for i := 0; i < softFailureJailThreshold-1; i++ { + d.respondToPeer(peer, peerFailureStalling, errStallingPeer) + clearPeerBackoff(peer) + } + for i := 0; i < 2*softFailureJailThreshold; i++ { + d.respondToPeer(peer, peerFailureTimeout, errTimeout) + checkTimeoutBackoff(t, d.peers, peer, softFailureJailThreshold-1) + clearPeerBackoff(peer) + } + d.respondToPeer(peer, peerFailureStalling, errStallingPeer) + if remaining := peer.backoffRemaining(); remaining <= peerSoftBackoff { + t.Fatalf("non-timeout failures should still escalate: remaining %v", remaining) + } +} + +func TestSkeletonTimeoutsOnlyBackOff(t *testing.T) { + t.Parallel() + + peers := newPeerSet() + peer := newPeerConnection("peer", eth.ETH69, nil, log.New()) + if err := peers.Register(peer); err != nil { + t.Fatal(err) + } + s := &skeleton{peers: peers} + for i := 0; i < 2*softFailureJailThreshold; i++ { + clearPeerBackoff(peer) + req := &headerRequest{ + peer: peer.id, + stale: make(chan struct{}), + cancel: make(chan struct{}), + revert: make(chan *headerRequest, 1), + } + s.handleHeaderTimeout(peer, req, time.Second) + checkTimeoutBackoff(t, peers, peer, 0) + select { + case reverted := <-req.revert: + if reverted != req { + t.Fatal("requeued a different request") + } + default: + t.Fatal("timed-out headers were not requeued") + } + } +} + +func TestStaleTimeoutsOnlyBackOff(t *testing.T) { + t.Parallel() + + d := &Downloader{peers: newPeerSet()} + peer := newPeerConnection("peer", eth.ETH69, nil, log.New()) + if err := d.peers.Register(peer); err != nil { + t.Fatal(err) + } + for i := 0; i < 2*softFailureJailThreshold; i++ { + clearPeerBackoff(peer) + req := ð.Request{Peer: peer.id, Sent: time.Now().Add(-2 * timeoutGracePeriod), Cancel: make(chan struct{})} + stales := map[string]*eth.Request{peer.id: req} + d.classifyPeer(fakeTypedQueue{k: headerQueueKind}, peer, nil, stales) + checkTimeoutBackoff(t, d.peers, peer, 0) + if len(stales) != 0 { + t.Fatal("timed-out request was not removed") + } + select { + case <-req.Cancel: + default: + t.Fatal("timed-out request was not closed") + } + } +} + +func checkTimeoutBackoff(t *testing.T, peers *peerSet, peer *peerConnection, strikes int) { + t.Helper() + + if remaining := peer.backoffRemaining(); remaining <= 0 || remaining > peerSoftBackoff { + t.Fatalf("timeout backoff should be in (0, %v], got %v", peerSoftBackoff, remaining) + } + if remaining := peers.persistentBackoff(peer.id); remaining <= 0 || remaining > peerSoftBackoff { + t.Fatalf("persistent timeout backoff should be in (0, %v], got %v", peerSoftBackoff, remaining) + } + if got := softStrikeTally(peers, peer.id); got != strikes { + t.Fatalf("timeout changed escalation strikes: have %d, want %d", got, strikes) + } + if peers.Peer(peer.id) != peer { + t.Fatal("timeout removed the peer") + } +} diff --git a/eth/downloader/skeleton.go b/eth/downloader/skeleton.go index 29c20c0d49..bc64eb5c7a 100644 --- a/eth/downloader/skeleton.go +++ b/eth/downloader/skeleton.go @@ -1027,14 +1027,9 @@ func (s *skeleton) handleHeaderTimeout(peer *peerConnection, req *headerRequest, } headerTimeoutMeter.Mark(1) s.peers.rates.Update(peer.id, eth.BlockHeadersMsg, 0, 0) - if penalized, jailed := s.peers.backoffSoftFailure(peer); penalized { - if jailed { - peerJailMeter.Mark(1) - peer.log.Warn("Skeleton: escalating repeated header timeouts to local jail", "elapsed", ttl, "effective", common.PrettyDuration(peer.backoffRemaining())) - } else { - peerSoftBackoffMeter.Mark(1) - peer.log.Warn("Skeleton: header request timed out, backing off peer", "elapsed", ttl, "effective", common.PrettyDuration(peer.backoffRemaining())) - } + if penalized, _ := s.peers.backoffSoftFailure(peer, peerFailureTimeout); penalized { + peerSoftBackoffMeter.Mark(1) + peer.log.Warn("Skeleton: header request timed out, backing off peer", "elapsed", ttl, "effective", common.PrettyDuration(peer.backoffRemaining())) } s.scheduleRevertRequest(req) } diff --git a/eth/fetcher/block_fetcher.go b/eth/fetcher/block_fetcher.go index 9a2d919f35..3d3134c331 100644 --- a/eth/fetcher/block_fetcher.go +++ b/eth/fetcher/block_fetcher.go @@ -19,7 +19,6 @@ package fetcher import ( "errors" - "math/rand" "sync" "time" @@ -39,6 +38,10 @@ const ( arriveTimeout = 500 * time.Millisecond // Time allowance before an announced block/transaction is explicitly requested gatherSlack = 100 * time.Millisecond // Interval used to collate almost-expired announces with fetches fetchTimeout = 5 * time.Second // Maximum allotted time to return an explicitly requested block/transaction + + // fetchRetryInterval is how long announcements that admission could not + // place wait before being reconsidered. + fetchRetryInterval = 20 * time.Millisecond ) const ( @@ -225,6 +228,7 @@ type BlockFetcher struct { bodyFilter chan chan *bodyFilterTask done chan common.Hash + fetchRelease chan blockFetchRelease witnessRetry chan *blockOrHeaderInject // Import failed with a size-oracle-accepted witness: forget, then re-fetch the witness quit chan struct{} @@ -238,6 +242,11 @@ type BlockFetcher struct { fetched map[common.Hash][]*blockAnnounce // Blocks with headers fetched, scheduled for body retrieval completing map[common.Hash]*blockAnnounce // Blocks with headers, currently body-completing + // Outstanding request accounting, bounding workers that are blocked in + // dispatch before their response deadline exists. + fetchSlots map[string]int + fetchSlotsTotal int + // Block cache queue *prque.Prque[int64, *blockOrHeaderInject] // Queue containing the import operations (block number sorted) queues map[string]int // Per peer block counts to prevent memory exhaustion @@ -279,6 +288,7 @@ func NewBlockFetcher(light bool, getHeader HeaderRetrievalFn, getBlock blockRetr headerFilter: make(chan chan *headerFilterTask), bodyFilter: make(chan chan *bodyFilterTask), done: make(chan common.Hash), + fetchRelease: make(chan blockFetchRelease), witnessRetry: make(chan *blockOrHeaderInject), quit: make(chan struct{}), announces: make(map[string]int), @@ -286,6 +296,7 @@ func NewBlockFetcher(light bool, getHeader HeaderRetrievalFn, getBlock blockRetr fetching: make(map[common.Hash]*blockAnnounce), fetched: make(map[common.Hash][]*blockAnnounce), completing: make(map[common.Hash]*blockAnnounce), + fetchSlots: make(map[string]int), queue: prque.New[int64, *blockOrHeaderInject](nil), queues: make(map[string]int), queued: make(map[common.Hash]*blockOrHeaderInject), @@ -518,14 +529,7 @@ func (f *BlockFetcher) loop() { for { // Clean up any expired block fetches - f.mu.RLock() - expiredHashes := make([]common.Hash, 0) - for hash, announce := range f.fetching { - if time.Since(announce.time) > fetchTimeout { - expiredHashes = append(expiredHashes, hash) - } - } - f.mu.RUnlock() + expiredHashes := f.expiredFetches() for _, hash := range expiredHashes { f.forgetHash(hash) @@ -597,9 +601,10 @@ func (f *BlockFetcher) loop() { // A block was announced, make sure the peer isn't DOSing us blockAnnounceInMeter.Mark(1) - count := f.announces[notification.origin] + 1 - if count > hashLimit { - log.Debug("Peer exceeded outstanding announces", "peer", notification.origin, "limit", hashLimit) + count, ok := f.admitAnnouncement(notification.origin) + if !ok { + log.Debug("Declined outstanding announce", "peer", notification.origin, + "limit", hashLimit, "origins", len(f.announces), "originLimit", maxPendingAnnounceOrigins) blockAnnounceDOSMeter.Mark(1) break } @@ -633,7 +638,7 @@ func (f *BlockFetcher) loop() { } if len(f.announced) == 1 { - f.rescheduleFetch(fetchTimer) + f.rescheduleFetch(fetchTimer, false) } case op := <-f.inject: @@ -661,6 +666,8 @@ func (f *BlockFetcher) loop() { // A pending import finished, remove all traces of the notification f.forgetHash(hash) // This calls wm.forget f.forgetBlock(hash) // This calls wm.forget + case release := <-f.fetchRelease: + f.releaseFetchOwnership(release) case op := <-f.witnessRetry: // An import failed with a witness accepted on the WIT2 size oracle @@ -674,176 +681,15 @@ func (f *BlockFetcher) loop() { case <-fetchTimer.C: // At least one block's timer ran out, check for needing retrieval - request := make(map[string][]common.Hash) - - for hash, announces := range f.announced { - // In current LES protocol(les2/les3), only header announce is - // available, no need to wait too much time for header broadcast. - timeout := arriveTimeout - gatherSlack - if f.light { - timeout = 0 - } - - if time.Since(announces[0].time) > timeout { - // Determine if we should proceed to fetch the header for this hash. - shouldScheduleFetch := !f.wm.isPending(hash) && // Not handled by witness manager - f.queued[hash] == nil && // Not already in the import queue - f.fetched[hash] == nil && // Header not already fetched - f.completing[hash] == nil && // Body not already being completed - ((f.light && f.getHeader(hash) == nil) || (!f.light && f.getBlock(hash) == nil)) // Not in DB - - if shouldScheduleFetch { - announceToUse := announces[rand.Intn(len(announces))] - f.forgetHash(hash) // This also removes from f.announced - - request[announceToUse.origin] = append(request[announceToUse.origin], hash) - f.fetching[hash] = announceToUse - } else { - for _, annItem := range announces { // 'announces' is f.announced[hash] - f.announces[annItem.origin]-- - if f.announces[annItem.origin] <= 0 { - delete(f.announces, annItem.origin) - } - } - delete(f.announced, hash) - if f.announceChangeHook != nil { - f.announceChangeHook(hash, false) - } - } - } - } - // Send out all block header requests - for peer, hashes := range request { - log.Debug("Fetching scheduled headers", "peer", peer, "list", hashes) - if len(hashes) == 0 { - continue - } - - // Create a closure of the fetch and schedule in on a new thread - announceCopy := *f.fetching[hashes[0]] // Copy announce data for goroutine - go func(p string, hs []common.Hash, ann blockAnnounce) { - if f.fetchingHook != nil { - f.fetchingHook(hs) - } - fetchHeader := ann.fetchHeader // Use copied data - announcedAt := ann.time - - for _, h := range hs { - headerFetchMeter.Mark(1) - go func(h common.Hash) { - resCh := make(chan *eth.Response) - if fetchHeader == nil { - return - } // Check before using - req, err := fetchHeader(h, resCh) - if err != nil { - log.Debug("Failed to request header", "peer", p, "hash", h, "err", err) - return - } - defer req.Close() - - timeout := time.NewTimer(2 * fetchTimeout) // 2x leeway before dropping the peer - defer timeout.Stop() - - select { - case res := <-resCh: - if res == nil { - return - } // Channel closed - res.Done <- nil - headersResponse, ok := res.Res.(*eth.BlockHeadersRequest) - if !ok || headersResponse == nil { - return - } // Invalid response type - headers := *headersResponse - metrics.RecordPerItemDuration(blockHeaderItemDownloadTimer, res.Time, len(headers)) - f.FilterHeaders(p, headers, time.Now(), announcedAt) - - case <-timeout.C: - log.Debug("BlockFetcher: header fetch timed out, dropping peer", "peer", p, "hash", h, "timeout", 2*fetchTimeout) - f.dropPeer(p) - case <-f.quit: - return // Fetcher stopped - } - }(h) - } - }(peer, hashes, announceCopy) - } + deferred := f.scheduleHeaderFetches() // Schedule the next fetch if blocks are still pending - f.rescheduleFetch(fetchTimer) + f.rescheduleFetch(fetchTimer, deferred) case <-completeTimer.C: // At least one header's timer ran out, retrieve everything - request := make(map[string][]common.Hash) - - for hash, announces := range f.fetched { - // Pick a random peer to retrieve from, reset all others - announce := announces[rand.Intn(len(announces))] - - f.forgetHash(hash) // Removes from f.fetched - - // If the block still didn't arrive, queue for completion - // Also check if witness manager is already handling it - if !f.wm.isPending(hash) && f.getBlock(hash) == nil { - request[announce.origin] = append(request[announce.origin], hash) - f.completing[hash] = announce - } - } - // Send out all block body requests - for peer, hashes := range request { - log.Debug("Fetching scheduled bodies", "peer", peer, "list", hashes) - if len(hashes) == 0 { - continue - } - - announceCopy := *f.completing[hashes[0]] // Copy announce data for goroutine - go func(p string, hs []common.Hash, ann blockAnnounce) { - if f.completingHook != nil { - f.completingHook(hs) - } - fetchBodies := ann.fetchBodies // Use copied data - bodyFetchMeter.Mark(int64(len(hs))) - announcedAt := ann.time - - resCh := make(chan *eth.Response) - if fetchBodies == nil { - return - } // Check before using - req, err := fetchBodies(hs, resCh) - if err != nil { - log.Debug("Failed to request bodies", "peer", p, "hashes", hs, "err", err) - return - } - defer req.Close() - - timeout := time.NewTimer(2 * fetchTimeout) // 2x leeway before dropping the peer - defer timeout.Stop() - - select { - case res := <-resCh: - if res == nil { - return - } // Channel closed - res.Done <- nil - bodyResponse, ok := res.Res.(*eth.BlockBodiesResponse) - if !ok || bodyResponse == nil { - return - } // Invalid response type - // Ignoring withdrawals here, since the block fetcher is not used post-merge. - txs, uncles, _ := bodyResponse.Unpack() - metrics.RecordPerItemDuration(blockBodyItemDownloadTimer, res.Time, len(txs)) - f.FilterBodies(p, txs, uncles, time.Now(), announcedAt) - - case <-timeout.C: - log.Debug("BlockFetcher: body fetch timed out, dropping peer", "peer", p, "hashes", hs, "timeout", 2*fetchTimeout) - f.dropPeer(p) - case <-f.quit: - return // Fetcher stopped - } - }(peer, hashes, announceCopy) - } + deferred := f.scheduleBodyFetches() // Schedule the next fetch if blocks are still pending - f.rescheduleComplete(completeTimer) + f.rescheduleComplete(completeTimer, deferred) case filter := <-f.headerFilter: // Headers arrived from a remote peer. Extract those that were explicitly @@ -940,7 +786,7 @@ func (f *BlockFetcher) loop() { f.fetched[hash] = append(f.fetched[hash], announce) delete(f.fetching, hash) if len(f.fetched) == 1 { - f.rescheduleComplete(completeTimer) + f.rescheduleComplete(completeTimer, false) } } // Schedule the header for light fetcher import @@ -1067,7 +913,7 @@ func (f *BlockFetcher) loop() { } // rescheduleFetch resets the specified fetch timer to the next blockAnnounce timeout. -func (f *BlockFetcher) rescheduleFetch(fetch *time.Timer) { +func (f *BlockFetcher) rescheduleFetch(fetch *time.Timer, deferred bool) { // Short circuit if no blocks are announced if len(f.announced) == 0 { return @@ -1086,11 +932,24 @@ func (f *BlockFetcher) rescheduleFetch(fetch *time.Timer) { } } - fetch.Reset(arriveTimeout - time.Since(earliest)) + fetch.Reset(retryAfter(arriveTimeout-time.Since(earliest), deferred)) +} + +// retryAfter floors a timer delay for a pass that left announcements behind +// because admission was full. Without the floor the timer would re-fire at once +// on an announcement that is already past due and spin until capacity frees. +// Passes that placed everything keep their natural delay, including an +// immediate one. +func retryAfter(delay time.Duration, deferred bool) time.Duration { + if deferred && delay < fetchRetryInterval { + return fetchRetryInterval + } + + return delay } // rescheduleComplete resets the specified completion timer to the next fetch timeout. -func (f *BlockFetcher) rescheduleComplete(complete *time.Timer) { +func (f *BlockFetcher) rescheduleComplete(complete *time.Timer, deferred bool) { // Short circuit if no headers are fetched if len(f.fetched) == 0 { return @@ -1103,7 +962,7 @@ func (f *BlockFetcher) rescheduleComplete(complete *time.Timer) { } } - complete.Reset(gatherSlack - time.Since(earliest)) + complete.Reset(retryAfter(gatherSlack-time.Since(earliest), deferred)) } // enqueue schedules a new header or block import operation, if the component @@ -1363,32 +1222,12 @@ func (f *BlockFetcher) forgetHash(hash common.Hash) { f.announceChangeHook(hash, false) } } - // Remove any pending fetches and decrement the DOS counters - if announce := f.fetching[hash]; announce != nil { - f.announces[announce.origin]-- - if f.announces[announce.origin] <= 0 { - delete(f.announces, announce.origin) - } - delete(f.fetching, hash) - } - - // Remove any pending completion requests and decrement the DOS counters - for _, announce := range f.fetched[hash] { - f.announces[announce.origin]-- - if f.announces[announce.origin] <= 0 { - delete(f.announces, announce.origin) - } - } + // Scheduling already released the credit for anything past the announced + // stage, so these are metadata removals only. Debiting again would free + // quota a peer never gave back and let its pending records grow. + delete(f.fetching, hash) delete(f.fetched, hash) - - // Remove any pending completions and decrement the DOS counters - if announce := f.completing[hash]; announce != nil { - f.announces[announce.origin]-- - if f.announces[announce.origin] <= 0 { - delete(f.announces, announce.origin) - } - delete(f.completing, hash) - } + delete(f.completing, hash) // Notify witness manager to forget any pending state f.wm.forget(hash) diff --git a/eth/fetcher/block_fetcher_admission.go b/eth/fetcher/block_fetcher_admission.go new file mode 100644 index 0000000000..0cc71d3efe --- /dev/null +++ b/eth/fetcher/block_fetcher_admission.go @@ -0,0 +1,139 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package fetcher + +import "math/rand" + +// Request dispatch is synchronous and completes only once the message has been +// written to the peer, which happens before the worker creates its response +// timer. A peer that stops draining its connection therefore holds workers for +// longer than the response deadline suggests, so outstanding requests are +// bounded on admission rather than relying on that deadline. +const ( + // A peer legitimately needs one outstanding request per block it announced + // but has not served yet. At a 1.5s block time and a 5s fetch deadline that + // is under four, so 16 absorbs catch-up bursts while still bounding a peer + // that never drains. + maxPeerFetchRequests = 16 + + // Peers reconnecting under fresh identities each get their own per-peer + // budget while already dispatched workers from earlier identities may still + // be waiting, so an aggregate ceiling covers that overlap. 256 is five times + // the default maxpeers at one outstanding request each, which keeps honest + // peers clear of the ceiling in steady state. + maxTotalFetchRequests = 256 +) + +// acquireFetchSlot reserves capacity for one outstanding request to peer. It +// never blocks and never queues, so a refused caller simply leaves its +// announcements for another round or another announcer. Called from the loop, +// before the worker is spawned. +func (f *BlockFetcher) acquireFetchSlot(peer string) bool { + return f.acquireFetchSlotWithin(peer, maxPeerFetchRequests) +} + +// acquireFetchSlotWithin is acquireFetchSlot against a lowered per-peer limit, +// used to hold capacity back for a peer's pending body request. +func (f *BlockFetcher) acquireFetchSlotWithin(peer string, limit int) bool { + f.mu.Lock() + defer f.mu.Unlock() + + if f.fetchSlotsTotal >= maxTotalFetchRequests { + return false + } + if f.fetchSlots[peer] >= limit { + return false + } + f.fetchSlots[peer]++ + f.fetchSlotsTotal++ + + return true +} + +// releaseFetchSlot returns capacity taken by acquireFetchSlot. Workers defer it +// immediately so every exit releases exactly once. The per-peer entry +// disappears with its last outstanding request, while workers that are still +// running keep theirs counted across a same-id reconnect. +func (f *BlockFetcher) releaseFetchSlot(peer string) { + f.mu.Lock() + defer f.mu.Unlock() + + count, ok := f.fetchSlots[peer] + if !ok { + return + } + if count <= 1 { + delete(f.fetchSlots, peer) + } else { + f.fetchSlots[peer] = count - 1 + } + f.fetchSlotsTotal-- +} + +// acquireAnnouncer reserves a slot for one announcer of a hash, starting from a +// random offset so a single peer does not end up owning every announced hash. +// An announcer whose already fetched headers still need a body keeps one slot +// in reserve for that grouped body request, so header refills cannot starve it +// while headers and bodies still pipeline normally below the limit. +// +// Headers waiting for a body are also capped in aggregate. The per-peer reserve +// alone lets a fresh identity keep taking capacity that earlier identities' +// bodies then cannot get, so admission stops once that backlog reaches the +// node-wide ceiling. +// +// Returns nil when no announcer qualifies, leaving the alternatives in place +// instead of discarding them along with the unavailable peer. +func (f *BlockFetcher) acquireAnnouncer(announces []*blockAnnounce, awaitingBodies map[string]bool) *blockAnnounce { + if len(announces) == 0 || len(f.fetched) >= maxTotalFetchRequests { + return nil + } + offset := rand.Intn(len(announces)) + for i := range announces { + candidate := announces[(offset+i)%len(announces)] + limit := maxPeerFetchRequests + if awaitingBodies[candidate.origin] { + limit-- + } + if f.acquireFetchSlotWithin(candidate.origin, limit) { + return candidate + } + } + + return nil +} + +// acquireBodyAnnouncer is acquireAnnouncer for body retrieval, where one +// request carries a batch of hashes and so costs a single slot. Hashes landing +// on a peer already admitted this round ride that peer's slot. +func (f *BlockFetcher) acquireBodyAnnouncer(announces []*blockAnnounce, admitted map[string]bool) *blockAnnounce { + if len(announces) == 0 { + return nil + } + offset := rand.Intn(len(announces)) + for i := range announces { + candidate := announces[(offset+i)%len(announces)] + if admitted[candidate.origin] { + return candidate + } + if f.acquireFetchSlot(candidate.origin) { + admitted[candidate.origin] = true + return candidate + } + } + + return nil +} diff --git a/eth/fetcher/block_fetcher_admission_test.go b/eth/fetcher/block_fetcher_admission_test.go new file mode 100644 index 0000000000..07bb8c14e3 --- /dev/null +++ b/eth/fetcher/block_fetcher_admission_test.go @@ -0,0 +1,176 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package fetcher + +import ( + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" +) + +func TestFetchSlotPerPeerBound(t *testing.T) { + f := newBlockReleaseFetcher(t) + for i := 0; i < maxPeerFetchRequests; i++ { + if !f.acquireFetchSlot("peer") { + t.Fatalf("peer refused at %d of %d", i, maxPeerFetchRequests) + } + } + if f.acquireFetchSlot("peer") { + t.Fatal("peer admitted past its bound") + } + if !f.acquireFetchSlot("other") { + t.Fatal("a full peer consumed another peer's capacity") + } + f.releaseFetchSlot("peer") + if !f.acquireFetchSlot("peer") { + t.Fatal("released capacity was not reusable") + } +} + +func TestFetchSlotNodeWideBound(t *testing.T) { + f := newBlockReleaseFetcher(t) + admitted := 0 + // Identity churn: each new peer id gets a fresh per-peer budget, so only + // the node-wide ceiling bounds the aggregate. + for i := 0; i < maxTotalFetchRequests+maxPeerFetchRequests; i++ { + if f.acquireFetchSlot(string(rune('a' + i%26))) { + admitted++ + } + } + if admitted != maxTotalFetchRequests { + t.Fatalf("admitted %d requests, want the node-wide ceiling %d", admitted, maxTotalFetchRequests) + } + if f.fetchSlotsTotal != maxTotalFetchRequests { + t.Fatalf("total accounting = %d, want %d", f.fetchSlotsTotal, maxTotalFetchRequests) + } +} + +func TestFetchSlotAccountingClearsWhenIdle(t *testing.T) { + f := newBlockReleaseFetcher(t) + for _, peer := range []string{"a", "a", "b"} { + if !f.acquireFetchSlot(peer) { + t.Fatalf("peer %q refused", peer) + } + } + for _, peer := range []string{"a", "a", "b"} { + f.releaseFetchSlot(peer) + } + if len(f.fetchSlots) != 0 || f.fetchSlotsTotal != 0 { + t.Fatalf("bookkeeping survived the last worker: %v total=%d", f.fetchSlots, f.fetchSlotsTotal) + } + // A release without a matching acquire must not corrupt the totals a + // reconnecting peer's live workers still rely on. + f.releaseFetchSlot("a") + if !f.acquireFetchSlot("a") || f.fetchSlotsTotal != 1 { + t.Fatalf("unmatched release corrupted accounting: total=%d", f.fetchSlotsTotal) + } +} + +func TestAcquireAnnouncerPrefersAvailablePeer(t *testing.T) { + f := newBlockReleaseFetcher(t) + for i := 0; i < maxPeerFetchRequests; i++ { + f.acquireFetchSlot("full") + } + announces := []*blockAnnounce{ + {hash: common.Hash{1}, origin: "full"}, + {hash: common.Hash{1}, origin: "healthy"}, + } + for i := 0; i < 8; i++ { + announce := f.acquireAnnouncer(announces, nil) + if announce == nil || announce.origin != "healthy" { + t.Fatalf("round %d chose %v, want the peer with capacity", i, announce) + } + f.releaseFetchSlot(announce.origin) + } +} + +func TestAcquireAnnouncerRefusesWhenAllFull(t *testing.T) { + f := newBlockReleaseFetcher(t) + for _, peer := range []string{"a", "b"} { + for i := 0; i < maxPeerFetchRequests; i++ { + f.acquireFetchSlot(peer) + } + } + announces := []*blockAnnounce{ + {hash: common.Hash{1}, origin: "a"}, + {hash: common.Hash{1}, origin: "b"}, + } + if announce := f.acquireAnnouncer(announces, nil); announce != nil { + t.Fatalf("admitted %q with no capacity", announce.origin) + } + if f.acquireAnnouncer(nil, nil) != nil { + t.Fatal("admitted an empty announcer set") + } +} + +func TestAcquireAnnouncerReservesSlotForWaitingBodies(t *testing.T) { + f := newBlockReleaseFetcher(t) + announces := []*blockAnnounce{{hash: common.Hash{1}, origin: "waiting"}} + waiting := map[string]bool{"waiting": true} + + // Headers still pipeline alongside a pending body, up to the reserve. + for i := 0; i < maxPeerFetchRequests-1; i++ { + if f.acquireAnnouncer(announces, waiting) == nil { + t.Fatalf("header %d refused below the reserved limit", i) + } + } + if f.acquireAnnouncer(announces, waiting) != nil { + t.Fatal("header request consumed the slot reserved for the waiting body") + } + if !f.acquireFetchSlot("waiting") { + t.Fatal("the reserved slot was not available to the body request") + } + if f.acquireAnnouncer(announces, nil) != nil { + t.Fatal("peer admitted past its outstanding-request bound") + } +} + +func TestAcquireBodyAnnouncerChargesOnePerBatch(t *testing.T) { + f := newBlockReleaseFetcher(t) + admitted := make(map[string]bool) + announces := []*blockAnnounce{{hash: common.Hash{1}, origin: "peer"}} + for i := 0; i < maxPeerFetchRequests+4; i++ { + if f.acquireBodyAnnouncer(announces, admitted) == nil { + t.Fatalf("hash %d refused although the batch already holds a slot", i) + } + } + if f.fetchSlots["peer"] != 1 || f.fetchSlotsTotal != 1 { + t.Fatalf("batch took %d slots, want 1", f.fetchSlots["peer"]) + } +} + +func TestRetryAfterFloorsOnlyDeferredPasses(t *testing.T) { + for _, tc := range []struct { + name string + delay time.Duration + deferred bool + want time.Duration + }{ + {"placed pass keeps an immediate retry", -time.Second, false, -time.Second}, + {"placed pass keeps its delay", time.Second, false, time.Second}, + {"deferred pass floors an expired delay", -time.Second, true, fetchRetryInterval}, + {"deferred pass floors a short delay", fetchRetryInterval - 1, true, fetchRetryInterval}, + {"deferred pass keeps a longer delay", time.Second, true, time.Second}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := retryAfter(tc.delay, tc.deferred); got != tc.want { + t.Fatalf("retryAfter(%v, %t) = %v, want %v", tc.delay, tc.deferred, got, tc.want) + } + }) + } +} diff --git a/eth/fetcher/block_fetcher_body_request_test.go b/eth/fetcher/block_fetcher_body_request_test.go new file mode 100644 index 0000000000..4ed3e2865e --- /dev/null +++ b/eth/fetcher/block_fetcher_body_request_test.go @@ -0,0 +1,192 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package fetcher + +import ( + "errors" + "slices" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/eth/protocols/eth" +) + +func TestFetchSlotBodyReleasedOnEveryExit(t *testing.T) { + for _, exit := range []string{"nil fetch", "dispatch error", "nil response", "wrong response type", "typed nil response", "shutdown"} { + t.Run(exit, func(t *testing.T) { + f := newBlockReleaseFetcher(t) + first := addBlockReleaseRequest(f, false, 1, "peer") + second := addBlockReleaseRequest(f, false, 2, "peer") + other := addBlockReleaseRequest(f, false, 3, "peer") + header := addBlockReleaseRequest(f, true, 1, "peer") + owned := []*blockAnnounce{first, second} + request := ð.Request{Cancel: make(chan struct{})} + response := bodyExitResponse(exit) + fetch := bodyExitFetcher(f, exit, request, response) + if exit == "shutdown" { + f.Stop() + } + done := startBodyRequestTest(t, f, bodyRequest{hashes: []common.Hash{first.hash, second.hash}, announces: owned, fetch: fetch}) + if exit != "shutdown" { + select { + case release := <-f.fetchRelease: + if release.headers || !slices.Equal(release.announces, owned) { + t.Fatalf("body worker returned the wrong ownership: %+v", release) + } + f.releaseFetchOwnership(release) + case <-time.After(time.Second): + t.Fatal("body worker did not return its batch ownership") + } + } + finishBodyRequestTest(t, f, done) + if exit != "shutdown" && (f.completing[first.hash] != nil || f.completing[second.hash] != nil) { + t.Fatal("completed body request retained its missing bodies") + } + if f.completing[other.hash] != other || f.fetching[header.hash] != header { + t.Fatal("body exit removed another request's ownership") + } + assertBodyRequestCompletion(t, exit, request, response) + }) + } +} + +func bodyExitResponse(exit string) *eth.Response { + if exit == "nil response" { + return nil + } + var payload interface{} = new(eth.BlockHeadersRequest) + if exit == "typed nil response" { + payload = (*eth.BlockBodiesResponse)(nil) + } + return ð.Response{Res: payload, Done: make(chan error, 1)} +} + +func bodyExitFetcher(f *BlockFetcher, exit string, request *eth.Request, response *eth.Response) bodyRequesterFn { + if exit == "nil fetch" { + return nil + } + return func(_ []common.Hash, chanRes chan *eth.Response) (*eth.Request, error) { + if exit == "dispatch error" { + return nil, errors.New("body dispatch failed") + } + if exit != "shutdown" { + go func() { + select { + case chanRes <- response: + case <-f.quit: + } + }() + } + return request, nil + } +} + +func startBodyRequestTest(t *testing.T, f *BlockFetcher, req bodyRequest) <-chan struct{} { + t.Helper() + if !f.acquireFetchSlot("peer") { + t.Fatal("could not reserve body request capacity") + } + done := make(chan struct{}) + go func() { + defer close(done) + f.runBodyRequest("peer", req) + }() + return done +} + +func finishBodyRequestTest(t *testing.T, f *BlockFetcher, done <-chan struct{}) { + t.Helper() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("body worker did not finish") + } + f.mu.RLock() + defer f.mu.RUnlock() + if len(f.fetchSlots) != 0 || f.fetchSlotsTotal != 0 { + t.Fatalf("body worker leaked capacity: peers=%v total=%d", f.fetchSlots, f.fetchSlotsTotal) + } +} + +func assertBodyRequestCompletion(t *testing.T, exit string, request *eth.Request, response *eth.Response) { + t.Helper() + if exit == "nil fetch" || exit == "dispatch error" { + return + } + select { + case <-request.Cancel: + default: + t.Error("body worker did not close its dispatched request") + } + if response != nil && exit != "shutdown" { + select { + case err := <-response.Done: + if err != nil { + t.Errorf("body response acknowledgement: %v", err) + } + default: + t.Error("body response was not acknowledged") + } + } +} + +func TestFetchSlotBodyResponseReleasesMissing(t *testing.T) { + hashes, blocks := makeChain(2, 91, genesis) + for _, partial := range []bool{false, true} { + name := "empty" + if partial { + name = "partial" + } + t.Run(name, func(t *testing.T) { + f := newBlockReleaseFetcher(t) + f.getBlock = func(common.Hash) *types.Block { return nil } + f.chainHeight = func() uint64 { return 0 } + var owned []*blockAnnounce + for _, hash := range hashes[:2] { + announce := &blockAnnounce{hash: hash, header: blocks[hash].Header(), origin: "peer", time: time.Now()} + f.completing[hash] = announce + owned = append(owned, announce) + } + var bodies eth.BlockBodiesResponse + if partial { + block := blocks[hashes[0]] + bodies = append(bodies, ð.BlockBody{Transactions: block.Transactions(), Uncles: block.Uncles()}) + } + request := ð.Request{Cancel: make(chan struct{})} + response := ð.Response{Res: &bodies, Done: make(chan error, 1)} + fetch := bodyExitFetcher(f, name, request, response) + f.Start() + done := startBodyRequestTest(t, f, bodyRequest{hashes: hashes[:2], announces: owned, fetch: fetch}) + finishBodyRequestTest(t, f, done) + assertBodyRequestCompletion(t, name, request, response) + resume := pauseBlockTimeoutLoop(t, f) + defer resume() + if f.completing[hashes[1]] != nil { + t.Error("missing body remained owned after the request ended") + } + if partial { + if f.queued[hashes[0]] == nil || f.completing[hashes[0]] != owned[0] { + t.Error("partial response lost its delivered body before import") + } + } else if len(f.completing) != 0 || len(f.queued) != 0 { + t.Error("empty response retained body ownership or queued a block") + } + }) + } +} diff --git a/eth/fetcher/block_fetcher_bounds_test.go b/eth/fetcher/block_fetcher_bounds_test.go new file mode 100644 index 0000000000..6a0f0f5256 --- /dev/null +++ b/eth/fetcher/block_fetcher_bounds_test.go @@ -0,0 +1,315 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package fetcher + +import ( + "errors" + "runtime" + "sync/atomic" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/eth/protocols/eth" +) + +// stalledDispatch models a peer that stops draining its connection: the request +// callback blocks before it can hand back a Request, which is where the real +// dispatcher blocks and the reason the response deadline cannot bound workers. +type stalledDispatch struct { + entered chan struct{} + release chan struct{} + inside atomic.Int32 + peak atomic.Int32 + calls atomic.Int32 +} + +func newStalledDispatch(capacity int) *stalledDispatch { + return &stalledDispatch{ + entered: make(chan struct{}, capacity), + release: make(chan struct{}), + } +} + +func (s *stalledDispatch) headerFetcher() headerRequesterFn { + return func(common.Hash, chan *eth.Response) (*eth.Request, error) { + s.enter() + defer s.inside.Add(-1) + <-s.release + + return nil, errors.New("peer stalled") + } +} + +func (s *stalledDispatch) enter() { + s.calls.Add(1) + if depth := s.inside.Add(1); depth > s.peak.Load() { + s.peak.Store(depth) + } + select { + case s.entered <- struct{}{}: + default: + } +} + +func (s *stalledDispatch) await(t *testing.T, count int) { + t.Helper() + for i := 0; i < count; i++ { + select { + case <-s.entered: + case <-time.After(5 * time.Second): + t.Fatalf("only %d of %d requests reached dispatch", i, count) + } + } +} + +func announceSynthetic(t *testing.T, f *BlockFetcher, peer string, count int, fetch headerRequesterFn) { + t.Helper() + for i := 0; i < count; i++ { + hash := common.Hash{} + hash[0], hash[1] = byte(i), byte(i>>8) + hash[31] = peer[0] + // Number 1 keeps every hash inside the queue distance window. + if err := f.Notify(peer, hash, 1, time.Now().Add(-arriveTimeout), fetch, nil, nil); err != nil { + t.Fatal(err) + } + } +} + +func TestFetchWorkersBoundedWhenDispatchBlocks(t *testing.T) { + tester := newTester(false) + defer tester.fetcher.Stop() + + stalled := newStalledDispatch(maxPeerFetchRequests) + defer close(stalled.release) + + announceSynthetic(t, tester.fetcher, "stalled", maxPeerFetchRequests+24, stalled.headerFetcher()) + stalled.await(t, maxPeerFetchRequests) + + // Give the loop several scheduling passes to try the surplus announcements. + time.Sleep(10 * fetchRetryInterval) + + if peak := stalled.peak.Load(); peak > maxPeerFetchRequests { + t.Fatalf("%d concurrent requests reached dispatch, want at most %d", peak, maxPeerFetchRequests) + } + if calls := stalled.calls.Load(); calls > maxPeerFetchRequests { + t.Fatalf("%d dispatch attempts, want at most %d: refused announcements were redispatched", calls, maxPeerFetchRequests) + } + + tester.fetcher.mu.RLock() + held, total := tester.fetcher.fetchSlots["stalled"], tester.fetcher.fetchSlotsTotal + tester.fetcher.mu.RUnlock() + + if held != maxPeerFetchRequests || total != maxPeerFetchRequests { + t.Fatalf("slots held=%d total=%d, want %d", held, total, maxPeerFetchRequests) + } +} + +func TestFetchAdmissionKeepsHealthyPeerProgressing(t *testing.T) { + hashes, blocks := makeChain(1, 61, genesis) + block := blocks[hashes[0]] + + tester := newTester(false) + defer tester.fetcher.Stop() + + imported := make(chan struct{}, 1) + tester.fetcher.importedHook = func(*types.Header, *types.Block) { imported <- struct{}{} } + + stalled := newStalledDispatch(maxPeerFetchRequests) + defer close(stalled.release) + + announceSynthetic(t, tester.fetcher, "stalled", maxPeerFetchRequests+8, stalled.headerFetcher()) + stalled.await(t, maxPeerFetchRequests) + + err := tester.fetcher.Notify("healthy", block.Hash(), block.NumberU64(), time.Now(), + tester.makeHeaderFetcher("healthy", blocks, -gatherSlack), + tester.makeBodyFetcher("healthy", blocks, 0), nil) + if err != nil { + t.Fatal(err) + } + select { + case <-imported: + case <-time.After(5 * time.Second): + t.Fatal("a saturated peer blocked an unrelated healthy peer") + } +} + +func TestFetchAdmissionKeepsAlternativeAnnouncers(t *testing.T) { + hashes, blocks := makeChain(1, 62, genesis) + block := blocks[hashes[0]] + + tester := newTester(false) + defer tester.fetcher.Stop() + f := tester.fetcher + + imported := make(chan struct{}, 1) + f.importedHook = func(*types.Header, *types.Block) { imported <- struct{}{} } + + stalled := newStalledDispatch(maxPeerFetchRequests) + defer close(stalled.release) + + announceSynthetic(t, f, "stalled", maxPeerFetchRequests, stalled.headerFetcher()) + stalled.await(t, maxPeerFetchRequests) + + // The full peer announces first, so a refusal that discarded the whole + // announcement list would take the healthy alternative down with it. + if err := f.Notify("stalled", block.Hash(), block.NumberU64(), time.Now(), stalled.headerFetcher(), nil, nil); err != nil { + t.Fatal(err) + } + err := f.Notify("healthy", block.Hash(), block.NumberU64(), time.Now(), + tester.makeHeaderFetcher("healthy", blocks, -gatherSlack), + tester.makeBodyFetcher("healthy", blocks, 0), nil) + if err != nil { + t.Fatal(err) + } + select { + case <-imported: + case <-time.After(5 * time.Second): + t.Fatal("a full announcer prevented the healthy announcer from supplying the block") + } +} + +func TestFetchAdmissionDoesNotBusyLoop(t *testing.T) { + tester := newTester(false) + defer tester.fetcher.Stop() + + stalled := newStalledDispatch(maxPeerFetchRequests) + defer close(stalled.release) + + announceSynthetic(t, tester.fetcher, "stalled", maxPeerFetchRequests+32, stalled.headerFetcher()) + stalled.await(t, maxPeerFetchRequests) + + settled := runtime.NumGoroutine() + time.Sleep(15 * fetchRetryInterval) + + if grown := runtime.NumGoroutine() - settled; grown > maxPeerFetchRequests { + t.Fatalf("goroutines grew by %d while admission was full", grown) + } + // A spinning timer would keep redispatching the announcements it refused. + if calls := stalled.calls.Load(); calls != maxPeerFetchRequests { + t.Fatalf("%d dispatch attempts over the window, want %d", calls, maxPeerFetchRequests) + } +} + +func TestFetchSlotReleasedOnEveryExit(t *testing.T) { + for _, tc := range []struct { + name string + exit string + release bool + }{ + {"nil fetch function", "nil", true}, + {"dispatch error", "error", true}, + {"nil response", "nilres", true}, + {"wrong response type", "badtype", true}, + {"shutdown", "quit", false}, + } { + t.Run(tc.name, func(t *testing.T) { + testFetchSlotReleasedOnExit(t, tc.exit, tc.release) + }) + } +} + +func testFetchSlotReleasedOnExit(t *testing.T, exit string, wantRelease bool) { + f := newBlockReleaseFetcher(t) + released := drainFetchReleases(f) + + announce := &blockAnnounce{hash: common.Hash{1}, origin: "peer"} + f.fetching[announce.hash] = announce + if !f.acquireFetchSlot("peer") { + t.Fatal("could not take a slot") + } + req := headerRequest{hash: announce.hash, announce: announce, fetch: exitFetcher(t, f, exit)} + if exit == "quit" { + f.Stop() + } + f.runHeaderRequest("peer", req) + + f.mu.RLock() + leaked, total := len(f.fetchSlots), f.fetchSlotsTotal + f.mu.RUnlock() + + if leaked != 0 || total != 0 { + t.Fatalf("slot leaked on the %q exit: %d peers total=%d", exit, leaked, total) + } + assertRelease(t, released, announce, wantRelease) +} + +func assertRelease(t *testing.T, released <-chan blockFetchRelease, announce *blockAnnounce, want bool) { + t.Helper() + if !want { + select { + case release := <-released: + t.Fatalf("unexpected release notification: %+v", release) + case <-time.After(100 * time.Millisecond): + } + + return + } + select { + case release := <-released: + if !release.headers || len(release.announces) != 1 || release.announces[0] != announce { + t.Fatalf("release notification = %+v", release) + } + case <-time.After(5 * time.Second): + t.Fatal("exit did not return ownership of its announcement") + } +} + +func exitFetcher(t *testing.T, f *BlockFetcher, exit string) headerRequesterFn { + t.Helper() + if exit == "nil" { + return nil + } + + return func(_ common.Hash, sink chan *eth.Response) (*eth.Request, error) { + if exit == "error" { + return nil, errors.New("dispatch failed") + } + request := ð.Request{Peer: "peer"} + go func() { + switch exit { + case "nilres": + sink <- nil + case "badtype": + sink <- ð.Response{Req: request, Res: nil, Done: make(chan error, 1)} + case "quit": + <-f.quit + } + }() + + return request, nil + } +} + +// drainFetchReleases forwards release notifications so workers under test do +// not block on the loop, which is not running here. +func drainFetchReleases(f *BlockFetcher) <-chan blockFetchRelease { + released := make(chan blockFetchRelease, 8) + go func() { + for { + select { + case release := <-f.fetchRelease: + released <- release + case <-f.quit: + return + } + } + }() + + return released +} diff --git a/eth/fetcher/block_fetcher_pending.go b/eth/fetcher/block_fetcher_pending.go new file mode 100644 index 0000000000..61459f3538 --- /dev/null +++ b/eth/fetcher/block_fetcher_pending.go @@ -0,0 +1,44 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package fetcher + +// maxPendingAnnounceOrigins caps how many origins may hold pending +// announcements at once. hashLimit bounds each origin on its own, but nothing +// bounded the number of origins, and peers reconnecting under fresh identities +// keep acquiring new quota. Reusing maxTotalFetchRequests pairs the two +// node-wide bounds; together with hashLimit it caps retained announcement +// records, duplicates of one hash across origins included. +// +// This bounds metadata. It is not a fairness guarantee, and refusing a new +// origin at the cap says nothing about whether that peer is misbehaving. +const maxPendingAnnounceOrigins = maxTotalFetchRequests + +// admitAnnouncement reports the credit a pending announcement from origin would +// take, and whether there is room for it. An origin that already holds pending +// announcements keeps using its own per-origin quota; a new one is declined +// until an existing origin's records are actually removed. +// +// Runs on the fetcher loop. +func (f *BlockFetcher) admitAnnouncement(origin string) (int, bool) { + count, known := f.announces[origin] + if !known && len(f.announces) >= maxPendingAnnounceOrigins { + return 0, false + } + count++ + + return count, count <= hashLimit +} diff --git a/eth/fetcher/block_fetcher_pending_test.go b/eth/fetcher/block_fetcher_pending_test.go new file mode 100644 index 0000000000..f41842ba25 --- /dev/null +++ b/eth/fetcher/block_fetcher_pending_test.go @@ -0,0 +1,319 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package fetcher + +import ( + "encoding/binary" + "fmt" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/eth/protocols/eth" +) + +// pendingCredits counts the announcement records each origin holds in +// f.announced, which is exactly what f.announces must mirror. +func pendingCredits(f *BlockFetcher) map[string]int { + credits := make(map[string]int) + for _, announces := range f.announced { + for _, announce := range announces { + credits[announce.origin]++ + } + } + + return credits +} + +func assertPendingAccounting(t *testing.T, f *BlockFetcher) { + t.Helper() + want := pendingCredits(f) + if len(f.announces) != len(want) { + t.Fatalf("announces tracks %d origins, pending records span %d", len(f.announces), len(want)) + } + for origin, count := range want { + if f.announces[origin] != count { + t.Fatalf("origin %q credited %d, holds %d pending records", origin, f.announces[origin], count) + } + } +} + +func pendingAnnounce(origin string, hash common.Hash) *blockAnnounce { + return &blockAnnounce{hash: hash, number: 1, origin: origin, time: time.Now()} +} + +// seedPending registers count announcements for origin the way Notify does. +func seedPending(f *BlockFetcher, origin string, count int, first uint64) []common.Hash { + hashes := make([]common.Hash, count) + for i := range hashes { + binary.BigEndian.PutUint64(hashes[i][:8], first+uint64(i)) + f.announced[hashes[i]] = append(f.announced[hashes[i]], pendingAnnounce(origin, hashes[i])) + f.announces[origin]++ + } + + return hashes +} + +func TestForgetHashDebitsOnlyPendingAnnouncements(t *testing.T) { + for _, stage := range []string{"fetching", "fetched", "completing"} { + t.Run(stage, func(t *testing.T) { + f := newBlockReleaseFetcher(t) + kept := seedPending(f, "peer", 4, 100) + + var hash common.Hash + hash[0] = 0xaa + announce := pendingAnnounce("peer", hash) + switch stage { + case "fetching": + f.fetching[hash] = announce + case "fetched": + f.fetched[hash] = []*blockAnnounce{announce} + case "completing": + f.completing[hash] = announce + } + + f.forgetHash(hash) + + if f.announces["peer"] != len(kept) { + t.Fatalf("clearing a %s entry debited pending credits: %d, want %d", stage, f.announces["peer"], len(kept)) + } + assertPendingAccounting(t, f) + if f.fetching[hash] != nil || f.fetched[hash] != nil || f.completing[hash] != nil { + t.Fatalf("%s metadata survived forgetHash", stage) + } + }) + } +} + +func TestForgetHashDebitsTheAnnouncedList(t *testing.T) { + f := newBlockReleaseFetcher(t) + hashes := seedPending(f, "peer", 3, 200) + f.announced[hashes[0]] = append(f.announced[hashes[0]], pendingAnnounce("other", hashes[0])) + f.announces["other"]++ + + f.forgetHash(hashes[0]) + + if f.announces["peer"] != 2 { + t.Fatalf("peer credited %d after forgetting one of its three announcements, want 2", f.announces["peer"]) + } + if _, ok := f.announces["other"]; ok { + t.Fatal("an origin with no remaining announcements kept a counter") + } + assertPendingAccounting(t, f) +} + +func TestExpirySweepPreservesPendingAccounting(t *testing.T) { + f := newBlockReleaseFetcher(t) + kept := seedPending(f, "peer", 6, 300) + + // Headers already admitted carry no pending credit; admitHeaderRequest + // released it, so the sweep must not take a second one. + admitted := f.admitHeaderRequest(kept[0], f.announced[kept[0]][0]) + admitted.announce.time = time.Now().Add(-fetchTimeout - time.Second) + + expired := f.expiredFetches() + if len(expired) != 1 { + t.Fatalf("expiry sweep found %d requests, want 1", len(expired)) + } + for _, hash := range expired { + f.forgetHash(hash) + } + if f.announces["peer"] != len(kept)-1 { + t.Fatalf("peer credited %d after expiry, want %d", f.announces["peer"], len(kept)-1) + } + assertPendingAccounting(t, f) +} + +func TestAdmitAnnouncementOriginCap(t *testing.T) { + for _, tc := range []struct { + name string + origin string + want bool + }{ + {"existing origin continues at the cap", "origin-0", true}, + {"fresh origin declined at the cap", "newcomer", false}, + } { + t.Run(tc.name, func(t *testing.T) { + f := newBlockReleaseFetcher(t) + for i := 0; i < maxPendingAnnounceOrigins; i++ { + seedPending(f, fmt.Sprintf("origin-%d", i), 1, uint64(i)*1000) + } + if _, ok := f.admitAnnouncement(tc.origin); ok != tc.want { + t.Fatalf("admitAnnouncement(%q) = %t, want %t", tc.origin, ok, tc.want) + } + }) + } +} + +func TestAdmitAnnouncementRecoversSpaceAfterRemoval(t *testing.T) { + f := newBlockReleaseFetcher(t) + var hashes []common.Hash + for i := 0; i < maxPendingAnnounceOrigins; i++ { + hashes = append(hashes, seedPending(f, fmt.Sprintf("origin-%d", i), 1, uint64(i)*1000)...) + } + if _, ok := f.admitAnnouncement("newcomer"); ok { + t.Fatal("a fresh origin was admitted at the origin cap") + } + + // Only an actual removal of pending records frees an origin slot. + f.forgetHash(hashes[0]) + + if _, ok := f.announces["origin-0"]; ok { + t.Fatal("a drained origin kept its counter") + } + if _, ok := f.admitAnnouncement("newcomer"); !ok { + t.Fatal("space was not recovered after pending records were removed") + } + assertPendingAccounting(t, f) +} + +func TestAdmitAnnouncementBoundsDuplicateHashOrigins(t *testing.T) { + f := newBlockReleaseFetcher(t) + var shared common.Hash + shared[0] = 0x7f + + // Every origin announces the same hash, so len(f.announced) stays at one + // while the retained record count tracks the number of origins. + admitted := 0 + for i := 0; i < maxPendingAnnounceOrigins*2; i++ { + origin := fmt.Sprintf("origin-%d", i) + count, ok := f.admitAnnouncement(origin) + if !ok { + continue + } + f.announces[origin] = count + f.announced[shared] = append(f.announced[shared], pendingAnnounce(origin, shared)) + admitted++ + } + if admitted != maxPendingAnnounceOrigins { + t.Fatalf("admitted %d origins for one hash, want the origin cap %d", admitted, maxPendingAnnounceOrigins) + } + if len(f.announced) != 1 { + t.Fatalf("expected a single announced hash, got %d", len(f.announced)) + } + assertPendingAccounting(t, f) +} + +func TestAdmitAnnouncementKeepsPerOriginLimit(t *testing.T) { + f := newBlockReleaseFetcher(t) + seedPending(f, "peer", hashLimit, 500) + + if _, ok := f.admitAnnouncement("peer"); ok { + t.Fatalf("origin admitted past the per-origin limit of %d", hashLimit) + } + if _, ok := f.admitAnnouncement("fresh"); !ok { + t.Fatal("a full origin blocked an unrelated origin below the origin cap") + } +} + +func TestPendingAnnouncementsStayBoundedAcrossExpiryCycles(t *testing.T) { + tester := newTester(false) + defer tester.fetcher.Stop() + f := tester.fetcher + + sinks := make(chan chan *eth.Response, 4*maxPeerFetchRequests) + fetch := func(_ common.Hash, sink chan *eth.Response) (*eth.Request, error) { + sinks <- sink + + return ð.Request{Cancel: make(chan struct{})}, nil + } + next := uint64(0) + announce := func(count int) { + for i := 0; i < count; i++ { + next++ + var hash common.Hash + binary.BigEndian.PutUint64(hash[:8], next) + if err := f.Notify("slow", hash, 1, time.Now().Add(-arriveTimeout), fetch, nil, nil); err != nil { + t.Fatal(err) + } + } + } + announce(maxPeerFetchRequests) + active := awaitSinks(t, sinks, maxPeerFetchRequests) + announce(hashLimit) + + for cycle := 0; cycle < 3; cycle++ { + expirePendingFetches(t, f) + announce(maxPeerFetchRequests) + active = replyEmptyHeaders(t, sinks, active) + announce(maxPeerFetchRequests) + } + + resume := pauseBlockTimeoutLoop(t, f) + defer resume() + f.mu.RLock() + defer f.mu.RUnlock() + + if queued := len(f.announced); queued > hashLimit { + t.Fatalf("pending metadata grew to %d hashes, limit %d (credited %d)", queued, hashLimit, f.announces["slow"]) + } + assertPendingAccounting(t, f) +} + +func awaitSinks(t *testing.T, sinks <-chan chan *eth.Response, count int) []chan *eth.Response { + t.Helper() + active := make([]chan *eth.Response, count) + for i := range active { + select { + case active[i] = <-sinks: + case <-time.After(5 * time.Second): + t.Fatalf("only %d of %d header requests started", i, count) + } + } + + return active +} + +// expirePendingFetches ages the outstanding requests past the fetch deadline so +// the loop's own sweep runs, without waiting out fetchTimeout. +func expirePendingFetches(t *testing.T, f *BlockFetcher) { + t.Helper() + resume := pauseBlockTimeoutLoop(t, f) + f.mu.Lock() + if len(f.fetching) != maxPeerFetchRequests { + f.mu.Unlock() + resume() + t.Fatalf("outstanding requests = %d, want %d", len(f.fetching), maxPeerFetchRequests) + } + for _, owned := range f.fetching { + owned.time = time.Now().Add(-fetchTimeout - time.Second) + } + f.mu.Unlock() + resume() + + resume = pauseBlockTimeoutLoop(t, f) + outstanding := len(f.fetching) + resume() + + if outstanding != 0 { + t.Fatalf("expiry sweep left %d outstanding requests", outstanding) + } +} + +func replyEmptyHeaders(t *testing.T, sinks <-chan chan *eth.Response, active []chan *eth.Response) []chan *eth.Response { + t.Helper() + empty := eth.BlockHeadersRequest{} + for _, sink := range active { + select { + case sink <- ð.Response{Res: &empty, Done: make(chan error, 1)}: + case <-time.After(5 * time.Second): + t.Fatal("worker did not accept a late empty response") + } + } + + return awaitSinks(t, sinks, len(active)) +} diff --git a/eth/fetcher/block_fetcher_release.go b/eth/fetcher/block_fetcher_release.go new file mode 100644 index 0000000000..912dc23a77 --- /dev/null +++ b/eth/fetcher/block_fetcher_release.go @@ -0,0 +1,74 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package fetcher + +import "github.com/ethereum/go-ethereum/common" + +// blockFetchRelease hands back announcements whose request ended without the +// block reaching a later stage, covering timeouts as well as dispatch failures +// and responses that did not carry the requested data. +type blockFetchRelease struct { + headers bool + announces []*blockAnnounce +} + +// fetchAnnouncements snapshots the announcements a scheduled request owns, so +// the worker can identify them later even though the maps keep changing. +func fetchAnnouncements(pending map[common.Hash]*blockAnnounce, hashes []common.Hash) []*blockAnnounce { + announces := make([]*blockAnnounce, 0, len(hashes)) + for _, hash := range hashes { + if announce := pending[hash]; announce != nil { + announces = append(announces, announce) + } + } + + return announces +} + +func (f *BlockFetcher) notifyFetchRelease(headers bool, announces []*blockAnnounce) { + if len(announces) == 0 { + return + } + select { + case f.fetchRelease <- blockFetchRelease{headers: headers, announces: announces}: + case <-f.quit: + } +} + +// releaseFetchOwnership runs on the fetcher loop so a finished request cannot +// erase a newer attempt for the same hash, nor a block that has already +// advanced to the import queue or to witness retrieval. +func (f *BlockFetcher) releaseFetchOwnership(release blockFetchRelease) { + f.mu.Lock() + defer f.mu.Unlock() + + pending := f.completing + if release.headers { + pending = f.fetching + } + for _, announce := range release.announces { + if announce == nil || pending[announce.hash] != announce { + continue + } + if f.queued[announce.hash] != nil || f.wm.isPending(announce.hash) { + continue + } + // Scheduling already released this announcement's count; the counts + // still held belong to other announcements from the same peer. + delete(pending, announce.hash) + } +} diff --git a/eth/fetcher/block_fetcher_release_test.go b/eth/fetcher/block_fetcher_release_test.go new file mode 100644 index 0000000000..21cb35d4fe --- /dev/null +++ b/eth/fetcher/block_fetcher_release_test.go @@ -0,0 +1,321 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package fetcher + +import ( + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" +) + +func newBlockReleaseFetcher(t *testing.T) *BlockFetcher { + t.Helper() + f := NewBlockFetcher(false, nil, nil, nil, nil, nil, nil, nil, nil, func(peer string) { + t.Errorf("release dropped peer %q", peer) + }, false, false, 0, nil, nil) + t.Cleanup(func() { + select { + case <-f.quit: + default: + f.Stop() + } + f.wm.stopAndDrainTimer() + }) + + return f +} + +func pendingBlockRelease(f *BlockFetcher, headers bool) map[common.Hash]*blockAnnounce { + if headers { + return f.fetching + } + + return f.completing +} + +func addBlockReleaseRequest(f *BlockFetcher, headers bool, hash byte, peer string) *blockAnnounce { + announce := &blockAnnounce{hash: common.Hash{hash}, origin: peer} + pendingBlockRelease(f, headers)[announce.hash] = announce + + return announce +} + +func TestBlockFetchReleaseBatchCleanup(t *testing.T) { + for _, tc := range []struct { + name string + headers bool + }{{"headers", true}, {"bodies", false}} { + t.Run(tc.name, func(t *testing.T) { + testBlockFetchReleaseBatchCleanup(t, tc.headers) + }) + } +} + +func testBlockFetchReleaseBatchCleanup(t *testing.T, headers bool) { + f := newBlockReleaseFetcher(t) + first := addBlockReleaseRequest(f, headers, 1, "shared") + second := addBlockReleaseRequest(f, headers, 2, "expired") + samePeer := addBlockReleaseRequest(f, headers, 3, "shared") + otherPeer := addBlockReleaseRequest(f, headers, 4, "other") + release := blockFetchRelease{headers: headers, announces: []*blockAnnounce{first, second}} + f.releaseFetchOwnership(release) + f.releaseFetchOwnership(release) + + pending := pendingBlockRelease(f, headers) + if len(pending) != 2 || pending[first.hash] != nil || pending[second.hash] != nil { + t.Fatalf("released requests remain: %v", pending) + } + for _, announce := range []*blockAnnounce{samePeer, otherPeer} { + if pending[announce.hash] != announce || f.announces[announce.origin] != 0 { + t.Errorf("unrelated request or accounting changed for %q", announce.origin) + } + } + if len(f.announces) != 0 { + t.Errorf("release created announcement counters: %v", f.announces) + } +} + +func TestBlockFetchReleasePreservesReplacement(t *testing.T) { + for _, tc := range []struct { + name string + headers bool + }{{"headers", true}, {"bodies", false}} { + t.Run(tc.name, func(t *testing.T) { + f := newBlockReleaseFetcher(t) + original := addBlockReleaseRequest(f, tc.headers, 1, "peer") + replacement := &blockAnnounce{hash: original.hash, origin: original.origin} + pending := pendingBlockRelease(f, tc.headers) + pending[original.hash] = replacement + + f.releaseFetchOwnership(blockFetchRelease{headers: tc.headers, announces: []*blockAnnounce{original}}) + if pending[original.hash] != replacement || f.announces[original.origin] != 0 { + t.Fatal("late release removed the replacement request or its accounting") + } + }) + } +} + +func TestBlockFetchReleasePreservesLaterStage(t *testing.T) { + for _, stage := range []string{"fetched", "completing"} { + t.Run(stage, func(t *testing.T) { + testBlockFetchReleasePreservesLaterStage(t, stage) + }) + } +} + +func testBlockFetchReleasePreservesLaterStage(t *testing.T, stage string) { + f := newBlockReleaseFetcher(t) + announce := addBlockReleaseRequest(f, true, 1, "peer") + delete(f.fetching, announce.hash) + if stage == "fetched" { + f.fetched[announce.hash] = []*blockAnnounce{announce} + } else { + f.completing[announce.hash] = announce + } + + f.releaseFetchOwnership(blockFetchRelease{headers: true, announces: []*blockAnnounce{announce}}) + current := f.completing[announce.hash] + if stage == "fetched" { + if len(f.fetched[announce.hash]) != 1 { + t.Fatal("late release removed the fetched header") + } + current = f.fetched[announce.hash][0] + } + if current != announce || f.announces[announce.origin] != 0 { + t.Fatal("late release changed the later stage or its accounting") + } +} + +func TestBlockFetchReleasePreservesCompletedWork(t *testing.T) { + for _, tc := range []struct { + name string + headers bool + witness bool + }{ + {"header queued", true, false}, + {"header witness", true, true}, + {"body queued", false, false}, + {"body witness", false, true}, + } { + t.Run(tc.name, func(t *testing.T) { + testBlockFetchReleasePreservesCompletedWork(t, tc.headers, tc.witness) + }) + } +} + +func testBlockFetchReleasePreservesCompletedWork(t *testing.T, headers, pendingWitness bool) { + f := newBlockReleaseFetcher(t) + announce := addBlockReleaseRequest(f, headers, 1, "peer") + op := &blockOrHeaderInject{origin: announce.origin} + witness := &witnessRequestState{op: op, announce: announce} + if pendingWitness { + f.wm.pending[announce.hash] = witness + } else { + f.queued[announce.hash] = op + } + + f.releaseFetchOwnership(blockFetchRelease{headers: headers, announces: []*blockAnnounce{announce}}) + if pendingBlockRelease(f, headers)[announce.hash] != announce || f.announces[announce.origin] != 0 { + t.Fatal("late release changed a completed request or its accounting") + } + if pendingWitness && f.wm.pending[announce.hash] != witness { + t.Error("late release removed pending witness work") + } + if !pendingWitness && f.queued[announce.hash] != op { + t.Error("late release removed the queued block") + } +} + +func TestBlockFetchReleaseOnlyClearsMatchingStage(t *testing.T) { + for _, tc := range []struct { + name string + headers bool + }{{"headers", true}, {"bodies", false}} { + t.Run(tc.name, func(t *testing.T) { + f := newBlockReleaseFetcher(t) + original := addBlockReleaseRequest(f, tc.headers, 1, "peer") + otherStage := addBlockReleaseRequest(f, !tc.headers, 1, "peer") + + f.releaseFetchOwnership(blockFetchRelease{headers: tc.headers, announces: []*blockAnnounce{original}}) + if pendingBlockRelease(f, tc.headers)[original.hash] != nil { + t.Error("released request remains") + } + if pendingBlockRelease(f, !tc.headers)[original.hash] != otherStage || f.announces[original.origin] != 0 { + t.Fatal("release changed another stage or its accounting") + } + }) + } +} + +func TestFetchAnnouncementsSnapshot(t *testing.T) { + first := &blockAnnounce{hash: common.Hash{1}, origin: "peer"} + second := &blockAnnounce{hash: common.Hash{2}, origin: "peer"} + pending := map[common.Hash]*blockAnnounce{first.hash: first, second.hash: second} + snapshot := fetchAnnouncements(pending, []common.Hash{second.hash, first.hash}) + pending[first.hash] = &blockAnnounce{hash: first.hash, origin: first.origin} + delete(pending, second.hash) + + if len(snapshot) != 2 || snapshot[0] != second || snapshot[1] != first { + t.Fatalf("snapshot = %v, want the original requests in hash order", snapshot) + } + if empty := fetchAnnouncements(pending, nil); len(empty) != 0 { + t.Errorf("empty batch snapshot = %v", empty) + } + if missing := fetchAnnouncements(pending, []common.Hash{{9}}); len(missing) != 0 { + t.Errorf("unknown hash snapshot = %v", missing) + } +} + +func TestBlockFetchReleaseNotification(t *testing.T) { + for _, tc := range []struct { + name string + headers bool + }{{"headers", true}, {"bodies", false}} { + t.Run(tc.name, func(t *testing.T) { + f := newBlockReleaseFetcher(t) + announce := &blockAnnounce{hash: common.Hash{1}, origin: "peer"} + done := make(chan struct{}) + go func() { + f.notifyFetchRelease(tc.headers, []*blockAnnounce{announce}) + close(done) + }() + + select { + case release := <-f.fetchRelease: + if release.headers != tc.headers || len(release.announces) != 1 || release.announces[0] != announce { + t.Errorf("release notification = %+v", release) + } + case <-time.After(time.Second): + t.Fatal("release notification was not delivered") + } + waitBlockReleaseNotification(t, done) + }) + } +} + +func TestBlockFetchReleaseNotificationSkipsEmpty(t *testing.T) { + f := newBlockReleaseFetcher(t) + done := make(chan struct{}) + go func() { + f.notifyFetchRelease(true, nil) + close(done) + }() + + select { + case release := <-f.fetchRelease: + t.Fatalf("empty release was delivered: %+v", release) + case <-done: + case <-time.After(time.Second): + t.Fatal("empty release notification did not return") + } +} + +func TestBlockFetchReleaseNotificationStops(t *testing.T) { + for _, stopped := range []bool{false, true} { + name := "while notifying" + if stopped { + name = "before notifying" + } + t.Run(name, func(t *testing.T) { + f := newBlockReleaseFetcher(t) + if stopped { + f.Stop() + } + announce := &blockAnnounce{hash: common.Hash{1}, origin: "peer"} + done, started := make(chan struct{}), make(chan struct{}) + go func() { + close(started) + f.notifyFetchRelease(true, []*blockAnnounce{announce}) + close(done) + }() + <-started + if !stopped { + f.Stop() + } + waitBlockReleaseNotification(t, done) + }) + } +} + +func waitBlockReleaseNotification(t *testing.T, done <-chan struct{}) { + t.Helper() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("release notification did not finish") + } +} + +func TestBlockFetchReleaseMixedBatch(t *testing.T) { + f := newBlockReleaseFetcher(t) + served := addBlockReleaseRequest(f, false, 1, "peer") + skipped := addBlockReleaseRequest(f, false, 2, "peer") + // A partial response advances the hashes it covered to the import queue and + // leaves the rest owned by the finished request. + op := &blockOrHeaderInject{origin: "peer"} + f.queued[served.hash] = op + + f.releaseFetchOwnership(blockFetchRelease{announces: []*blockAnnounce{served, skipped}}) + + if f.completing[served.hash] != served || f.queued[served.hash] != op { + t.Fatal("partial release discarded the body that was served") + } + if f.completing[skipped.hash] != nil { + t.Fatal("partial release retained the hash the response skipped") + } +} diff --git a/eth/fetcher/block_fetcher_request.go b/eth/fetcher/block_fetcher_request.go new file mode 100644 index 0000000000..dfb4b4fc5e --- /dev/null +++ b/eth/fetcher/block_fetcher_request.go @@ -0,0 +1,156 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package fetcher + +import ( + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/eth/protocols/eth" + "github.com/ethereum/go-ethereum/log" + "github.com/ethereum/go-ethereum/metrics" +) + +// headerRequest carries what a header worker needs by value. The announce +// pointer is an ownership token only; its mutable fields belong to the loop. +type headerRequest struct { + hash common.Hash + announce *blockAnnounce + fetch headerRequesterFn + announcedAt time.Time +} + +type bodyRequest struct { + hashes []common.Hash + announces []*blockAnnounce + fetch bodyRequesterFn + announcedAt time.Time +} + +// runHeaderRequest performs one header retrieval using a slot the loop already +// reserved, releasing it on every exit. A blocked dispatch keeps that slot +// until the call below returns and the request is closed; the response deadline +// does not start until then, which is why admission and not the deadline is +// what bounds outstanding requests. +func (f *BlockFetcher) runHeaderRequest(peer string, req headerRequest) { + defer f.releaseFetchSlot(peer) + + owned := []*blockAnnounce{req.announce} + if req.fetch == nil { + f.notifyFetchRelease(true, owned) + return + } + resCh := make(chan *eth.Response) + + request, err := req.fetch(req.hash, resCh) + if err != nil { + log.Debug("Failed to request header", "peer", peer, "hash", req.hash, "err", err) + f.notifyFetchRelease(true, owned) + + return + } + defer request.Close() + + timeout := time.NewTimer(2 * fetchTimeout) + defer timeout.Stop() + + select { + case res := <-resCh: + f.deliverHeaders(peer, req, res) + case <-timeout.C: + log.Debug("BlockFetcher: header fetch timed out", "peer", peer, "hash", req.hash, "timeout", 2*fetchTimeout) + f.notifyFetchRelease(true, owned) + case <-f.quit: + } +} + +// deliverHeaders filters a header response, then returns ownership. A response +// that did not carry the requested header leaves the announcement owned here, +// so releasing unconditionally is what lets a later announcement retry. +func (f *BlockFetcher) deliverHeaders(peer string, req headerRequest, res *eth.Response) { + owned := []*blockAnnounce{req.announce} + if res == nil { + f.notifyFetchRelease(true, owned) + return + } + res.Done <- nil + + headers, ok := res.Res.(*eth.BlockHeadersRequest) + if !ok || headers == nil { + f.notifyFetchRelease(true, owned) + return + } + metrics.RecordPerItemDuration(blockHeaderItemDownloadTimer, res.Time, len(*headers)) + f.FilterHeaders(peer, *headers, time.Now(), req.announcedAt) + f.notifyFetchRelease(true, owned) +} + +// runBodyRequest performs one body retrieval for a batch of hashes. +func (f *BlockFetcher) runBodyRequest(peer string, req bodyRequest) { + defer f.releaseFetchSlot(peer) + + bodyFetchMeter.Mark(int64(len(req.hashes))) + + if req.fetch == nil { + f.notifyFetchRelease(false, req.announces) + return + } + resCh := make(chan *eth.Response) + + request, err := req.fetch(req.hashes, resCh) + if err != nil { + log.Debug("Failed to request bodies", "peer", peer, "hashes", req.hashes, "err", err) + f.notifyFetchRelease(false, req.announces) + + return + } + defer request.Close() + + timeout := time.NewTimer(2 * fetchTimeout) + defer timeout.Stop() + + select { + case res := <-resCh: + f.deliverBodies(peer, req, res) + case <-timeout.C: + log.Debug("BlockFetcher: body fetch timed out", "peer", peer, "hashes", req.hashes, "timeout", 2*fetchTimeout) + f.notifyFetchRelease(false, req.announces) + case <-f.quit: + } +} + +// deliverBodies filters a body response, then returns ownership of the batch. +// Hashes an empty or partial reply did not cover are still owned here; the loop +// keeps the ones that reached the import queue or witness retrieval. +func (f *BlockFetcher) deliverBodies(peer string, req bodyRequest, res *eth.Response) { + if res == nil { + f.notifyFetchRelease(false, req.announces) + return + } + res.Done <- nil + + bodies, ok := res.Res.(*eth.BlockBodiesResponse) + if !ok || bodies == nil { + f.notifyFetchRelease(false, req.announces) + return + } + // Withdrawals are ignored here, since the block fetcher is not used post-merge. + txs, uncles, _ := bodies.Unpack() + metrics.RecordPerItemDuration(blockBodyItemDownloadTimer, res.Time, len(txs)) + f.FilterBodies(peer, txs, uncles, time.Now(), req.announcedAt) + f.notifyFetchRelease(false, req.announces) +} diff --git a/eth/fetcher/block_fetcher_schedule.go b/eth/fetcher/block_fetcher_schedule.go new file mode 100644 index 0000000000..97248b8696 --- /dev/null +++ b/eth/fetcher/block_fetcher_schedule.go @@ -0,0 +1,231 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package fetcher + +import ( + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/log" +) + +// scheduleHeaderFetches turns expired announcements into header requests, one +// request per hash. It reports whether admission left any announcement behind. +// Runs on the fetcher loop. +func (f *BlockFetcher) scheduleHeaderFetches() bool { + requests := make(map[string][]headerRequest) + awaitingBodies := f.peersAwaitingBodies() + deferred := false + + for hash, announces := range f.announced { + // In current LES protocol(les2/les3), only header announce is + // available, no need to wait too much time for header broadcast. + wait := arriveTimeout - gatherSlack + if f.light { + wait = 0 + } + if time.Since(announces[0].time) <= wait { + continue + } + if !f.readyToFetch(hash) { + f.dropAnnouncements(hash, announces) + continue + } + announce := f.acquireAnnouncer(announces, awaitingBodies) + if announce == nil { + // No announcer has capacity to spare for a header right now. + // rescheduleFetch holds the retry off so this does not spin. + deferred = true + + continue + } + req := f.admitHeaderRequest(hash, announce) + requests[req.announce.origin] = append(requests[req.announce.origin], req) + } + + for peer, batch := range requests { + f.startHeaderRequests(peer, batch) + } + + return deferred +} + +// peersAwaitingBodies lists the peers holding headers that still need a body +// request, computed once per pass so header admission can leave their slots +// alone. Scheduling bodies first is not enough on its own: a worker can free a +// slot between the two passes. +func (f *BlockFetcher) peersAwaitingBodies() map[string]bool { + if len(f.fetched) == 0 { + return nil + } + waiting := make(map[string]bool) + for _, announces := range f.fetched { + for _, announce := range announces { + waiting[announce.origin] = true + } + } + + return waiting +} + +// expiredFetches lists outstanding header requests whose fetch phase ran out. +func (f *BlockFetcher) expiredFetches() []common.Hash { + f.mu.RLock() + defer f.mu.RUnlock() + + expired := make([]common.Hash, 0) + for hash, announce := range f.fetching { + if time.Since(announce.time) > fetchTimeout { + expired = append(expired, hash) + } + } + + return expired +} + +// admitHeaderRequest moves an announcement into the fetching stage. The fetch +// phase clock restarts here because an announcement may have waited for +// capacity; carrying its original age over would make expiredFetches drop the +// request before any response could arrive. The announcement time travels on +// the request so latency metrics still measure from the announcement. +func (f *BlockFetcher) admitHeaderRequest(hash common.Hash, announce *blockAnnounce) headerRequest { + announcedAt := announce.time + f.forgetHash(hash) // This also removes from f.announced + announce.time = time.Now() + f.fetching[hash] = announce + + return headerRequest{hash: hash, announce: announce, fetch: announce.fetchHeader, announcedAt: announcedAt} +} + +// readyToFetch reports whether a hash still needs a header request, rather than +// already being held by the witness manager, the import queue, a later fetch +// stage, or the local chain. +func (f *BlockFetcher) readyToFetch(hash common.Hash) bool { + if f.wm.isPending(hash) || f.queued[hash] != nil { + return false + } + if f.fetched[hash] != nil || f.completing[hash] != nil { + return false + } + if f.light { + return f.getHeader(hash) == nil + } + + return f.getBlock(hash) == nil +} + +// dropAnnouncements forgets a hash that no longer needs fetching, returning the +// announcement credits it holds. +func (f *BlockFetcher) dropAnnouncements(hash common.Hash, announces []*blockAnnounce) { + for _, announce := range announces { + f.announces[announce.origin]-- + if f.announces[announce.origin] <= 0 { + delete(f.announces, announce.origin) + } + } + delete(f.announced, hash) + + if f.announceChangeHook != nil { + f.announceChangeHook(hash, false) + } +} + +func (f *BlockFetcher) startHeaderRequests(peer string, batch []headerRequest) { + hashes := make([]common.Hash, len(batch)) + for i, req := range batch { + hashes[i] = req.hash + } + log.Debug("Fetching scheduled headers", "peer", peer, "list", hashes) + + go func() { + if f.fetchingHook != nil { + f.fetchingHook(hashes) + } + for _, req := range batch { + headerFetchMeter.Mark(1) + + go f.runHeaderRequest(peer, req) + } + }() +} + +// scheduleBodyFetches turns fetched headers into body requests, grouped so that +// one request per peer carries that peer's whole batch. It reports whether +// admission left any header behind. Runs on the fetcher loop. +func (f *BlockFetcher) scheduleBodyFetches() bool { + requests := make(map[string]*bodyRequest) + admitted := make(map[string]bool) + deferred := false + + for hash, announces := range f.fetched { + announce := f.acquireBodyAnnouncer(announces, admitted) + if announce == nil { + deferred = true + + continue + } + f.groupBodyRequest(requests, hash, announce) + } + f.startBodyRequests(requests, admitted) + + return deferred +} + +// groupBodyRequest moves one fetched header into the completion stage and adds +// it to its peer's batch. The hash is forgotten first so a block that arrived +// by another route is dropped rather than re-requested. +func (f *BlockFetcher) groupBodyRequest(requests map[string]*bodyRequest, hash common.Hash, announce *blockAnnounce) { + announcedAt := announce.time + f.forgetHash(hash) // Removes from f.fetched + + if f.wm.isPending(hash) || f.getBlock(hash) != nil { + return + } + f.completing[hash] = announce + + req := requests[announce.origin] + if req == nil { + req = &bodyRequest{fetch: announce.fetchBodies, announcedAt: announcedAt} + requests[announce.origin] = req + } + req.hashes = append(req.hashes, hash) + req.announces = append(req.announces, announce) +} + +func (f *BlockFetcher) startBodyRequests(requests map[string]*bodyRequest, admitted map[string]bool) { + // A peer admitted for a hash that turned out not to need a body holds a + // slot with nothing to send. + for peer := range admitted { + if requests[peer] == nil { + f.releaseFetchSlot(peer) + } + } + for peer, req := range requests { + f.startBodyRequest(peer, *req) + } +} + +func (f *BlockFetcher) startBodyRequest(peer string, req bodyRequest) { + log.Debug("Fetching scheduled bodies", "peer", peer, "list", req.hashes) + + go func() { + if f.completingHook != nil { + f.completingHook(req.hashes) + } + f.runBodyRequest(peer, req) + }() +} diff --git a/eth/fetcher/block_fetcher_schedule_edges_test.go b/eth/fetcher/block_fetcher_schedule_edges_test.go new file mode 100644 index 0000000000..06fb4d52a9 --- /dev/null +++ b/eth/fetcher/block_fetcher_schedule_edges_test.go @@ -0,0 +1,92 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package fetcher + +import ( + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" +) + +func TestHeaderSchedulingWaitsForAnnouncement(t *testing.T) { + p := newScheduleProbe(t) + hash := p.announce("peer", 0) + announcement := p.fetcher.announced[hash][0] + if p.fetcher.scheduleHeaderFetches() { + t.Fatal("fresh announcement marked capacity-deferred") + } + if p.fetcher.fetching[hash] != nil || p.fetcher.fetchSlotsTotal != 0 || len(p.fetcher.announced[hash]) != 1 || p.fetcher.announced[hash][0] != announcement { + t.Fatal("fresh announcement was fetched before its gather window") + } +} + +func TestHeaderSchedulingKeepsLaterStages(t *testing.T) { + for _, stage := range []string{"fetched", "completing"} { + t.Run(stage, func(t *testing.T) { + p := newScheduleProbe(t) + f := p.fetcher + hash := p.announce("new", time.Second) + owned := &blockAnnounce{hash: hash, origin: "existing", time: time.Now()} + if stage == "fetched" { + f.fetched[hash] = []*blockAnnounce{owned} + } else { + f.completing[hash] = owned + } + if f.scheduleHeaderFetches() { + t.Fatal("duplicate announcement deferred") + } + if f.fetchSlotsTotal != 0 || f.fetching[hash] != nil || len(f.announced[hash]) != 0 { + t.Fatal("duplicate announcement started another header request") + } + if stage == "fetched" && (len(f.fetched[hash]) != 1 || f.fetched[hash][0] != owned) { + t.Fatal("lost fetched ownership") + } + if stage == "completing" && f.completing[hash] != owned { + t.Fatal("lost completing ownership") + } + }) + } +} + +func TestHeaderSchedulingDropsKnownAnnouncements(t *testing.T) { + p := newScheduleProbe(t) + f := p.fetcher + hash := p.announce("peer", time.Second) + f.getBlock = func(common.Hash) *types.Block { return genesis } + f.scheduleHeaderFetches() + if _, exists := f.announces["peer"]; exists { + t.Fatal("empty per-peer announcement entry retained") + } + if len(f.announced[hash]) != 0 || f.fetchSlotsTotal != 0 { + t.Fatal("known block announcement retained or fetched") + } +} + +func TestAcquireBodyAnnouncerRefusesEmpty(t *testing.T) { + for _, announces := range [][]*blockAnnounce{nil, {}} { + f := newBlockReleaseFetcher(t) + admitted := make(map[string]bool) + if f.acquireBodyAnnouncer(announces, admitted) != nil { + t.Fatal("admitted empty body announcer set") + } + if f.fetchSlotsTotal != 0 || len(admitted) != 0 { + t.Fatal("empty announcer set reserved capacity") + } + } +} diff --git a/eth/fetcher/block_fetcher_schedule_test.go b/eth/fetcher/block_fetcher_schedule_test.go new file mode 100644 index 0000000000..dfff5f5c84 --- /dev/null +++ b/eth/fetcher/block_fetcher_schedule_test.go @@ -0,0 +1,248 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package fetcher + +import ( + "encoding/binary" + "fmt" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/eth/protocols/eth" +) + +// scheduleProbe drives the production scheduling passes directly, so a legal +// interleaving of header completion and header refill is reproducible without +// timers. +type scheduleProbe struct { + fetcher *BlockFetcher + started chan common.Hash + next byte +} + +func newScheduleProbe(t *testing.T) *scheduleProbe { + t.Helper() + f := NewBlockFetcher(false, nil, func(common.Hash) *types.Block { return nil }, nil, nil, + func() uint64 { return 0 }, nil, nil, nil, + func(peer string) { t.Errorf("scheduling dropped peer %q", peer) }, false, false, 0, nil, nil) + t.Cleanup(func() { + select { + case <-f.quit: + default: + f.Stop() + } + f.wm.stopAndDrainTimer() + }) + + return &scheduleProbe{fetcher: f, started: make(chan common.Hash, 4*maxPeerFetchRequests)} +} + +func (p *scheduleProbe) announce(peer string, age time.Duration) common.Hash { + p.next++ + hash := common.Hash{p.next, byte(len(peer))} + announce := &blockAnnounce{ + hash: hash, + number: 1, + origin: peer, + time: time.Now().Add(-age), + fetchHeader: func(h common.Hash, _ chan *eth.Response) (*eth.Request, error) { + p.started <- h + + return ð.Request{Cancel: make(chan struct{})}, nil + }, + fetchBodies: func([]common.Hash, chan *eth.Response) (*eth.Request, error) { + return ð.Request{Cancel: make(chan struct{})}, nil + }, + } + p.fetcher.announced[hash] = []*blockAnnounce{announce} + p.fetcher.announces[peer]++ + + return hash +} + +// completeHeader mirrors the loop's fetching to fetched transition and the +// worker's slot release for one outstanding request. +func (p *scheduleProbe) completeHeader(t *testing.T, hash common.Hash) { + t.Helper() + f := p.fetcher + announce := f.fetching[hash] + if announce == nil { + t.Fatalf("no outstanding header request for %x", hash[:4]) + } + delete(f.fetching, hash) + f.fetched[hash] = []*blockAnnounce{announce} + f.releaseFetchSlot(announce.origin) +} + +func TestHeaderSchedulingYieldsToWaitingBodies(t *testing.T) { + probe := newScheduleProbe(t) + f := probe.fetcher + + for i := 0; i < maxPeerFetchRequests; i++ { + probe.announce("peer", time.Second) + } + if f.scheduleHeaderFetches() { + t.Fatal("initial batch was refused") + } + if f.fetchSlotsTotal != maxPeerFetchRequests { + t.Fatalf("slots held = %d, want %d", f.fetchSlotsTotal, maxPeerFetchRequests) + } + probe.completeHeader(t, <-probe.started) + + // A new announcement from the same peer must not reclaim the slot the + // waiting body needs, or fetched metadata grows without bound. + probe.announce("peer", time.Second) + if !f.scheduleHeaderFetches() { + t.Fatal("header scheduling refilled the slot a waiting body needs") + } + if f.fetchSlotsTotal != maxPeerFetchRequests-1 { + t.Fatalf("slots held = %d, want the freed slot left for the body", f.fetchSlotsTotal) + } + if f.scheduleBodyFetches() { + t.Fatal("body scheduling was refused the slot header scheduling yielded") + } + if len(f.completing) != 1 || len(f.fetched) != 0 { + t.Fatalf("completing=%d fetched=%d, want the body admitted and fetched drained", len(f.completing), len(f.fetched)) + } +} + +func TestHeaderSchedulingPrefersPeerWithoutWaitingBodies(t *testing.T) { + probe := newScheduleProbe(t) + f := probe.fetcher + + for i := 0; i < maxPeerFetchRequests; i++ { + probe.announce("busy", time.Second) + } + if f.scheduleHeaderFetches() { + t.Fatal("initial batch was refused") + } + probe.completeHeader(t, <-probe.started) + + // Each hash is announced by both peers. Choosing at random would land on + // the peer with waiting bodies about half the time. + const rounds = 8 + + hashes := make([]common.Hash, rounds) + for i := range hashes { + hashes[i] = probe.announce("busy", time.Second) + f.announced[hashes[i]] = append(f.announced[hashes[i]], &blockAnnounce{ + hash: hashes[i], number: 1, origin: "idle", time: time.Now().Add(-time.Second), + fetchHeader: func(h common.Hash, _ chan *eth.Response) (*eth.Request, error) { + probe.started <- h + + return ð.Request{Cancel: make(chan struct{})}, nil + }, + }) + f.announces["idle"]++ + } + if f.scheduleHeaderFetches() { + t.Fatal("an alternative announcer without waiting bodies was not used") + } + for _, hash := range hashes { + if f.fetching[hash] == nil || f.fetching[hash].origin != "idle" { + t.Fatalf("header request for %x went to %v, want the peer with no waiting bodies", hash[:4], f.fetching[hash]) + } + } +} + +func TestAdmittedHeaderRequestRefreshesFetchClock(t *testing.T) { + probe := newScheduleProbe(t) + f := probe.fetcher + + stale := fetchTimeout + time.Second + hash := probe.announce("peer", stale) + announcedAt := f.announced[hash][0].time + + req := f.admitHeaderRequest(hash, f.announced[hash][0]) + + // The loop sweeps fetching entries older than fetchTimeout, so an + // announcement that waited for capacity must not arrive already expired. + if elapsed := time.Since(f.fetching[hash].time); elapsed > fetchTimeout { + t.Fatalf("admitted request is already %v old, the expiry sweep will drop it before a response arrives", elapsed) + } + if !req.announcedAt.Equal(announcedAt) { + t.Fatalf("announcedAt = %v, want the original announcement time %v", req.announcedAt, announcedAt) + } + if req.hash != hash || req.announce != f.fetching[hash] { + t.Fatal("admitted request does not own the fetching entry") + } +} + +func TestDeferredAnnouncementSurvivesExpirySweep(t *testing.T) { + probe := newScheduleProbe(t) + f := probe.fetcher + + for i := 0; i < maxPeerFetchRequests; i++ { + if !f.acquireFetchSlot("peer") { + t.Fatal("could not fill peer slots") + } + } + probe.announce("peer", fetchTimeout+time.Second) + if !f.scheduleHeaderFetches() { + t.Fatal("full peer did not defer the announcement") + } + for i := 0; i < maxPeerFetchRequests; i++ { + f.releaseFetchSlot("peer") + } + if f.scheduleHeaderFetches() { + t.Fatal("freed peer did not admit the deferred announcement") + } + if expired := f.expiredFetches(); len(expired) != 0 { + t.Fatalf("expiry sweep drops %d freshly dispatched request(s)", len(expired)) + } +} + +func TestHeaderSchedulingStopsAtAggregateBacklog(t *testing.T) { + for _, backlog := range []int{maxTotalFetchRequests - 1, maxTotalFetchRequests, maxTotalFetchRequests + 1} { + t.Run(fmt.Sprint(backlog), func(t *testing.T) { + probe := newScheduleProbe(t) + f := probe.fetcher + + // Headers left by earlier identities; the per-peer body preference + // does not apply to the fresh announcer used below. + for i := 0; i < backlog; i++ { + var hash common.Hash + binary.BigEndian.PutUint64(hash[:8], uint64(i+1)) + f.fetched[hash] = []*blockAnnounce{{hash: hash, origin: fmt.Sprintf("prior-%d", i), time: time.Now()}} + } + hash := probe.announce("fresh", time.Second) + original := f.announced[hash][0] + + deferred := f.scheduleHeaderFetches() + if backlog < maxTotalFetchRequests { + if deferred || f.fetching[hash] != original { + t.Fatal("a fresh peer was refused below the aggregate backlog ceiling") + } + return + } + if !deferred { + t.Fatalf("admitted a header with %d hashes already waiting for bodies", backlog) + } + if f.fetching[hash] != nil || len(f.announced[hash]) != 1 || f.announced[hash][0] != original { + t.Fatal("aggregate refusal did not retain the pending announcement") + } + if f.fetchSlotsTotal != 0 { + t.Fatalf("aggregate refusal reserved %d worker slots", f.fetchSlotsTotal) + } + if len(f.fetched) != backlog { + t.Fatalf("aggregate refusal altered waiting bodies: %d, want %d", len(f.fetched), backlog) + } + }) + } +} diff --git a/eth/fetcher/block_fetcher_timeout_recovery_test.go b/eth/fetcher/block_fetcher_timeout_recovery_test.go new file mode 100644 index 0000000000..74a6eb9cdc --- /dev/null +++ b/eth/fetcher/block_fetcher_timeout_recovery_test.go @@ -0,0 +1,272 @@ +package fetcher + +import ( + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/eth/protocols/eth" +) + +func TestBlockFetchTimeoutKeepsPeerAndAllowsRetry(t *testing.T) { + hashes, blocks := makeChain(1, 42, genesis) + block := blocks[hashes[0]] + for _, headers := range []bool{true, false} { + name := "body" + if headers { + name = "header" + } + t.Run(name, func(t *testing.T) { + t.Parallel() + tester := newTester(false) + defer tester.fetcher.Stop() + imported := make(chan struct{}, 1) + tester.fetcher.importedHook = func(*types.Header, *types.Block) { imported <- struct{}{} } + request, started := requestDelayedBlock(t, tester, blocks, block, headers) + prepareBlockTimeoutState(t, tester.fetcher, block.Hash(), headers) + awaitBlockTimeout(t, request, started) + assertBlockTimeoutReleased(t, tester.fetcher, block.Hash(), headers) + assertBlockPeerRetained(t, tester) + err := tester.fetcher.Notify("slow", block.Hash(), block.NumberU64(), time.Now(), + tester.makeHeaderFetcher("slow", blocks, 0), tester.makeBodyFetcher("slow", blocks, 0), nil) + if err != nil { + t.Fatal(err) + } + select { + case <-imported: + case <-time.After(3 * time.Second): + t.Fatal("timed-out block could not be fetched again") + } + assertBlockPeerRetained(t, tester) + }) + } +} + +func TestBlockBodyTimeoutPreservesOtherAnnouncements(t *testing.T) { + hashes, blocks := makeChain(1, 43, genesis) + block := blocks[hashes[0]] + tester := newTester(false) + defer tester.fetcher.Stop() + f := tester.fetcher + other := common.Hash{0xff} + announced, resume := make(chan struct{}), make(chan struct{}) + defer close(resume) + f.announceChangeHook = func(hash common.Hash, added bool) { + if hash == other && added { + close(announced) + <-resume + } + } + request, started := requestDelayedBlock(t, tester, blocks, block, false) + if err := f.Notify("slow", other, block.NumberU64(), time.Now(), nil, nil, nil); err != nil { + t.Fatal(err) + } + select { + case <-announced: + case <-time.After(time.Second): + t.Fatal("second announcement was not registered") + } + if f.completing[block.Hash()] == nil || len(f.announced[other]) != 1 || f.announces["slow"] != 1 { + t.Fatal("expected a body request and one counted announcement from the same peer") + } + // The hook pauses scheduling B while the real worker deadline for A expires. + select { + case release := <-f.fetchRelease: + if release.headers || len(release.announces) != 1 || release.announces[0] != f.completing[block.Hash()] { + t.Fatal("received a release for a different request") + } + f.releaseFetchOwnership(release) + case <-time.After(12 * time.Second): + t.Fatal("body request did not time out") + } + awaitBlockTimeout(t, request, started) + if f.completing[block.Hash()] != nil { + t.Error("timeout did not release the body request") + } + if len(f.announced[other]) != 1 || f.announces["slow"] != 1 { + t.Errorf("body timeout changed unrelated announcement accounting: count=%d, want 1", f.announces["slow"]) + } + assertBlockPeerRetained(t, tester) +} + +type delayedBlockRequest struct { + request *eth.Request + started time.Time +} + +func requestDelayedBlock(t *testing.T, tester *fetcherTester, blocks map[common.Hash]*types.Block, block *types.Block, headers bool) (*eth.Request, time.Time) { + t.Helper() + requests := make(chan delayedBlockRequest, 1) + header := tester.makeHeaderFetcher("slow", blocks, 0) + body := tester.makeBodyFetcher("slow", blocks, 0) + if headers { + header = func(common.Hash, chan *eth.Response) (*eth.Request, error) { + req := ð.Request{Cancel: make(chan struct{})} + requests <- delayedBlockRequest{request: req, started: time.Now()} + return req, nil + } + } else { + body = func([]common.Hash, chan *eth.Response) (*eth.Request, error) { + req := ð.Request{Cancel: make(chan struct{})} + requests <- delayedBlockRequest{request: req, started: time.Now()} + return req, nil + } + } + if err := tester.fetcher.Notify("slow", block.Hash(), block.NumberU64(), time.Now(), header, body, nil); err != nil { + t.Fatal(err) + } + select { + case request := <-requests: + return request.request, request.started + case <-time.After(3 * time.Second): + t.Fatal("block request was not started") + return nil, time.Time{} + } +} + +func prepareBlockTimeoutState(t *testing.T, f *BlockFetcher, hash common.Hash, headers bool) { + t.Helper() + resume := pauseBlockTimeoutLoop(t, f) + defer resume() + f.mu.Lock() + defer f.mu.Unlock() + announce := pendingBlockRelease(f, headers)[hash] + if announce == nil { + t.Fatal("request was not registered in the expected fetch stage") + } + // Isolate the worker's real deadline from the loop's legacy announcement-age sweep. + announce.time = time.Now().Add(time.Hour) +} + +func awaitBlockTimeout(t *testing.T, request *eth.Request, started time.Time) { + t.Helper() + select { + case <-request.Cancel: + case <-time.After(12 * time.Second): + t.Fatal("timed-out request was not cancelled") + } + if elapsed := time.Since(started); elapsed < 2*fetchTimeout || elapsed > 12*time.Second { + t.Fatalf("request timed out after %v, want between %v and 12s", elapsed, 2*fetchTimeout) + } +} + +func assertBlockTimeoutReleased(t *testing.T, f *BlockFetcher, hash common.Hash, headers bool) { + t.Helper() + resume := pauseBlockTimeoutLoop(t, f) + defer resume() + f.mu.RLock() + defer f.mu.RUnlock() + if pendingBlockRelease(f, headers)[hash] != nil { + t.Error("timeout did not release the matching fetch request") + } + if count := f.announces["slow"]; count != 0 { + t.Errorf("timeout left %d outstanding announcements, want 0", count) + } +} + +func pauseBlockTimeoutLoop(t *testing.T, f *BlockFetcher) func() { + t.Helper() + filter := make(chan *headerFilterTask) + select { + case f.headerFilter <- filter: + case <-time.After(time.Second): + t.Fatal("fetcher did not reach the state inspection barrier") + } + return func() { + select { + case filter <- &headerFilterTask{}: + case <-time.After(time.Second): + t.Fatal("fetcher did not resume from the state inspection barrier") + } + select { + case <-filter: + case <-time.After(time.Second): + t.Fatal("fetcher did not complete the state inspection barrier") + } + } +} + +func assertBlockPeerRetained(t *testing.T, tester *fetcherTester) { + t.Helper() + tester.lock.RLock() + defer tester.lock.RUnlock() + if tester.drops["slow"] { + t.Fatal("slow peer was disconnected") + } +} + +func TestEmptyBodyResponseAllowsReannouncement(t *testing.T) { + hashes, blocks := makeChain(1, 63, genesis) + block := blocks[hashes[0]] + if len(block.Transactions()) == 0 && len(block.Uncles()) == 0 { + t.Fatal("fixture block needs a body to reach the body fetch stage") + } + tester := newTester(false) + defer tester.fetcher.Stop() + f := tester.fetcher + + imported := make(chan struct{}, 1) + f.importedHook = func(*types.Header, *types.Block) { imported <- struct{}{} } + completing := make(chan []common.Hash, 4) + f.completingHook = func(hs []common.Hash) { completing <- hs } + + err := f.Notify("silent", block.Hash(), block.NumberU64(), time.Now(), + tester.makeHeaderFetcher("silent", blocks, -gatherSlack), emptyBodyFetcher("silent"), nil) + if err != nil { + t.Fatal(err) + } + select { + case <-completing: + case <-time.After(5 * time.Second): + t.Fatal("body request was never issued") + } + // An announcement arriving while the request is still outstanding is + // deduplicated against it, so recovery depends on the record being released + // once the empty response ends the request. + awaitCompletionReleased(t, f, block.Hash()) + err = f.Notify("healthy", block.Hash(), block.NumberU64(), time.Now(), + tester.makeHeaderFetcher("healthy", blocks, -gatherSlack), + tester.makeBodyFetcher("healthy", blocks, 0), nil) + if err != nil { + t.Fatal(err) + } + select { + case <-imported: + case <-time.After(5 * time.Second): + t.Fatal("block was not recovered after an empty body response") + } + + tester.lock.RLock() + defer tester.lock.RUnlock() + if tester.drops["silent"] { + t.Fatal("empty body response dropped the peer") + } +} + +func awaitCompletionReleased(t *testing.T, f *BlockFetcher, hash common.Hash) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + f.mu.RLock() + held := f.completing[hash] != nil + f.mu.RUnlock() + if !held { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatal("completion record was retained after the response ended the request") +} + +func emptyBodyFetcher(peer string) bodyRequesterFn { + return func(_ []common.Hash, sink chan *eth.Response) (*eth.Request, error) { + req := ð.Request{Peer: peer} + bodies := make([]*eth.BlockBody, 0) + res := ð.Response{Req: req, Res: (*eth.BlockBodiesResponse)(&bodies), Done: make(chan error, 1)} + + go func() { sink <- res }() + + return req, nil + } +} diff --git a/eth/filters/IBackend.go b/eth/filters/IBackend.go index 3197a74530..fff06f5847 100644 --- a/eth/filters/IBackend.go +++ b/eth/filters/IBackend.go @@ -114,6 +114,20 @@ func (mr *MockBackendMockRecorder) BaseFee(ctx any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BaseFee", reflect.TypeOf((*MockBackend)(nil).BaseFee), ctx) } +// BeginPrivateTx mocks base method. +func (m *MockBackend) BeginPrivateTx(hash common.Hash) func(bool) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "BeginPrivateTx", hash) + ret0, _ := ret[0].(func(bool)) + return ret0 +} + +// BeginPrivateTx indicates an expected call of BeginPrivateTx. +func (mr *MockBackendMockRecorder) BeginPrivateTx(hash any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BeginPrivateTx", reflect.TypeOf((*MockBackend)(nil).BeginPrivateTx), hash) +} + // BlobBaseFee mocks base method. func (m *MockBackend) BlobBaseFee(ctx context.Context) *big.Int { m.ctrl.T.Helper() diff --git a/eth/handler.go b/eth/handler.go index 8b2be5c3d5..546572672e 100644 --- a/eth/handler.go +++ b/eth/handler.go @@ -1105,8 +1105,7 @@ func (h *handler) rebroadcastStuckTransactions(txs types.Transactions, onBroadca // enableSyncedFeatures enables the post-sync functionalities when the initial // sync is finished. func (h *handler) enableSyncedFeatures() { - // Mark the local node as synced. - h.synced.Store(true) + wasSynced := h.synced.Swap(true) // If we were running snap sync and it finished, disable doing another // round on next sync cycle @@ -1114,6 +1113,21 @@ func (h *handler) enableSyncedFeatures() { log.Info("Snap sync complete, auto disabling") h.snapSync.Store(false) } + if wasSynced || h.disableTxPropagation { + return + } + peers := h.peers.all() + if len(peers) == 0 { + return + } + // Suppressed retries may have expired while these peers remained connected. + hashes := h.pendingTransactionHashes() + if len(hashes) == 0 { + return + } + for _, peer := range peers { + peer.AsyncSendPooledTransactionHashes(hashes) + } } // PeerStats represents a short summary of the information known about a connected diff --git a/eth/rebroadcast_delivery_test.go b/eth/rebroadcast_delivery_test.go index fd1bddb691..3581fc2912 100644 --- a/eth/rebroadcast_delivery_test.go +++ b/eth/rebroadcast_delivery_test.go @@ -177,7 +177,7 @@ func TestRebroadcastEventsExcludeNonGossipableTransactions(t *testing.T) { if err != nil { t.Fatal(err) } - tx.SetTime(time.Now().Add(-time.Hour)) + tx.SetTime(time.Now().Add(-time.Minute)) txs = append(txs, tx) } backend.APIBackend.RecordPrivateTx(txs[0].Hash()) diff --git a/eth/relay/private_tx_store.go b/eth/relay/private_tx_store.go index 42b01e7688..6cdc13caa7 100644 --- a/eth/relay/private_tx_store.go +++ b/eth/relay/private_tx_store.go @@ -14,9 +14,8 @@ import ( ) const ( - privateTxTTL = 10 * time.Minute // hard TTL: remove tx from store after this duration regardless - privateTxGracePeriod = 2 * time.Minute // min age before txpool presence check applies - sweepInterval = 1 * time.Minute // how often the sweep goroutine runs + privateTxGracePeriod = 2 * time.Minute // min age before txpool presence check applies + sweepInterval = 1 * time.Minute // how often the sweep goroutine runs ) var privateTxStoreSizeGauge = metrics.NewRegisteredGauge("relay/privatetx/store/size", nil) @@ -34,8 +33,9 @@ type PrivateTxSetter interface { type TxPoolChecker func(hash common.Hash) bool type PrivateTxStore struct { - txs map[common.Hash]time.Time // tx hash to last updated time - mu sync.RWMutex + txs map[common.Hash]time.Time // committed tx hash to last updated time + pending map[common.Hash]*privateTxReservation + mu sync.RWMutex chainEventSubFn func(ch chan<- core.ChainEvent) event.Subscription txPoolChecker TxPoolChecker @@ -44,7 +44,7 @@ type PrivateTxStore struct { txsAdded atomic.Uint64 txsPurged atomic.Uint64 // deleted by an explicit call txsDeleted atomic.Uint64 // deleted because tx got included - txsExpired atomic.Uint64 // deleted by sweep (txpool eviction or TTL) + txsExpired atomic.Uint64 // deleted by sweep after txpool eviction closeCh chan struct{} } @@ -52,6 +52,7 @@ type PrivateTxStore struct { func NewPrivateTxStore() *PrivateTxStore { store := &PrivateTxStore{ txs: make(map[common.Hash]time.Time), + pending: make(map[common.Hash]*privateTxReservation), closeCh: make(chan struct{}), } go store.report() @@ -72,6 +73,7 @@ func (s *PrivateTxStore) Purge(hash common.Hash) { defer s.mu.Unlock() delete(s.txs, hash) + delete(s.pending, hash) s.txsPurged.Add(1) } @@ -83,7 +85,7 @@ func (s *PrivateTxStore) IsTxPrivate(hash common.Hash) bool { return true } - return false + return s.pending[hash] != nil } func (s *PrivateTxStore) cleanupLoop() { @@ -115,6 +117,7 @@ func (s *PrivateTxStore) cleanup() error { s.mu.Lock() deleted := uint64(0) for _, tx := range event.Transactions { + delete(s.pending, tx.Hash()) if _, exists := s.txs[tx.Hash()]; exists { deleted++ delete(s.txs, tx.Hash()) @@ -145,11 +148,7 @@ func (s *PrivateTxStore) SetTxPoolChecker(checker TxPoolChecker) { s.txPoolChecker = checker } -// sweep periodically removes stale entries from the store. An entry is removed -// either when it exceeds the hard TTL (10 minutes by default), regardless of -// txpool state, or when it is older than privateTxGracePeriod and is no longer -// present in the local txpool, which is treated as the source of truth for -// eviction. +// Pool residence can exceed any fixed TTL; only absence permits expiration. func (s *PrivateTxStore) sweep() { ticker := time.NewTicker(sweepInterval) defer ticker.Stop() @@ -175,6 +174,12 @@ func (s *PrivateTxStore) sweepOnce() { // Snapshot under the read lock. s.mu.RLock() + checker := s.txPoolChecker + if checker == nil { + // Keep markers until pool ownership can be checked. + s.mu.RUnlock() + return + } entries := make([]entry, 0, len(s.txs)) for h, t := range s.txs { entries = append(entries, entry{h, t}) @@ -187,10 +192,7 @@ func (s *PrivateTxStore) sweepOnce() { toDelete := make([]entry, 0) for _, entry := range entries { age := now.Sub(entry.addedAt) - if age > privateTxTTL { - // Hard TTL: remove regardless of txpool status - toDelete = append(toDelete, entry) - } else if age > privateTxGracePeriod && s.txPoolChecker != nil && !s.txPoolChecker(entry.hash) { + if age > privateTxGracePeriod && !checker(entry.hash) { toDelete = append(toDelete, entry) } } @@ -198,12 +200,13 @@ func (s *PrivateTxStore) sweepOnce() { return } - // Delete the entries under write lock. Only delete those whose `addedAt` time - // hasn't changed as it's possible that the tx was re-added to the pool after - // snapshot was taken. + // Pending admissions keep ownership; successful admissions refresh addedAt. expired := uint64(0) s.mu.Lock() for _, entry := range toDelete { + if s.pending[entry.hash] != nil { + continue + } if cur, ok := s.txs[entry.hash]; ok && cur.Equal(entry.addedAt) { delete(s.txs, entry.hash) expired++ diff --git a/eth/relay/private_tx_store_submission.go b/eth/relay/private_tx_store_submission.go new file mode 100644 index 0000000000..a48a466298 --- /dev/null +++ b/eth/relay/private_tx_store_submission.go @@ -0,0 +1,49 @@ +package relay + +import ( + "sync" + "time" + + "github.com/ethereum/go-ethereum/common" +) + +type privateTxReservation struct { + inflight int +} + +// Begin hides a transaction during admission. Overlapping submissions share a +// reservation so a failed call cannot expose another call's pending admission. +// The returned callback takes the result of local pool admission. +func (s *PrivateTxStore) Begin(hash common.Hash) func(bool) { + s.mu.Lock() + pending := s.pending[hash] + if pending == nil { + pending = new(privateTxReservation) + s.pending[hash] = pending + } + pending.inflight++ + s.mu.Unlock() + + var once sync.Once + return func(accepted bool) { + once.Do(func() { s.finishSubmission(hash, pending, accepted) }) + } +} + +func (s *PrivateTxStore) finishSubmission(hash common.Hash, pending *privateTxReservation, accepted bool) { + s.mu.Lock() + defer s.mu.Unlock() + + // Inclusion or an explicit purge invalidates callbacks from older attempts. + if s.pending[hash] != pending { + return + } + if accepted { + s.txs[hash] = time.Now() + s.txsAdded.Add(1) + } + pending.inflight-- + if pending.inflight == 0 { + delete(s.pending, hash) + } +} diff --git a/eth/relay/private_tx_store_submission_test.go b/eth/relay/private_tx_store_submission_test.go new file mode 100644 index 0000000000..95179af81c --- /dev/null +++ b/eth/relay/private_tx_store_submission_test.go @@ -0,0 +1,198 @@ +package relay + +import ( + "sync" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/event" + "github.com/stretchr/testify/require" +) + +func TestPrivateTxStoreSubmissionOutcomes(t *testing.T) { + for _, tc := range []struct { + name string + committed, first, second bool + }{ + {name: "all rejected"}, + {name: "first accepted", first: true}, + {name: "second accepted", second: true}, + {name: "both accepted", first: true, second: true}, + {name: "existing private", committed: true}, + } { + t.Run(tc.name, func(t *testing.T) { + s := NewPrivateTxStore() + defer s.Close() + hash := common.Hash{1} + if tc.committed { + s.Add(hash) + } + first, second := s.Begin(hash), s.Begin(hash) + require.True(t, s.IsTxPrivate(hash)) + first(tc.first) + require.True(t, s.IsTxPrivate(hash), "remaining admission must keep the hash private") + second(tc.second) + require.Equal(t, tc.committed || tc.first || tc.second, s.IsTxPrivate(hash)) + require.Empty(t, s.pending) + }) + } +} + +func TestPrivateTxStoreSubmissionConcurrentCompletion(t *testing.T) { + for _, accepted := range []bool{false, true} { + t.Run(map[bool]string{false: "all rejected", true: "one accepted"}[accepted], func(t *testing.T) { + s := NewPrivateTxStore() + defer s.Close() + hash := common.Hash{1} + finishes := make([]func(bool), 32) + for i := range finishes { + finishes[i] = s.Begin(hash) + } + var wg sync.WaitGroup + for i, finish := range finishes { + wg.Go(func() { finish(accepted && i == 0) }) + } + wg.Wait() + require.Equal(t, accepted, s.IsTxPrivate(hash)) + require.Empty(t, s.pending) + }) + } +} + +func TestPrivateTxStoreSubmissionFinishesOnce(t *testing.T) { + s := NewPrivateTxStore() + defer s.Close() + hash := common.Hash{1} + first, second := s.Begin(hash), s.Begin(hash) + first(false) + first(true) + require.True(t, s.IsTxPrivate(hash)) + require.Empty(t, s.txs, "repeated completion cannot commit a rejected submission") + second(false) + require.False(t, s.IsTxPrivate(hash)) + require.Empty(t, s.pending) +} + +func TestPrivateTxStoreSubmissionPreservesExplicitCommit(t *testing.T) { + s := NewPrivateTxStore() + defer s.Close() + hash := common.Hash{1} + finish := s.Begin(hash) + s.Add(hash) + finish(false) + require.True(t, s.IsTxPrivate(hash)) + require.Empty(t, s.pending) +} + +func TestPrivateTxStoreSubmissionPurgeInvalidates(t *testing.T) { + s := NewPrivateTxStore() + defer s.Close() + hash := common.Hash{1} + old := s.Begin(hash) + s.Purge(hash) + current := s.Begin(hash) + old(true) + require.Empty(t, s.txs, "completion from before purge cannot commit a later reservation") + current(false) + require.False(t, s.IsTxPrivate(hash)) + require.Empty(t, s.pending) +} + +func TestPrivateTxStoreSubmissionInclusionInvalidates(t *testing.T) { + s := NewPrivateTxStore() + defer s.Close() + tx := types.NewTx(&types.LegacyTx{Nonce: 1}) + finish := s.Begin(tx.Hash()) + var feed event.Feed + subscribed := make(chan struct{}) + s.SetchainEventSubFn(func(ch chan<- core.ChainEvent) event.Subscription { + sub := feed.Subscribe(ch) + close(subscribed) + return sub + }) + select { + case <-subscribed: + case <-time.After(time.Second): + t.Fatal("cleanup did not subscribe") + } + feed.Send(core.ChainEvent{Transactions: types.Transactions{tx}}) + require.Eventually(t, func() bool { return !s.IsTxPrivate(tx.Hash()) }, time.Second, time.Millisecond) + finish(true) + require.False(t, s.IsTxPrivate(tx.Hash()), "included submission was recreated by its callback") +} + +func TestPrivateTxStoreSubmissionProtectsSweep(t *testing.T) { + s := NewPrivateTxStore() + defer s.Close() + hash := common.Hash{1} + s.Add(hash) + s.mu.Lock() + s.txs[hash] = time.Now().Add(-time.Hour) + s.mu.Unlock() + finish := s.Begin(hash) + s.SetTxPoolChecker(func(common.Hash) bool { return false }) + s.sweepOnce() + require.True(t, s.IsTxPrivate(hash)) + require.Contains(t, s.txs, hash, "long-running admission lost its prior commitment") + finish(false) + require.True(t, s.IsTxPrivate(hash)) + s.sweepOnce() + require.False(t, s.IsTxPrivate(hash), "completed failed reservation prevented absent-marker expiry") +} + +func TestPrivateTxStoreSubmissionFailureDoesNotRefresh(t *testing.T) { + s := NewPrivateTxStore() + defer s.Close() + hash := common.Hash{1} + addedAt := time.Now().Add(-time.Hour) + s.mu.Lock() + s.txs[hash] = addedAt + s.mu.Unlock() + s.SetTxPoolChecker(func(common.Hash) bool { return false }) + for range 32 { + s.Begin(hash)(false) + } + s.mu.RLock() + current := s.txs[hash] + s.mu.RUnlock() + require.Equal(t, addedAt, current, "rejected submissions renewed the absent commitment") + s.sweepOnce() + require.False(t, s.IsTxPrivate(hash), "rejected submissions postponed absent-marker cleanup") +} + +func TestPrivateTxStoreSubmissionCommitProtectsStaleSweep(t *testing.T) { + s := NewPrivateTxStore() + defer s.Close() + hash := common.Hash{1} + s.mu.Lock() + s.txs[hash] = time.Now().Add(-time.Hour) + s.mu.Unlock() + checked, release, done := make(chan struct{}), make(chan struct{}), make(chan struct{}) + defer close(release) + s.SetTxPoolChecker(func(common.Hash) bool { + close(checked) + <-release + return false + }) + go func() { + s.sweepOnce() + close(done) + }() + select { + case <-checked: + case <-time.After(time.Second): + t.Fatal("sweep did not check the pool") + } + s.Begin(hash)(true) + release <- struct{}{} + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("sweep did not finish") + } + require.True(t, s.IsTxPrivate(hash), "stale pool absence removed a successful admission") + require.Zero(t, s.txsExpired.Load()) +} diff --git a/eth/relay/private_tx_store_test.go b/eth/relay/private_tx_store_test.go index d3dfb04a7a..17025cc5aa 100644 --- a/eth/relay/private_tx_store_test.go +++ b/eth/relay/private_tx_store_test.go @@ -205,74 +205,137 @@ func TestPrivateTxStoreCleanup(t *testing.T) { require.Equal(t, uint64(1), store.txsDeleted.Load(), "expected txsDeleted metric to be 1") } -// TestPrivateTxStoreSweepTxPoolCheck tests that the sweep removes entries -// that are no longer in the txpool (after the grace period). -func TestPrivateTxStoreSweepTxPoolCheck(t *testing.T) { +func TestPrivateTxStoreSweepPoolRetention(t *testing.T) { t.Parallel() + for _, test := range []struct { + name string + age time.Duration + checker bool + inPool bool + wantPrivate bool + }{ + {"fresh absent", time.Minute, true, false, true}, + {"absent past grace", 3 * time.Minute, true, false, false}, + {"absent past former TTL", 11 * time.Minute, true, false, false}, + {"present past former TTL", 11 * time.Minute, true, true, true}, + {"present for a day", 24 * time.Hour, true, true, true}, + {"unknown past grace", 3 * time.Minute, false, false, true}, + {"unknown past former TTL", 11 * time.Minute, false, false, true}, + } { + t.Run(test.name, func(t *testing.T) { + store := NewPrivateTxStore() + defer store.Close() + hash := common.HexToHash("0x1") + store.Add(hash) + store.mu.Lock() + store.txs[hash] = time.Now().Add(-test.age) + store.mu.Unlock() + if test.checker { + store.SetTxPoolChecker(func(common.Hash) bool { return test.inPool }) + } + store.sweepOnce() + require.Equal(t, test.wantPrivate, store.IsTxPrivate(hash)) + if test.wantPrivate { + require.Zero(t, store.txsExpired.Load()) + } else { + require.Equal(t, uint64(1), store.txsExpired.Load()) + } + }) + } +} +func TestPrivateTxStoreSweepCheckerAvailability(t *testing.T) { + t.Parallel() store := NewPrivateTxStore() defer store.Close() - - hash1 := common.HexToHash("0x1") // in pool - hash2 := common.HexToHash("0x2") // NOT in pool, past grace period - hash3 := common.HexToHash("0x3") // NOT in pool, but too fresh (within grace period) - - store.Add(hash1) - store.Add(hash2) - store.Add(hash3) - - // Backdate hash1 and hash2 past the grace period + hash := common.HexToHash("0x1") + store.Add(hash) store.mu.Lock() - store.txs[hash1] = time.Now().Add(-(privateTxGracePeriod + time.Minute)) - store.txs[hash2] = time.Now().Add(-(privateTxGracePeriod + time.Minute)) - // hash3 stays fresh (within grace period) + store.txs[hash] = time.Now().Add(-time.Hour) store.mu.Unlock() - // Set txpool checker: only hash1 is in the pool - poolSet := map[common.Hash]bool{hash1: true} - store.SetTxPoolChecker(func(hash common.Hash) bool { - return poolSet[hash] - }) - - // Exercise the actual sweep code path used by the background goroutine store.sweepOnce() - - require.True(t, store.IsTxPrivate(hash1), "hash1 should remain (still in pool)") - require.False(t, store.IsTxPrivate(hash2), "hash2 should be removed (not in pool, past grace)") - require.True(t, store.IsTxPrivate(hash3), "hash3 should remain (within grace period)") - require.Equal(t, uint64(1), store.txsExpired.Load(), "expected 1 txsExpired") + require.True(t, store.IsTxPrivate(hash)) + store.SetTxPoolChecker(func(common.Hash) bool { return true }) + store.sweepOnce() + require.True(t, store.IsTxPrivate(hash)) + store.SetTxPoolChecker(nil) + store.sweepOnce() + require.True(t, store.IsTxPrivate(hash)) + store.SetTxPoolChecker(func(common.Hash) bool { return false }) + store.sweepOnce() + require.False(t, store.IsTxPrivate(hash)) + require.Equal(t, uint64(1), store.txsExpired.Load()) } -// TestPrivateTxStoreSweepTTL tests that the sweep goroutine removes entries -// older than the TTL. -func TestPrivateTxStoreSweepTTL(t *testing.T) { +func TestPrivateTxStoreSweepPreservesRefresh(t *testing.T) { t.Parallel() + for name, purge := range map[string]bool{"refresh": false, "purge and re-add": true} { + t.Run(name, func(t *testing.T) { + store := NewPrivateTxStore() + defer store.Close() + hash := common.HexToHash("0x1") + store.Add(hash) + store.mu.Lock() + store.txs[hash] = time.Now().Add(-3 * time.Minute) + store.mu.Unlock() + + checked, release, done := make(chan struct{}), make(chan struct{}), make(chan struct{}) + defer close(release) + store.SetTxPoolChecker(func(common.Hash) bool { + close(checked) + <-release + return false + }) + go func() { + store.sweepOnce() + close(done) + }() + select { + case <-checked: + case <-time.After(time.Second): + t.Fatal("sweep did not check the pool") + } + if purge { + store.Purge(hash) + } + store.Add(hash) + release <- struct{}{} + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("sweep did not finish") + } + require.True(t, store.IsTxPrivate(hash)) + require.Zero(t, store.txsExpired.Load()) + }) + } +} +func TestPrivateTxStoreSweepConcurrentChecker(t *testing.T) { + t.Parallel() store := NewPrivateTxStore() defer store.Close() - - hash1 := common.HexToHash("0x1") - hash2 := common.HexToHash("0x2") - hash3 := common.HexToHash("0x3") - - store.Add(hash1) - store.Add(hash2) - store.Add(hash3) - - // Manually backdate hash1 and hash2 to be older than TTL + hash := common.HexToHash("0x1") + store.Add(hash) store.mu.Lock() - store.txs[hash1] = time.Now().Add(-(privateTxTTL + time.Minute)) - store.txs[hash2] = time.Now().Add(-(privateTxTTL + 2*time.Minute)) - // hash3 stays fresh + store.txs[hash] = time.Now().Add(-3 * time.Minute) store.mu.Unlock() - // Exercise the actual sweep code path used by the background goroutine - store.sweepOnce() - - require.False(t, store.IsTxPrivate(hash1), "hash1 should be removed after TTL") - require.False(t, store.IsTxPrivate(hash2), "hash2 should be removed after TTL") - require.True(t, store.IsTxPrivate(hash3), "hash3 should still be present (not expired)") - require.Equal(t, uint64(2), store.txsExpired.Load(), "expected 2 txsExpired") + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + for range 1000 { + store.SetTxPoolChecker(func(common.Hash) bool { return true }) + store.SetTxPoolChecker(nil) + } + }() + for range 1000 { + store.sweepOnce() + } + wg.Wait() + require.True(t, store.IsTxPrivate(hash)) } // TestPrivateTxStoreSetTxPoolChecker tests the SetTxPoolChecker method. diff --git a/eth/relay/relay.go b/eth/relay/relay.go index ed1ce4dd52..414a885713 100644 --- a/eth/relay/relay.go +++ b/eth/relay/relay.go @@ -64,6 +64,13 @@ func (s *RelayService) RecordPrivateTx(hash common.Hash) { } } +func (s *RelayService) BeginPrivateTx(hash common.Hash) func(bool) { + if s.privateTxStore != nil { + return s.privateTxStore.Begin(hash) + } + return func(bool) {} +} + func (s *RelayService) PurgePrivateTx(hash common.Hash) { if s.privateTxStore != nil { s.privateTxStore.Purge(hash) @@ -139,20 +146,7 @@ func (s *RelayService) SubmitPreconfTransactionSync(ctx context.Context, tx *typ } // SubmitPrivateTransaction submits a private transaction to block producers. -// Note: We deliberately do NOT purge the private-tx hash from the store on BP -// rejection. Various types of tx rejections are possible. -// - Permanent rejection (i.e. tx which will always be rejected). As the tx has -// already passed the relay's local txpool validation, invalid txs are already -// filtered out. -// - Permanent rejection due to BP config (e.g. tx fee cap set by BP) which will -// lead to relay accepting the tx but BPs rejecting it. Such instances should be -// logged but as they can't be fixed immediately by the relay, they'll be pruned -// after the default private tx store timeout. -// - Transient rejection due to reorg or nonce race. The relay's local pool will -// evict the tx eventually once it catches up with the txpool-aware sweep. -// - Transient rejection due to BPs current mempool state (e.g. pool full). This -// will be handled by local re-submissions in BPs. If the tx still remains invalid -// it will be evicted after the default private tx store timeout. +// Producer rejection cannot release privacy while the local pool retains the transaction. func (s *RelayService) SubmitPrivateTransaction(tx *types.Transaction) error { if s.txRelay == nil { return fmt.Errorf("request dropped: %w", errRelayNotConfigured) diff --git a/eth/sync.go b/eth/sync.go index 3db5cc5476..24bdf79365 100644 --- a/eth/sync.go +++ b/eth/sync.go @@ -37,22 +37,24 @@ const ( // syncTransactions starts sending all currently pending transactions to the given peer. func (h *handler) syncTransactions(p *eth.Peer) { + if hashes := h.pendingTransactionHashes(); len(hashes) > 0 { + p.AsyncSendPooledTransactionHashes(hashes) + } +} + +func (h *handler) pendingTransactionHashes() []common.Hash { var hashes []common.Hash for _, batch := range h.txpool.Pending(txpool.PendingFilter{BlobTxs: false}, nil) { for _, tx := range batch { - if h.privateTxGetter != nil && h.privateTxGetter.IsTxPrivate(tx.Hash) { - continue - } - if resolved := tx.Resolve(); resolved == nil || resolved.GetOptions() != nil { + resolved := tx.Resolve() + if resolved == nil || resolved.GetOptions() != nil || + (h.privateTxGetter != nil && h.privateTxGetter.IsTxPrivate(tx.Hash)) { continue } hashes = append(hashes, tx.Hash) } } - if len(hashes) == 0 { - return - } - p.AsyncSendPooledTransactionHashes(hashes) + return hashes } // chainSyncer coordinates blockchain sync components. diff --git a/eth/sync_completion_test.go b/eth/sync_completion_test.go new file mode 100644 index 0000000000..31e82f0338 --- /dev/null +++ b/eth/sync_completion_test.go @@ -0,0 +1,260 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package eth + +import ( + "math/big" + "sync" + "sync/atomic" + "testing" + "testing/synctest" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/consensus/ethash" + "github.com/ethereum/go-ethereum/core" + "github.com/ethereum/go-ethereum/core/rawdb" + "github.com/ethereum/go-ethereum/core/txpool" + "github.com/ethereum/go-ethereum/core/txpool/legacypool" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/eth/protocols/eth" + "github.com/ethereum/go-ethereum/p2p" + "github.com/ethereum/go-ethereum/p2p/enode" + "github.com/ethereum/go-ethereum/params" +) + +func TestEnableSyncedFeaturesAnnouncesExpiredPending(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + h, tx := syncCompletionRecoveryFixture(t) + peer, sink := syncCompletionPeer(t, h, eth.ETH69, 1) + h.syncTransactions(peer) + expectSyncCompletionAnnouncement(t, sink, tx) + pool := h.txpool.(*txpool.TxPool) + events := make(chan core.StuckTxsEvent, 1) + sub := pool.SubscribeRebroadcastTransactionsWithAcknowledgement(events) + defer sub.Unsubscribe() + time.Sleep(time.Second) + synctest.Wait() + select { + case event := <-events: + if len(event.Txs) != 1 || event.Txs[0].Hash() != tx.Hash() { + t.Fatal("unexpected pending transaction selected") + } + if h.rebroadcastStuckTransactions(event.Txs, pool.RebroadcastAcknowledgement(event.Txs)) { + t.Fatal("initial sync must suppress periodic rebroadcast") + } + default: + t.Fatal("missing first rebroadcast attempt") + } + time.Sleep(2 * time.Second) + synctest.Wait() + select { + case <-events: + t.Fatal("expired attempt must not remain eligible for periodic rebroadcast") + default: + } + if !pool.Has(tx.Hash()) || !peer.KnownTransaction(tx.Hash()) { + t.Fatal("test requires a retained transaction already announced on this connection") + } + h.enableSyncedFeatures() + expectSyncCompletionAnnouncement(t, sink, tx) + }) +} + +func TestEnableSyncedFeaturesPendingFilters(t *testing.T) { + for _, test := range []struct { + name, remove string + disabled bool + want bool + }{ + {name: "public", want: true}, + {name: "filtered only", remove: "public"}, + {name: "empty", remove: "all"}, + {name: "disabled", disabled: true}, + } { + t.Run(test.name, func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + h, pool, txs := syncCompletionFixture() + h.disableTxPropagation = test.disabled + if test.remove == "public" { + delete(pool.pool, txs[0].Hash()) + } else if test.remove == "all" { + clear(pool.pool) + } + peer, sink := syncCompletionPeer(t, h, eth.ETH69, 1) + h.snapSync.Store(true) + h.enableSyncedFeatures() + synctest.Wait() + if !h.synced.Load() || h.snapSync.Load() { + t.Fatal("sync completion did not preserve the sync state transition") + } + if peer.KnownTransaction(txs[0].Hash()) != test.want || peer.KnownTransaction(txs[1].Hash()) || peer.KnownTransaction(txs[2].Hash()) { + t.Fatal("sync completion announced an unexpected transaction set") + } + if test.want { + expectSyncCompletionAnnouncement(t, sink, txs[0]) + } else { + expectNoSyncCompletionAnnouncement(t, sink, txs[0]) + } + if test.disabled && pool.snapshots.Load() != 0 { + t.Fatal("disabled propagation read the pending pool") + } + }) + }) + } +} + +func TestEnableSyncedFeaturesSharesSnapshotOnce(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + h, pool, txs := syncCompletionFixture() + var sinks []*p2p.MsgPipeRW + for i, protocol := range eth.ProtocolVersions { + _, sink := syncCompletionPeer(t, h, protocol, byte(i+1)) + sinks = append(sinks, sink) + } + var wg sync.WaitGroup + for range 4 { + wg.Go(h.enableSyncedFeatures) + } + wg.Wait() + for _, sink := range sinks { + expectSyncCompletionAnnouncement(t, sink, txs[0]) + } + h.snapSync.Store(true) + h.enableSyncedFeatures() + if pool.snapshots.Load() != 1 || h.snapSync.Load() { + t.Fatal("repeated sync completion must preserve snap state without another pool snapshot") + } + for _, sink := range sinks { + expectNoSyncCompletionAnnouncement(t, sink, txs[0]) + } + }) +} + +type syncCompletionPool struct { + *testTxPool + snapshots atomic.Int32 +} + +func (p *syncCompletionPool) Pending(filter txpool.PendingFilter, interrupt *atomic.Bool) map[common.Address][]*txpool.LazyTransaction { + p.snapshots.Add(1) + return p.testTxPool.Pending(filter, interrupt) +} + +func syncCompletionFixture() (*handler, *syncCompletionPool, types.Transactions) { + pool := &syncCompletionPool{testTxPool: newTestTxPool()} + var txs types.Transactions + for nonce := range uint64(3) { + tx := types.NewTx(&types.LegacyTx{Nonce: nonce}) + pool.pool[tx.Hash()] = tx + txs = append(txs, tx) + } + txs[1].PutOptions(new(types.OptionsPIP15)) + h := &handler{txpool: pool, peers: newPeerSet(), privateTxGetter: &PrivateTxStore{ + store: map[common.Hash]struct{}{txs[2].Hash(): {}}, + }} + return h, pool, txs +} + +func syncCompletionRecoveryFixture(t *testing.T) (*handler, *types.Transaction) { + t.Helper() + db := rawdb.NewMemoryDatabase() + t.Cleanup(func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }) + genesis := &core.Genesis{Config: params.TestChainConfig, Alloc: types.GenesisAlloc{ + testAddr: {Balance: big.NewInt(1e18)}, + }} + chain, err := core.NewBlockChain(db, genesis, ethash.NewFaker(), nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(chain.Stop) + config := legacypool.DefaultConfig + config.Journal, config.NoLocals = "", true + config.RebroadcastInterval, config.RebroadcastMaxAge = time.Second, time.Hour + pool, err := txpool.New(1, chain, []txpool.SubPool{legacypool.New(config, chain)}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := pool.Close(); err != nil { + t.Error(err) + } + }) + tx, err := types.SignTx(types.NewTransaction(0, testAddr, big.NewInt(1), 21000, big.NewInt(params.BorDefaultTxPoolPriceLimit), nil), types.HomesteadSigner{}, testKey) + if err != nil { + t.Fatal(err) + } + tx.SetTime(time.Now().Add(-2 * time.Hour)) + if err := pool.Add(types.Transactions{tx}, true)[0]; err != nil { + t.Fatal(err) + } + return &handler{txpool: pool, peers: newPeerSet(), chain: chain}, tx +} + +func syncCompletionPeer(t *testing.T, h *handler, protocol uint, id byte) (*eth.Peer, *p2p.MsgPipeRW) { + t.Helper() + source, sink := p2p.MsgPipe() + peer := eth.NewPeer(protocol, p2p.NewPeer(enode.ID{id}, "", nil), source, h.txpool) + t.Cleanup(func() { + peer.Close() + source.Close() + sink.Close() + }) + if err := h.peers.registerPeer(peer, nil, nil); err != nil { + t.Fatal(err) + } + return peer, sink +} + +func syncCompletionAnnouncement(sink *p2p.MsgPipeRW, tx *types.Transaction) <-chan error { + result := make(chan error, 1) + go func() { + result <- p2p.ExpectMsg(sink, eth.NewPooledTransactionHashesMsg, ð.NewPooledTransactionHashesPacket{ + Types: []byte{tx.Type()}, Sizes: []uint32{uint32(tx.Size())}, Hashes: []common.Hash{tx.Hash()}, + }) + }() + return result +} + +func expectSyncCompletionAnnouncement(t *testing.T, sink *p2p.MsgPipeRW, tx *types.Transaction) { + t.Helper() + result := syncCompletionAnnouncement(sink, tx) + synctest.Wait() + select { + case err := <-result: + if err != nil { + t.Fatal(err) + } + default: + t.Fatal("missing pending announcement on the existing connection") + } +} + +func expectNoSyncCompletionAnnouncement(t *testing.T, sink *p2p.MsgPipeRW, tx *types.Transaction) { + t.Helper() + result := syncCompletionAnnouncement(sink, tx) + synctest.Wait() + select { + case err := <-result: + t.Fatalf("unexpected pending announcement: %v", err) + default: + } +} diff --git a/internal/ethapi/api.go b/internal/ethapi/api.go index 5c9ec321c6..a47cae10c4 100644 --- a/internal/ethapi/api.go +++ b/internal/ethapi/api.go @@ -2492,16 +2492,18 @@ func (api *TransactionAPI) SendRawTransactionPrivate(ctx context.Context, input return common.Hash{}, err } - // Track the tx hash to ensure it is not gossiped in public - api.b.RecordPrivateTx(tx.Hash()) + // A private duplicate must not temporarily hide an already-public transaction. + if api.b.GetPoolTransaction(tx.Hash()) != nil { + return common.Hash{}, txpool.ErrAlreadyKnown + } + + // Hide the hash before pool admission can emit a transaction event. + finish := api.b.BeginPrivateTx(tx.Hash()) + defer finish(false) hash, err := SubmitTransaction(ctx, api.b, tx) + finish(err == nil) if err != nil { - // Purge tx from private tx tracker if submission failed. Don't purge - // if `ErrAlreadyKnown` is being returned. - if !errors.Is(err, txpool.ErrAlreadyKnown) { - api.b.PurgePrivateTx(tx.Hash()) - } return hash, err } diff --git a/internal/ethapi/api_private_test.go b/internal/ethapi/api_private_test.go new file mode 100644 index 0000000000..71eb7d7a77 --- /dev/null +++ b/internal/ethapi/api_private_test.go @@ -0,0 +1,193 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package ethapi + +import ( + "context" + "errors" + "sync/atomic" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/consensus/ethash" + "github.com/ethereum/go-ethereum/core" + "github.com/ethereum/go-ethereum/core/txpool" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/eth/relay" + "github.com/ethereum/go-ethereum/params" + "github.com/stretchr/testify/require" +) + +type privateSubmissionBackend struct { + *testBackend + send func(context.Context, *types.Transaction) error + pooled *types.Transaction +} + +func (b *privateSubmissionBackend) SendTx(ctx context.Context, tx *types.Transaction) error { + return b.send(ctx, tx) +} + +func (b *privateSubmissionBackend) GetPoolTransaction(hash common.Hash) *types.Transaction { + if b.pooled != nil && b.pooled.Hash() == hash { + return b.pooled + } + return nil +} + +func TestSendRawTransactionPrivateConcurrentSubmission(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + first, second bool + }{ + {name: "all rejected"}, + {name: "first accepted", first: true}, + {name: "second accepted", second: true}, + } { + t.Run(tc.name, func(t *testing.T) { testPrivateConcurrentSubmission(t, tc.first, tc.second) }) + } +} + +func testPrivateConcurrentSubmission(t *testing.T, firstAccepted, secondAccepted bool) { + t.Helper() + genesis := &core.Genesis{Config: params.TestChainConfig, Alloc: types.GenesisAlloc{}} + base := newTestBackend(t, 0, genesis, ethash.NewFaker(), nil) + defer base.chain.Stop() + base.acceptPrivateTxs = true + store := relay.NewPrivateTxStore() + defer store.Close() + base.recordPrivateTxFn, base.purgePrivateTxFn = store.Add, store.Purge + base.beginPrivateTxFn = store.Begin + started, release := make(chan struct{}), make(chan struct{}) + defer close(release) + var calls atomic.Int32 + b := &privateSubmissionBackend{testBackend: base} + b.send = func(ctx context.Context, tx *types.Transaction) error { + if calls.Add(1) == 1 { + close(started) + <-release + if firstAccepted { + return base.SendTx(ctx, tx) + } + return txpool.ErrInflightTxLimitReached + } + if secondAccepted { + return base.SendTx(ctx, tx) + } + return txpool.ErrInflightTxLimitReached + } + api := NewTransactionAPI(b, new(AddrLocker)) + raw, tx := makeSelfSignedRaw(t, api, base.acc.Address) + failed := make(chan error, 1) + go func() { + _, err := api.SendRawTransactionPrivate(t.Context(), raw) + failed <- err + }() + select { + case <-started: + case <-time.After(5 * time.Second): + t.Fatal("first submission did not reach admission") + } + hash, err := api.SendRawTransactionPrivate(t.Context(), raw) + if secondAccepted { + require.NoError(t, err) + require.Equal(t, tx.Hash(), hash) + } else { + require.ErrorIs(t, err, txpool.ErrInflightTxLimitReached) + } + require.True(t, store.IsTxPrivate(tx.Hash()), "pending admission must remain private") + release <- struct{}{} + select { + case err := <-failed: + if firstAccepted { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, txpool.ErrInflightTxLimitReached) + } + case <-time.After(5 * time.Second): + t.Fatal("first submission did not finish") + } + require.Equal(t, firstAccepted || secondAccepted, store.IsTxPrivate(tx.Hash()), "final marker must reflect successful admission") +} + +func TestSendRawTransactionPrivateRelayFailurePreservesCommitment(t *testing.T) { + genesis := &core.Genesis{Config: params.TestChainConfig, Alloc: types.GenesisAlloc{}} + base := newTestBackend(t, 0, genesis, ethash.NewFaker(), nil) + defer base.chain.Stop() + base.privateTxEnabled = true + store := relay.NewPrivateTxStore() + defer store.Close() + base.beginPrivateTxFn = store.Begin + base.submitPrivateTxFn = func(tx *types.Transaction) error { + require.True(t, store.IsTxPrivate(tx.Hash()), "local admission must remain private during relay submission") + return errors.New("relay unavailable") + } + api := NewTransactionAPI(base, new(AddrLocker)) + raw, tx := makeSelfSignedRaw(t, api, base.acc.Address) + hash, err := api.SendRawTransactionPrivate(t.Context(), raw) + require.ErrorContains(t, err, "accepted locally, submission failed") + require.Equal(t, tx.Hash(), hash) + require.True(t, store.IsTxPrivate(hash), "relay failure removed an accepted local marker") +} + +func TestSendRawTransactionPrivatePanicReleasesReservation(t *testing.T) { + genesis := &core.Genesis{Config: params.TestChainConfig, Alloc: types.GenesisAlloc{}} + base := newTestBackend(t, 0, genesis, ethash.NewFaker(), nil) + defer base.chain.Stop() + base.acceptPrivateTxs = true + store := relay.NewPrivateTxStore() + defer store.Close() + base.beginPrivateTxFn = store.Begin + backend := &privateSubmissionBackend{testBackend: base} + backend.send = func(context.Context, *types.Transaction) error { panic("pool unavailable") } + api := NewTransactionAPI(backend, new(AddrLocker)) + raw, tx := makeSelfSignedRaw(t, api, base.acc.Address) + require.Panics(t, func() { _, _ = api.SendRawTransactionPrivate(t.Context(), raw) }) + require.False(t, store.IsTxPrivate(tx.Hash()), "recovered admission panic retained a reservation") +} + +func TestSendRawTransactionPrivateKnownPublicRemainsVisible(t *testing.T) { + genesis := &core.Genesis{Config: params.TestChainConfig, Alloc: types.GenesisAlloc{}} + base := newTestBackend(t, 0, genesis, ethash.NewFaker(), nil) + defer base.chain.Stop() + base.acceptPrivateTxs = true + store := relay.NewPrivateTxStore() + defer store.Close() + begins, admissions := 0, 0 + base.beginPrivateTxFn = func(hash common.Hash) func(bool) { + begins++ + return store.Begin(hash) + } + backend := &privateSubmissionBackend{testBackend: base} + backend.send = func(_ context.Context, tx *types.Transaction) error { + admissions++ + require.False(t, store.IsTxPrivate(tx.Hash()), "public transaction hidden during private duplicate admission") + return txpool.ErrAlreadyKnown + } + api := NewTransactionAPI(backend, new(AddrLocker)) + raw, tx := makeSelfSignedRaw(t, api, base.acc.Address) + backend.pooled = tx + require.False(t, store.IsTxPrivate(tx.Hash())) + hash, err := api.SendRawTransactionPrivate(t.Context(), raw) + require.ErrorIs(t, err, txpool.ErrAlreadyKnown) + require.Equal(t, common.Hash{}, hash) + require.Zero(t, begins, "known public duplicate must not reserve a private marker") + require.Zero(t, admissions, "known duplicate must not enter admission") + require.False(t, store.IsTxPrivate(tx.Hash())) +} diff --git a/internal/ethapi/api_test.go b/internal/ethapi/api_test.go index b63ce36746..333ddbac84 100644 --- a/internal/ethapi/api_test.go +++ b/internal/ethapi/api_test.go @@ -470,6 +470,7 @@ type testBackend struct { checkPreconfStatusFn func(common.Hash) (bool, error) submitPrivateTxFn func(*types.Transaction) error recordPrivateTxFn func(common.Hash) + beginPrivateTxFn func(common.Hash) func(bool) purgePrivateTxFn func(common.Hash) // Error to inject from SendTx (nil = existing logic) @@ -549,6 +550,14 @@ func (b testBackend) RecordPrivateTx(hash common.Hash) { b.recordPrivateTxFn(hash) } } +func (b testBackend) BeginPrivateTx(hash common.Hash) func(bool) { + if b.beginPrivateTxFn != nil { + finish := b.beginPrivateTxFn(hash) + var once sync.Once + return func(accepted bool) { once.Do(func() { finish(accepted) }) } + } + return func(bool) {} +} func (b testBackend) PurgePrivateTx(hash common.Hash) { if b.purgePrivateTxFn != nil { b.purgePrivateTxFn(hash) @@ -4879,7 +4888,7 @@ func TestSendRawTransactionPrivate(t *testing.T) { require.Error(t, err) }) - t.Run("accepted via AcceptPrivateTxs, RecordPrivateTx called", func(t *testing.T) { + t.Run("accepted via AcceptPrivateTxs, reservation committed", func(t *testing.T) { t.Parallel() genesis := &core.Genesis{Config: params.TestChainConfig, Alloc: types.GenesisAlloc{}} b := newTestBackend(t, 0, genesis, ethash.NewFaker(), nil) @@ -4887,7 +4896,10 @@ func TestSendRawTransactionPrivate(t *testing.T) { var recordCount atomic.Int32 var submitPrivateCount atomic.Int32 - b.recordPrivateTxFn = func(hash common.Hash) { recordCount.Add(1) } + b.beginPrivateTxFn = func(hash common.Hash) func(bool) { + recordCount.Add(1) + return func(accepted bool) { require.True(t, accepted) } + } b.submitPrivateTxFn = func(tx *types.Transaction) error { submitPrivateCount.Add(1); return nil } api := NewTransactionAPI(b, new(AddrLocker)) @@ -4896,11 +4908,11 @@ func TestSendRawTransactionPrivate(t *testing.T) { hash, err := api.SendRawTransactionPrivate(context.Background(), raw) require.NoError(t, err) require.Equal(t, tx.Hash(), hash) - require.Equal(t, int32(1), recordCount.Load(), "RecordPrivateTx should be called once") + require.Equal(t, int32(1), recordCount.Load(), "private reservation should be started once") require.Equal(t, int32(0), submitPrivateCount.Load(), "SubmitPrivateTx should NOT be called when PrivateTxEnabled is false") }) - t.Run("SendTx fails, PurgePrivateTx called", func(t *testing.T) { + t.Run("SendTx fails, reservation completed without commitment", func(t *testing.T) { t.Parallel() genesis := &core.Genesis{Config: params.TestChainConfig, Alloc: types.GenesisAlloc{}} b := newTestBackend(t, 0, genesis, ethash.NewFaker(), nil) @@ -4908,9 +4920,11 @@ func TestSendRawTransactionPrivate(t *testing.T) { b.sendTxErr = errors.New("pool full") var recordCount atomic.Int32 - var purgeCount atomic.Int32 - b.recordPrivateTxFn = func(hash common.Hash) { recordCount.Add(1) } - b.purgePrivateTxFn = func(hash common.Hash) { purgeCount.Add(1) } + var finishCount atomic.Int32 + b.beginPrivateTxFn = func(hash common.Hash) func(bool) { + recordCount.Add(1) + return func(accepted bool) { require.False(t, accepted); finishCount.Add(1) } + } api := NewTransactionAPI(b, new(AddrLocker)) raw, _ := makeSelfSignedRaw(t, api, b.acc.Address) @@ -4918,26 +4932,28 @@ func TestSendRawTransactionPrivate(t *testing.T) { _, err := api.SendRawTransactionPrivate(context.Background(), raw) require.Error(t, err) require.Contains(t, err.Error(), "pool full") - require.Equal(t, int32(1), recordCount.Load(), "RecordPrivateTx should be called before SendTx") - require.Equal(t, int32(1), purgeCount.Load(), "PurgePrivateTx should be called on SendTx failure") + require.Equal(t, int32(1), recordCount.Load(), "private reservation should precede SendTx") + require.Equal(t, int32(1), finishCount.Load(), "failed submission should finish its reservation") }) - t.Run("ErrAlreadyKnown does not purge", func(t *testing.T) { + t.Run("ErrAlreadyKnown completes failed reservation", func(t *testing.T) { t.Parallel() genesis := &core.Genesis{Config: params.TestChainConfig, Alloc: types.GenesisAlloc{}} b := newTestBackend(t, 0, genesis, ethash.NewFaker(), nil) b.acceptPrivateTxs = true b.sendTxErr = txpool.ErrAlreadyKnown - var purgeCount atomic.Int32 - b.purgePrivateTxFn = func(hash common.Hash) { purgeCount.Add(1) } + var finishCount atomic.Int32 + b.beginPrivateTxFn = func(hash common.Hash) func(bool) { + return func(accepted bool) { require.False(t, accepted); finishCount.Add(1) } + } api := NewTransactionAPI(b, new(AddrLocker)) raw, _ := makeSelfSignedRaw(t, api, b.acc.Address) _, err := api.SendRawTransactionPrivate(context.Background(), raw) require.ErrorIs(t, err, txpool.ErrAlreadyKnown) - require.Equal(t, int32(0), purgeCount.Load(), "PurgePrivateTx should NOT be called for ErrAlreadyKnown") + require.Equal(t, int32(1), finishCount.Load(), "already-known submission should finish its reservation") }) t.Run("PrivateTxEnabled, SubmitPrivateTx succeeds", func(t *testing.T) { diff --git a/internal/ethapi/backend.go b/internal/ethapi/backend.go index 1da35e26a9..1a52484f08 100644 --- a/internal/ethapi/backend.go +++ b/internal/ethapi/backend.go @@ -76,6 +76,7 @@ type Backend interface { AcceptPreconfTxs() bool AcceptPrivateTxs() bool RecordPrivateTx(hash common.Hash) + BeginPrivateTx(hash common.Hash) func(bool) PurgePrivateTx(hash common.Hash) // Blockchain API diff --git a/internal/ethapi/transaction_args_test.go b/internal/ethapi/transaction_args_test.go index 62b3380c23..5cb42ac3f4 100644 --- a/internal/ethapi/transaction_args_test.go +++ b/internal/ethapi/transaction_args_test.go @@ -327,6 +327,7 @@ func (b *backendMock) SubmitPrivateTx(tx *types.Transaction) error { retur func (b *backendMock) AcceptPreconfTxs() bool { return false } func (b *backendMock) AcceptPrivateTxs() bool { return false } func (b *backendMock) RecordPrivateTx(hash common.Hash) {} +func (b *backendMock) BeginPrivateTx(hash common.Hash) func(bool) { return func(bool) {} } func (b *backendMock) PurgePrivateTx(hash common.Hash) {} func (b *backendMock) SuggestGasTipCap(ctx context.Context) (*big.Int, error) { From fefec2fd8423304276b0215d0a6ec05f63d1bf55 Mon Sep 17 00:00:00 2001 From: Vikram Bhattacharjee Date: Wed, 21 Oct 2026 14:20:40 +0530 Subject: [PATCH 02/15] eth, eth/fetcher: preserve propagation retries under peer backpressure --- eth/api_backend_private_lifecycle_test.go | 3 + eth/api_backend_private_test.go | 1 + eth/fetcher/block_fetcher.go | 19 +- eth/fetcher/block_fetcher_alternates.go | 75 ++++++ eth/fetcher/block_fetcher_alternates_test.go | 166 +++++++++++++ eth/fetcher/block_fetcher_schedule.go | 70 +++--- .../block_fetcher_schedule_edges_test.go | 12 +- eth/fetcher/block_fetcher_schedule_test.go | 4 +- eth/handler.go | 5 +- eth/relay/relay_private_submission_test.go | 69 ++++++ eth/sync_completion.go | 88 +++++++ eth/sync_completion_retry_test.go | 224 ++++++++++++++++++ 12 files changed, 690 insertions(+), 46 deletions(-) create mode 100644 eth/fetcher/block_fetcher_alternates.go create mode 100644 eth/fetcher/block_fetcher_alternates_test.go create mode 100644 eth/relay/relay_private_submission_test.go create mode 100644 eth/sync_completion.go create mode 100644 eth/sync_completion_retry_test.go diff --git a/eth/api_backend_private_lifecycle_test.go b/eth/api_backend_private_lifecycle_test.go index 318bc9e022..815d6382fe 100644 --- a/eth/api_backend_private_lifecycle_test.go +++ b/eth/api_backend_private_lifecycle_test.go @@ -65,6 +65,9 @@ func TestSendTxPrivateRejectedThenPublic(t *testing.T) { if err := b.SendTx(t.Context(), first); err != nil { t.Fatal(err) } + if err := b.eth.txPool.Sync(); err != nil { + t.Fatal(err) + } tx := makeTx(1, nil, nil, key) raw, err := tx.MarshalBinary() if err != nil { diff --git a/eth/api_backend_private_test.go b/eth/api_backend_private_test.go index c6c9a9dd53..3098485477 100644 --- a/eth/api_backend_private_test.go +++ b/eth/api_backend_private_test.go @@ -53,6 +53,7 @@ func TestSendTxPrivateAdmission(t *testing.T) { b := privateSubmissionBackend(t, true) first := pricedSetCodeTx(0, 250000, uint256.NewInt(25*params.GWei), uint256.NewInt(25*params.GWei), key, []unsignedAuth{{nonce: 0, key: key}}) require.NoError(t, b.SendTx(t.Context(), first)) + require.NoError(t, b.eth.txPool.Sync()) tx := makeTx(1, nil, nil, key) if private { b.RecordPrivateTx(tx.Hash()) diff --git a/eth/fetcher/block_fetcher.go b/eth/fetcher/block_fetcher.go index 3d3134c331..bf4670bd18 100644 --- a/eth/fetcher/block_fetcher.go +++ b/eth/fetcher/block_fetcher.go @@ -246,6 +246,8 @@ type BlockFetcher struct { // dispatch before their response deadline exists. fetchSlots map[string]int fetchSlotsTotal int + fetchRetry time.Duration + completeRetry time.Duration // Block cache queue *prque.Prque[int64, *blockOrHeaderInject] // Queue containing the import operations (block number sorted) @@ -532,7 +534,9 @@ func (f *BlockFetcher) loop() { expiredHashes := f.expiredFetches() for _, hash := range expiredHashes { - f.forgetHash(hash) + f.mu.Lock() + delete(f.fetching, hash) + f.mu.Unlock() } // Import any queued blocks that could potentially fit height := f.chainHeight() @@ -619,11 +623,8 @@ func (f *BlockFetcher) loop() { blockAnnounceDropMeter.Mark(1) break } - // All is well, schedule the announce if block's not yet downloading or completing or pending witness - if _, ok := f.fetching[notification.hash]; ok { - break - } - if _, ok := f.completing[notification.hash]; ok { + // Keep distinct alternatives while another peer serves the block. + if f.hasAnnouncer(notification.hash, notification.origin) { break } if f.wm.isPending(notification.hash) { @@ -921,7 +922,7 @@ func (f *BlockFetcher) rescheduleFetch(fetch *time.Timer, deferred bool) { // Schedule announcement retrieval quickly for light mode // since server won't send any headers to client. if f.light { - fetch.Reset(lightTimeout) + fetch.Reset(nextFetchRetry(lightTimeout, deferred, &f.fetchRetry)) return } // Otherwise find the earliest expiring announcement @@ -932,7 +933,7 @@ func (f *BlockFetcher) rescheduleFetch(fetch *time.Timer, deferred bool) { } } - fetch.Reset(retryAfter(arriveTimeout-time.Since(earliest), deferred)) + fetch.Reset(nextFetchRetry(arriveTimeout-time.Since(earliest), deferred, &f.fetchRetry)) } // retryAfter floors a timer delay for a pass that left announcements behind @@ -962,7 +963,7 @@ func (f *BlockFetcher) rescheduleComplete(complete *time.Timer, deferred bool) { } } - complete.Reset(retryAfter(gatherSlack-time.Since(earliest), deferred)) + complete.Reset(nextFetchRetry(gatherSlack-time.Since(earliest), deferred, &f.completeRetry)) } // enqueue schedules a new header or block import operation, if the component diff --git a/eth/fetcher/block_fetcher_alternates.go b/eth/fetcher/block_fetcher_alternates.go new file mode 100644 index 0000000000..00c6ed9465 --- /dev/null +++ b/eth/fetcher/block_fetcher_alternates.go @@ -0,0 +1,75 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package fetcher + +import ( + "time" + + "github.com/ethereum/go-ethereum/common" +) + +// Alternatives retain their announcement credits until tried or forgotten. +// A timeout therefore cannot discard the only notification from a healthy peer. +func (f *BlockFetcher) consumeAnnouncement(hash common.Hash, origin string) { + announces := f.announced[hash] + remaining := announces[:0] + for _, announce := range announces { + if announce.origin != origin { + remaining = append(remaining, announce) + continue + } + f.announces[origin]-- + if f.announces[origin] <= 0 { + delete(f.announces, origin) + } + } + clear(announces[len(remaining):]) + if len(remaining) > 0 { + f.announced[hash] = remaining + return + } + delete(f.announced, hash) + if f.announceChangeHook != nil { + f.announceChangeHook(hash, false) + } +} + +func (f *BlockFetcher) hasAnnouncer(hash common.Hash, origin string) bool { + for _, active := range []*blockAnnounce{f.fetching[hash], f.completing[hash]} { + if active != nil && active.origin == origin { + return true + } + } + for _, announces := range [][]*blockAnnounce{f.announced[hash], f.fetched[hash]} { + for _, announce := range announces { + if announce.origin == origin { + return true + } + } + } + return false +} + +func nextFetchRetry(delay time.Duration, deferred bool, backoff *time.Duration) time.Duration { + if !deferred { + *backoff = 0 + return delay + } + previous := *backoff + *backoff = min(retryAfter(2*previous, true), time.Second) + return max(delay, *backoff) +} diff --git a/eth/fetcher/block_fetcher_alternates_test.go b/eth/fetcher/block_fetcher_alternates_test.go new file mode 100644 index 0000000000..5db35f8e9a --- /dev/null +++ b/eth/fetcher/block_fetcher_alternates_test.go @@ -0,0 +1,166 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package fetcher + +import ( + "testing" + "testing/synctest" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" +) + +func TestHeaderSchedulingSkipsSaturatedLookups(t *testing.T) { + p := newScheduleProbe(t) + f := p.fetcher + for range maxPeerFetchRequests { + if !f.acquireFetchSlot("busy") { + t.Fatal("could not fill slots") + } + } + for range hashLimit { + p.announce("busy", time.Second) + } + lookups := 0 + f.getBlock = func(common.Hash) *types.Block { lookups++; return nil } + for range 10 { + if !f.scheduleHeaderFetches() { + t.Fatal("saturated announcements were not deferred") + } + } + if lookups != 0 { + t.Fatalf("full slots caused %d chain lookups", lookups) + } + f.releaseFetchSlot("busy") + f.scheduleHeaderFetches() + if lookups != 1 { + t.Fatalf("one available slot caused %d lookups, want 1", lookups) + } +} + +func TestBlockFetchTimeoutUsesAlternative(t *testing.T) { + hashes, blocks := makeChain(1, 42, genesis) + block := blocks[hashes[0]] + for _, headers := range []bool{true, false} { + name := "body" + if headers { + name = "header" + } + t.Run(name, func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + tester := newTester(false) + defer tester.fetcher.Stop() + defer tester.fetcher.wm.witnessCache.Stop() + imported := make(chan struct{}, 1) + tester.fetcher.importedHook = func(*types.Header, *types.Block) { imported <- struct{}{} } + request, _ := requestDelayedBlock(t, tester, blocks, block, headers) + if err := tester.fetcher.Notify("healthy", block.Hash(), block.NumberU64(), time.Now(), + tester.makeHeaderFetcher("healthy", blocks, 0), tester.makeBodyFetcher("healthy", blocks, 0), nil); err != nil { + t.Fatal(err) + } + time.Sleep(3 * fetchTimeout) + synctest.Wait() + select { + case <-imported: + default: + t.Fatal("the healthy peer's only announcement was lost on timeout") + } + select { + case <-request.Cancel: + default: + t.Fatal("timed-out request remained open") + } + assertBlockPeerRetained(t, tester) + }) + }) + } +} + +func TestHeaderSchedulingPreservesAlternativeCredits(t *testing.T) { + p := newScheduleProbe(t) + f := p.fetcher + hash := p.announce("slow", time.Second) + slow := f.announced[hash][0] + healthy := &blockAnnounce{hash: hash, origin: "healthy", time: time.Now().Add(-time.Second)} + f.announced[hash] = append(f.announced[hash], healthy) + f.announces["healthy"] = 1 + f.admitHeaderRequest(hash, slow) + if f.announces["slow"] != 0 || f.announces["healthy"] != 1 || len(f.announced[hash]) != 1 { + t.Fatal("selection consumed an alternative's announcement credit") + } + if !f.hasAnnouncer(hash, "slow") || !f.hasAnnouncer(hash, "healthy") || f.hasAnnouncer(hash, "other") { + t.Fatal("pending and active announcers were not deduplicated") + } + f.releaseFetchOwnership(blockFetchRelease{headers: true, announces: []*blockAnnounce{slow}}) + f.admitHeaderRequest(hash, healthy) + if len(f.announces) != 0 || len(f.announced) != 0 || f.fetching[hash] != healthy { + t.Fatal("retry did not consume exactly the alternative's credit") + } + f.releaseFetchOwnership(blockFetchRelease{headers: true, announces: []*blockAnnounce{slow}}) + if f.fetching[hash] != healthy { + t.Fatal("old request release erased the alternative") + } +} + +func TestFetchDeferredRetryBackoff(t *testing.T) { + var backoff time.Duration + for _, want := range []time.Duration{20 * time.Millisecond, 40 * time.Millisecond, 80 * time.Millisecond} { + if got := nextFetchRetry(-time.Second, true, &backoff); got != want { + t.Fatalf("retry delay = %v, want %v", got, want) + } + } + for range 10 { + nextFetchRetry(0, true, &backoff) + } + if backoff != time.Second { + t.Fatalf("retry ceiling = %v, want 1s", backoff) + } + if got := nextFetchRetry(2*time.Second, true, &backoff); got != 2*time.Second { + t.Fatal("retry preceded the announcement's gather window") + } + if nextFetchRetry(-time.Second, false, &backoff) != -time.Second || backoff != 0 { + t.Fatal("successful scheduling did not reset backoff") + } +} + +func TestHeaderSchedulingLightLookup(t *testing.T) { + for _, known := range []bool{false, true} { + t.Run(map[bool]string{false: "unknown", true: "known"}[known], func(t *testing.T) { + p := newScheduleProbe(t) + f := p.fetcher + f.light = true + lookups := 0 + f.getHeader = func(common.Hash) *types.Header { + lookups++ + if known { + return genesis.Header() + } + return nil + } + f.getBlock = func(common.Hash) *types.Block { t.Error("light fetch read a full block"); return nil } + hash := p.announce("peer", time.Millisecond) + f.scheduleHeaderFetches() + if lookups != 1 || (f.fetching[hash] != nil) == known { + t.Fatal("light scheduling did not use the header lookup without a gather delay") + } + if known && f.fetchSlotsTotal != 0 { + t.Fatal("known header retained a request slot") + } + }) + } +} diff --git a/eth/fetcher/block_fetcher_schedule.go b/eth/fetcher/block_fetcher_schedule.go index 97248b8696..6093bc269b 100644 --- a/eth/fetcher/block_fetcher_schedule.go +++ b/eth/fetcher/block_fetcher_schedule.go @@ -32,31 +32,19 @@ func (f *BlockFetcher) scheduleHeaderFetches() bool { deferred := false for hash, announces := range f.announced { - // In current LES protocol(les2/les3), only header announce is - // available, no need to wait too much time for header broadcast. - wait := arriveTimeout - gatherSlack - if f.light { - wait = 0 - } - if time.Since(announces[0].time) <= wait { - continue - } - if !f.readyToFetch(hash) { - f.dropAnnouncements(hash, announces) - continue - } - announce := f.acquireAnnouncer(announces, awaitingBodies) + announce, retry := f.prepareHeaderFetch(hash, announces, awaitingBodies) + deferred = deferred || retry if announce == nil { - // No announcer has capacity to spare for a header right now. - // rescheduleFetch holds the retry off so this does not spin. - deferred = true - continue } req := f.admitHeaderRequest(hash, announce) + deferred = deferred || len(f.announced[hash]) > 0 requests[req.announce.origin] = append(requests[req.announce.origin], req) } + if len(requests) > 0 { + f.fetchRetry = 0 + } for peer, batch := range requests { f.startHeaderRequests(peer, batch) } @@ -64,6 +52,34 @@ func (f *BlockFetcher) scheduleHeaderFetches() bool { return deferred } +func (f *BlockFetcher) prepareHeaderFetch(hash common.Hash, announces []*blockAnnounce, awaitingBodies map[string]bool) (*blockAnnounce, bool) { + wait := arriveTimeout - gatherSlack + if f.light { + wait = 0 + } + if time.Since(announces[0].time) <= wait { + return nil, false + } + if f.fetchInProgress(hash) { + return nil, true + } + announce := f.acquireAnnouncer(announces, awaitingBodies) + if announce == nil { + return nil, true + } + // Unknown block lookups may hit disk, so reserve capacity before reading. + if !f.readyToFetch(hash) { + f.releaseFetchSlot(announce.origin) + f.dropAnnouncements(hash, announces) + return nil, false + } + return announce, false +} + +func (f *BlockFetcher) fetchInProgress(hash common.Hash) bool { + return f.fetching[hash] != nil || len(f.fetched[hash]) > 0 || f.completing[hash] != nil +} + // peersAwaitingBodies lists the peers holding headers that still need a body // request, computed once per pass so header admission can leave their slots // alone. Scheduling bodies first is not enough on its own: a worker can free a @@ -104,7 +120,7 @@ func (f *BlockFetcher) expiredFetches() []common.Hash { // the request so latency metrics still measure from the announcement. func (f *BlockFetcher) admitHeaderRequest(hash common.Hash, announce *blockAnnounce) headerRequest { announcedAt := announce.time - f.forgetHash(hash) // This also removes from f.announced + f.consumeAnnouncement(hash, announce.origin) announce.time = time.Now() f.fetching[hash] = announce @@ -112,15 +128,12 @@ func (f *BlockFetcher) admitHeaderRequest(hash common.Hash, announce *blockAnnou } // readyToFetch reports whether a hash still needs a header request, rather than -// already being held by the witness manager, the import queue, a later fetch -// stage, or the local chain. +// already being held by the witness manager, the import queue, or the local +// chain. The caller checks active fetch stages before reserving capacity. func (f *BlockFetcher) readyToFetch(hash common.Hash) bool { if f.wm.isPending(hash) || f.queued[hash] != nil { return false } - if f.fetched[hash] != nil || f.completing[hash] != nil { - return false - } if f.light { return f.getHeader(hash) == nil } @@ -180,17 +193,20 @@ func (f *BlockFetcher) scheduleBodyFetches() bool { } f.groupBodyRequest(requests, hash, announce) } + if len(requests) > 0 { + f.completeRetry = 0 + } f.startBodyRequests(requests, admitted) return deferred } // groupBodyRequest moves one fetched header into the completion stage and adds -// it to its peer's batch. The hash is forgotten first so a block that arrived -// by another route is dropped rather than re-requested. +// it to its peer's batch. Keep alternative announcements until the body arrives +// so an unanswered request can restart from another origin. func (f *BlockFetcher) groupBodyRequest(requests map[string]*bodyRequest, hash common.Hash, announce *blockAnnounce) { announcedAt := announce.time - f.forgetHash(hash) // Removes from f.fetched + delete(f.fetched, hash) if f.wm.isPending(hash) || f.getBlock(hash) != nil { return diff --git a/eth/fetcher/block_fetcher_schedule_edges_test.go b/eth/fetcher/block_fetcher_schedule_edges_test.go index 06fb4d52a9..d1760fac53 100644 --- a/eth/fetcher/block_fetcher_schedule_edges_test.go +++ b/eth/fetcher/block_fetcher_schedule_edges_test.go @@ -37,21 +37,23 @@ func TestHeaderSchedulingWaitsForAnnouncement(t *testing.T) { } func TestHeaderSchedulingKeepsLaterStages(t *testing.T) { - for _, stage := range []string{"fetched", "completing"} { + for _, stage := range []string{"fetching", "fetched", "completing"} { t.Run(stage, func(t *testing.T) { p := newScheduleProbe(t) f := p.fetcher hash := p.announce("new", time.Second) owned := &blockAnnounce{hash: hash, origin: "existing", time: time.Now()} - if stage == "fetched" { + if stage == "fetching" { + f.fetching[hash] = owned + } else if stage == "fetched" { f.fetched[hash] = []*blockAnnounce{owned} } else { f.completing[hash] = owned } - if f.scheduleHeaderFetches() { - t.Fatal("duplicate announcement deferred") + if !f.scheduleHeaderFetches() { + t.Fatal("alternative announcement was not deferred") } - if f.fetchSlotsTotal != 0 || f.fetching[hash] != nil || len(f.announced[hash]) != 0 { + if f.fetchSlotsTotal != 0 || len(f.announced[hash]) != 1 { t.Fatal("duplicate announcement started another header request") } if stage == "fetched" && (len(f.fetched[hash]) != 1 || f.fetched[hash][0] != owned) { diff --git a/eth/fetcher/block_fetcher_schedule_test.go b/eth/fetcher/block_fetcher_schedule_test.go index dfff5f5c84..edd0b59394 100644 --- a/eth/fetcher/block_fetcher_schedule_test.go +++ b/eth/fetcher/block_fetcher_schedule_test.go @@ -151,8 +151,8 @@ func TestHeaderSchedulingPrefersPeerWithoutWaitingBodies(t *testing.T) { }) f.announces["idle"]++ } - if f.scheduleHeaderFetches() { - t.Fatal("an alternative announcer without waiting bodies was not used") + if !f.scheduleHeaderFetches() { + t.Fatal("unused alternative announcers were not retained") } for _, hash := range hashes { if f.fetching[hash] == nil || f.fetching[hash].origin != "idle" { diff --git a/eth/handler.go b/eth/handler.go index 546572672e..bbd4ad6f10 100644 --- a/eth/handler.go +++ b/eth/handler.go @@ -1125,9 +1125,8 @@ func (h *handler) enableSyncedFeatures() { if len(hashes) == 0 { return } - for _, peer := range peers { - peer.AsyncSendPooledTransactionHashes(hashes) - } + h.wg.Add(1) + go h.recoverPendingAnnouncements(peers, hashes) } // PeerStats represents a short summary of the information known about a connected diff --git a/eth/relay/relay_private_submission_test.go b/eth/relay/relay_private_submission_test.go new file mode 100644 index 0000000000..10ab1293d5 --- /dev/null +++ b/eth/relay/relay_private_submission_test.go @@ -0,0 +1,69 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package relay + +import ( + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" +) + +func TestPrivateTxRelayReservation(t *testing.T) { + for _, enabled := range []bool{false, true} { + t.Run(map[bool]string{false: "disabled", true: "enabled"}[enabled], func(t *testing.T) { + s := &RelayService{} + if enabled { + s.privateTxStore = NewPrivateTxStore() + defer s.privateTxStore.Close() + } + tx := types.NewTx(&types.LegacyTx{}) + finish := s.BeginPrivateTx(tx.Hash()) + if finish == nil { + t.Fatal("missing completion callback") + } + if enabled && !s.privateTxStore.IsTxPrivate(tx.Hash()) { + t.Fatal("submission was visible before admission completed") + } + finish(true) + finish(false) + if enabled && !s.privateTxStore.IsTxPrivate(tx.Hash()) { + t.Fatal("accepted transaction lost its private marker") + } + }) + } +} + +func TestPrivateTxRelayFailureStaysPrivate(t *testing.T) { + store := NewPrivateTxStore() + defer store.Close() + s := &RelayService{privateTxStore: store} + tx := types.NewTx(&types.LegacyTx{}) + s.BeginPrivateTx(tx.Hash())(true) + if err := s.SubmitPrivateTransaction(tx); err == nil { + t.Fatal("unconfigured relay unexpectedly accepted the transaction") + } + store.mu.Lock() + store.txs[tx.Hash()] = time.Now().Add(-24 * time.Hour) + store.mu.Unlock() + store.SetTxPoolChecker(func(hash common.Hash) bool { return hash == tx.Hash() }) + store.sweepOnce() + if !store.IsTxPrivate(tx.Hash()) { + t.Fatal("relay failure or elapsed time made a retained private transaction public") + } +} diff --git a/eth/sync_completion.go b/eth/sync_completion.go new file mode 100644 index 0000000000..45e3f7864b --- /dev/null +++ b/eth/sync_completion.go @@ -0,0 +1,88 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package eth + +import ( + "time" + + "github.com/ethereum/go-ethereum/common" +) + +const ( + syncAnnouncementBatch = 256 + syncAnnouncementRetry = 100 * time.Millisecond +) + +// One worker owns a shared, pool-bounded snapshot and one cursor per peer. +// Queue rejection does not consume the cursor, even after rebroadcast expiry. +// The worker exits on completion, peer departure, or handler shutdown. +func (h *handler) recoverPendingAnnouncements(peers []*ethPeer, hashes []common.Hash) { + defer h.wg.Done() + pending := make(map[*ethPeer]int, len(peers)) + for _, peer := range peers { + pending[peer] = 0 + } + timer := time.NewTimer(0) + defer timer.Stop() + for len(pending) > 0 { + select { + case <-h.quitSync: + return + case <-timer.C: + } + h.retryPendingAnnouncements(pending, hashes) + timer.Reset(syncAnnouncementRetry) + } +} + +func (h *handler) retryPendingAnnouncements(pending map[*ethPeer]int, hashes []common.Hash) { + for peer, offset := range pending { + if h.peers.peer(peer.ID()) != peer { + delete(pending, peer) + continue + } + offset += h.queuePendingAnnouncements(peer, hashes[offset:]) + if offset == len(hashes) { + delete(pending, peer) + } else { + pending[peer] = offset + } + } +} + +func (h *handler) queuePendingAnnouncements(peer *ethPeer, hashes []common.Hash) int { + hashes = hashes[:min(len(hashes), syncAnnouncementBatch)] + batch := make([]common.Hash, 0, len(hashes)) + offsets := make([]int, 0, len(hashes)) + for i, hash := range hashes { + tx := h.txpool.Get(hash) + if tx == nil || tx.GetOptions() != nil || (h.privateTxGetter != nil && h.privateTxGetter.IsTxPrivate(hash)) { + continue + } + batch = append(batch, hash) + offsets = append(offsets, i) + } + if len(batch) == 0 { + return len(hashes) + } + // The propagation queue retains a prefix; retry from its first rejected hash. + accepted := peer.QueuePooledTransactionHashes(batch) + if len(accepted) < len(batch) { + return offsets[len(accepted)] + } + return len(hashes) +} diff --git a/eth/sync_completion_retry_test.go b/eth/sync_completion_retry_test.go new file mode 100644 index 0000000000..9703b9c6b9 --- /dev/null +++ b/eth/sync_completion_retry_test.go @@ -0,0 +1,224 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package eth + +import ( + "math/big" + "testing" + "testing/synctest" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/eth/protocols/eth" + "github.com/ethereum/go-ethereum/eth/relay" + "github.com/ethereum/go-ethereum/p2p" + "github.com/ethereum/go-ethereum/params" +) + +func TestEnableSyncedFeaturesRetriesFullQueue(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + h, target := syncCompletionRecoveryFixture(t) + prepareSyncRetryShutdown(t, h) + filler, err := types.SignTx(types.NewTransaction(1, testAddr, big.NewInt(1), 21000, + big.NewInt(params.BorDefaultTxPoolPriceLimit), nil), types.HomesteadSigner{}, testKey) + if err != nil { + t.Fatal(err) + } + if err := h.txpool.Add(types.Transactions{filler}, true)[0]; err != nil { + t.Fatal(err) + } + time.Sleep(3 * time.Second) + synctest.Wait() + slow, sink := syncCompletionPeer(t, h, eth.ETH69, 1) + fillSyncAnnouncementQueue(t, slow, filler.Hash()) + healthy, _ := syncCompletionPeer(t, h, eth.ETH69, 2) + h.enableSyncedFeatures() + synctest.Wait() + if slow.KnownTransaction(target.Hash()) || !healthy.KnownTransaction(target.Hash()) { + t.Fatal("queue rejection was not isolated to the saturated peer") + } + seen := readSyncRetryTarget(sink, target.Hash()) + time.Sleep(3 * syncAnnouncementRetry) + synctest.Wait() + select { + case err := <-seen: + if err != nil { + t.Fatal(err) + } + default: + t.Fatal("expired pending transaction was lost after the queue recovered") + } + h.wg.Wait() + }) +} + +func TestEnableSyncedFeaturesRetryStops(t *testing.T) { + for _, shutdown := range []bool{false, true} { + t.Run(map[bool]string{false: "peer departure", true: "shutdown"}[shutdown], func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + h, pool, txs := syncCompletionFixture() + prepareSyncRetryShutdown(t, h) + filler := types.NewTx(&types.LegacyTx{Nonce: 99}) + pool.pool[filler.Hash()] = filler + peer, _ := syncCompletionPeer(t, h, eth.ETH69, 1) + fillSyncAnnouncementQueue(t, peer, filler.Hash()) + h.enableSyncedFeatures() + synctest.Wait() + if shutdown { + close(h.quitSync) + } else if err := h.peers.unregisterPeer(peer.ID()); err != nil { + t.Fatal(err) + } + time.Sleep(syncAnnouncementRetry) + h.wg.Wait() + if peer.KnownTransaction(txs[0].Hash()) { + t.Fatal("rejected transaction was marked accepted") + } + }) + }) + } +} + +func TestEnableSyncedFeaturesRetryRefilters(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + h, pool, txs := syncCompletionFixture() + prepareSyncRetryShutdown(t, h) + private := relay.NewPrivateTxStore() + defer private.Close() + private.Add(txs[2].Hash()) + h.privateTxGetter = private + filler := types.NewTx(&types.LegacyTx{Nonce: 99}) + pool.pool[filler.Hash()] = filler + peer, _ := syncCompletionPeer(t, h, eth.ETH69, 1) + fillSyncAnnouncementQueue(t, peer, filler.Hash()) + h.enableSyncedFeatures() + synctest.Wait() + private.Add(txs[0].Hash()) + pool.lock.Lock() + delete(pool.pool, filler.Hash()) + pool.lock.Unlock() + time.Sleep(syncAnnouncementRetry) + h.wg.Wait() + if peer.KnownTransaction(txs[0].Hash()) { + t.Fatal("retry announced a transaction that became private") + } + }) +} + +func TestEnableSyncedFeaturesRetryPartialBatch(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + h, pool, txs := syncCompletionFixture() + filler := types.NewTx(&types.LegacyTx{Nonce: 99}) + last := types.NewTx(&types.LegacyTx{Nonce: 100}) + pool.pool[filler.Hash()], pool.pool[last.Hash()] = filler, last + peer, sink := syncCompletionPeer(t, h, eth.ETH69, 1) + fillSyncAnnouncementQueue(t, peer, filler.Hash()) + expectSyncCompletionAnnouncement(t, sink, filler) + synctest.Wait() + // The next packet now owns the previous full queue. Leave one slot + // in the replacement queue for the first eligible snapshot entry. + fill := make([]common.Hash, 16383) + for i := range fill { + fill[i] = filler.Hash() + } + if len(peer.QueuePooledTransactionHashes(fill)) != len(fill) { + t.Fatal("could not leave exactly one queue slot") + } + hashes := []common.Hash{txs[1].Hash(), txs[0].Hash(), txs[2].Hash(), last.Hash()} + if offset := h.queuePendingAnnouncements(h.peers.peer(peer.ID()), hashes); offset != 3 { + t.Fatalf("partial acceptance advanced by %d, want first rejected index 3", offset) + } + if !peer.KnownTransaction(txs[0].Hash()) || peer.KnownTransaction(last.Hash()) || peer.KnownTransaction(txs[2].Hash()) { + t.Fatal("partial acceptance marked the wrong transactions known") + } + }) +} + +func TestEnableSyncedFeaturesRetryBatchLimit(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + h, pool, _ := syncCompletionFixture() + peer, _ := syncCompletionPeer(t, h, eth.ETH69, 1) + hashes := make([]common.Hash, syncAnnouncementBatch+1) + for i := range hashes { + tx := types.NewTx(&types.LegacyTx{Nonce: uint64(i + 10)}) + pool.pool[tx.Hash()] = tx + hashes[i] = tx.Hash() + } + offset := h.queuePendingAnnouncements(h.peers.peer(peer.ID()), hashes) + if offset != 256 || peer.KnownTransaction(hashes[256]) { + t.Fatal("one retry exceeded the per-peer batch bound") + } + if next := h.queuePendingAnnouncements(h.peers.peer(peer.ID()), hashes[offset:]); next != 1 || !peer.KnownTransaction(hashes[256]) { + t.Fatal("next batch did not announce the remainder") + } + }) +} + +func prepareSyncRetryShutdown(t *testing.T, h *handler) { + t.Helper() + h.quitSync = make(chan struct{}) + t.Cleanup(func() { + select { + case <-h.quitSync: + default: + close(h.quitSync) + } + h.wg.Wait() + }) +} + +func fillSyncAnnouncementQueue(t *testing.T, peer *eth.Peer, filler common.Hash) { + t.Helper() + if len(peer.QueuePooledTransactionHashes([]common.Hash{filler})) != 1 { + t.Fatal("initial announcement was rejected") + } + synctest.Wait() + hashes := make([]common.Hash, 65536) + for i := range hashes { + hashes[i] = filler + } + accepted := peer.QueuePooledTransactionHashes(hashes) + if len(accepted) == 0 || len(accepted) == len(hashes) || len(peer.QueuePooledTransactionHashes(hashes[:1])) != 0 { + t.Fatal("failed to saturate the announcement queue") + } +} + +func readSyncRetryTarget(sink *p2p.MsgPipeRW, target common.Hash) <-chan error { + seen := make(chan error, 1) + go func() { + for { + msg, err := sink.ReadMsg() + if err != nil { + seen <- err + return + } + var packet eth.NewPooledTransactionHashesPacket + if err := msg.Decode(&packet); err != nil { + seen <- err + return + } + for _, hash := range packet.Hashes { + if hash == target { + seen <- nil + return + } + } + } + }() + return seen +} From 928d3b0d4daf48e2a4168932e6825f7fc1e8cfb7 Mon Sep 17 00:00:00 2001 From: Vikram Bhattacharjee Date: Thu, 22 Oct 2026 14:15:36 +0530 Subject: [PATCH 03/15] eth, eth/relay: verify private transaction replacement recovery --- eth/api_backend_private_replacement_test.go | 91 +++++++++++++++++++++ eth/relay/CLAUDE.md | 12 +++ 2 files changed, 103 insertions(+) create mode 100644 eth/api_backend_private_replacement_test.go diff --git a/eth/api_backend_private_replacement_test.go b/eth/api_backend_private_replacement_test.go new file mode 100644 index 0000000000..99aacaa257 --- /dev/null +++ b/eth/api_backend_private_replacement_test.go @@ -0,0 +1,91 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package eth + +import ( + "errors" + "math/big" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/ethereum/go-ethereum/core/txpool" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/internal/ethapi" + "github.com/ethereum/go-ethereum/params" +) + +type privateRelayFailureBackend struct { + *EthAPIBackend + relayCalls int +} + +func (b *privateRelayFailureBackend) PrivateTxEnabled() bool { return true } + +func (b *privateRelayFailureBackend) SubmitPrivateTx(*types.Transaction) error { + b.relayCalls++ + return errors.New("producer rejected transaction") +} + +func TestSendTxPrivateRelayFailureReplacement(t *testing.T) { + for _, private := range []bool{false, true} { + t.Run(map[bool]string{false: "public", true: "private"}[private], func(t *testing.T) { + b := &privateRelayFailureBackend{EthAPIBackend: privateSubmissionBackend(t, false)} + api := ethapi.NewTransactionAPI(b, new(ethapi.AddrLocker)) + original := makeTx(0, nil, nil, key) + raw, err := original.MarshalBinary() + require.NoError(t, err) + hash, err := api.SendRawTransactionPrivate(t.Context(), raw) + require.ErrorContains(t, err, "accepted locally, submission failed") + require.Equal(t, original.Hash(), hash) + require.NoError(t, b.eth.txPool.Sync()) + require.Equal(t, txpool.TxStatusPending, b.TxStatus(hash)) + require.True(t, b.relay.GetPrivateTxGetter().IsTxPrivate(hash)) + h := &handler{txpool: b.eth.txPool, privateTxGetter: b.relay.GetPrivateTxGetter()} + require.NotContains(t, h.pendingTransactionHashes(), hash) + + next := makeTx(1, nil, nil, key) + require.NoError(t, b.SendTx(t.Context(), next)) + replacement := makeTx(0, big.NewInt(50*params.GWei), big.NewInt(0), key) + raw, err = replacement.MarshalBinary() + require.NoError(t, err) + if private { + hash, err = api.SendRawTransactionPrivate(t.Context(), raw) + require.ErrorContains(t, err, "accepted locally, submission failed") + require.Equal(t, 2, b.relayCalls) + } else { + hash, err = api.SendRawTransaction(t.Context(), raw) + require.NoError(t, err) + require.Equal(t, 1, b.relayCalls) + } + require.Equal(t, replacement.Hash(), hash) + require.NoError(t, b.eth.txPool.Sync()) + require.False(t, b.eth.txPool.Has(original.Hash())) + require.Equal(t, txpool.TxStatusPending, b.TxStatus(replacement.Hash())) + require.Equal(t, txpool.TxStatusPending, b.TxStatus(next.Hash())) + require.Equal(t, private, b.relay.GetPrivateTxGetter().IsTxPrivate(replacement.Hash())) + announcements := h.pendingTransactionHashes() + require.NotContains(t, announcements, original.Hash()) + require.Contains(t, announcements, next.Hash()) + if private { + require.NotContains(t, announcements, replacement.Hash()) + } else { + require.Contains(t, announcements, replacement.Hash()) + } + }) + } +} diff --git a/eth/relay/CLAUDE.md b/eth/relay/CLAUDE.md index 8c7fa14e0d..e514997741 100644 --- a/eth/relay/CLAUDE.md +++ b/eth/relay/CLAUDE.md @@ -77,6 +77,18 @@ eth/relay/ - **Inclusion Detection**: Uses `TxGetter` to check if transaction was included in a block (stops retry) - **Already Known Handling**: "already known" errors are treated as successful submissions +**Failure recovery:** +- A relay error can occur after local pool admission; the RPC error reports that + distinction. Exhausted producer retries do not authorize public gossip. +- Privacy markers stay while the pool retains the transaction. Expiring a marker + alone would publish the original private payload through ordinary propagation. +- The sender can sign a replacement at the same nonce with a sufficient fee bump. + Submit it through the private endpoint to retain privacy, or the public endpoint + to explicitly publish that replacement. This also permits a cancellation + transaction; inclusion still depends on a producer accepting and mining it. +- The replaced transaction leaves the local pool. Its marker is cleaned up after + the grace period; later transactions still require that nonce to be mined. + ### 3. Already Known Error Handling **Concept:** From c15ccd87b3920cbc0ea476449c5ea42ec5af68ea Mon Sep 17 00:00:00 2001 From: Vikram Bhattacharjee Date: Thu, 22 Oct 2026 15:27:44 +0530 Subject: [PATCH 04/15] eth/fetcher: back off peers after block request timeouts --- eth/fetcher/block_fetcher.go | 3 + eth/fetcher/block_fetcher_admission.go | 11 +- eth/fetcher/block_fetcher_backoff.go | 38 +++++ eth/fetcher/block_fetcher_backoff_test.go | 139 ++++++++++++++++++ eth/fetcher/block_fetcher_request.go | 6 +- .../block_fetcher_timeout_recovery_test.go | 46 +++--- 6 files changed, 219 insertions(+), 24 deletions(-) create mode 100644 eth/fetcher/block_fetcher_backoff.go create mode 100644 eth/fetcher/block_fetcher_backoff_test.go diff --git a/eth/fetcher/block_fetcher.go b/eth/fetcher/block_fetcher.go index bf4670bd18..6c99ec178c 100644 --- a/eth/fetcher/block_fetcher.go +++ b/eth/fetcher/block_fetcher.go @@ -23,6 +23,7 @@ import ( "time" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/lru" "github.com/ethereum/go-ethereum/common/prque" "github.com/ethereum/go-ethereum/consensus" "github.com/ethereum/go-ethereum/core/stateless" @@ -246,6 +247,7 @@ type BlockFetcher struct { // dispatch before their response deadline exists. fetchSlots map[string]int fetchSlotsTotal int + fetchBackoff lru.BasicLRU[string, time.Time] // guarded by mu fetchRetry time.Duration completeRetry time.Duration @@ -299,6 +301,7 @@ func NewBlockFetcher(light bool, getHeader HeaderRetrievalFn, getBlock blockRetr fetched: make(map[common.Hash][]*blockAnnounce), completing: make(map[common.Hash]*blockAnnounce), fetchSlots: make(map[string]int), + fetchBackoff: lru.NewBasicLRU[string, time.Time](maxFetchBackoffPeers), queue: prque.New[int64, *blockOrHeaderInject](nil), queues: make(map[string]int), queued: make(map[common.Hash]*blockOrHeaderInject), diff --git a/eth/fetcher/block_fetcher_admission.go b/eth/fetcher/block_fetcher_admission.go index 0cc71d3efe..af277a34c1 100644 --- a/eth/fetcher/block_fetcher_admission.go +++ b/eth/fetcher/block_fetcher_admission.go @@ -16,7 +16,10 @@ package fetcher -import "math/rand" +import ( + "math/rand" + "time" +) // Request dispatch is synchronous and completes only once the message has been // written to the peer, which happens before the worker creates its response @@ -52,6 +55,12 @@ func (f *BlockFetcher) acquireFetchSlotWithin(peer string, limit int) bool { f.mu.Lock() defer f.mu.Unlock() + if until, ok := f.fetchBackoff.Peek(peer); ok { + if time.Now().Before(until) { + return false + } + f.fetchBackoff.Remove(peer) + } if f.fetchSlotsTotal >= maxTotalFetchRequests { return false } diff --git a/eth/fetcher/block_fetcher_backoff.go b/eth/fetcher/block_fetcher_backoff.go new file mode 100644 index 0000000000..c4766926cf --- /dev/null +++ b/eth/fetcher/block_fetcher_backoff.go @@ -0,0 +1,38 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package fetcher + +import "time" + +const ( + fetchPeerBackoff = 10 * time.Second + // Keep cooldown metadata bounded across reconnects and peer identity churn. + maxFetchBackoffPeers = 2 * maxTotalFetchRequests +) + +// Concurrent requests can time out together. They share one cooldown instead +// of extending it each time another outstanding request expires. +func (f *BlockFetcher) backoffFetchPeer(peer string) { + f.mu.Lock() + defer f.mu.Unlock() + + now := time.Now() + if until, ok := f.fetchBackoff.Peek(peer); ok && now.Before(until) { + return + } + f.fetchBackoff.Add(peer, now.Add(fetchPeerBackoff)) +} diff --git a/eth/fetcher/block_fetcher_backoff_test.go b/eth/fetcher/block_fetcher_backoff_test.go new file mode 100644 index 0000000000..ba2e2c64bd --- /dev/null +++ b/eth/fetcher/block_fetcher_backoff_test.go @@ -0,0 +1,139 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package fetcher + +import ( + "fmt" + "sync" + "testing" + "testing/synctest" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func assertBlockFetchCooldown(t *testing.T, f *BlockFetcher, imported <-chan struct{}) { + t.Helper() + time.Sleep(fetchPeerBackoff - time.Second) + synctest.Wait() + require.False(t, f.acquireFetchSlot("slow"), "timed-out peer acquired a new slot during cooldown") + select { + case <-imported: + t.Fatal("timed-out peer was retried before cooldown expired") + default: + } + time.Sleep(time.Second) +} + +func TestFetchPeerBackoffAdmission(t *testing.T) { + for _, bodies := range []bool{false, true} { + t.Run(fmt.Sprintf("bodies=%t", bodies), func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + f := newBlockReleaseFetcher(t) + defer f.wm.witnessCache.Stop() + acquire := func(announces []*blockAnnounce) *blockAnnounce { + if bodies { + return f.acquireBodyAnnouncer(announces, make(map[string]bool)) + } + return f.acquireAnnouncer(announces, nil) + } + slow := &blockAnnounce{hash: common.Hash{1}, origin: "slow"} + healthy := &blockAnnounce{hash: slow.hash, origin: "healthy"} + require.True(t, f.acquireFetchSlot(slow.origin)) + f.backoffFetchPeer(slow.origin) + f.releaseFetchSlot(slow.origin) + require.Nil(t, acquire([]*blockAnnounce{slow})) + require.Same(t, healthy, acquire([]*blockAnnounce{slow, healthy})) + f.releaseFetchSlot(healthy.origin) + require.Zero(t, f.fetchSlotsTotal) + time.Sleep(fetchPeerBackoff - time.Nanosecond) + require.Nil(t, acquire([]*blockAnnounce{slow})) + time.Sleep(time.Nanosecond) + require.Same(t, slow, acquire([]*blockAnnounce{slow})) + f.releaseFetchSlot(slow.origin) + require.Zero(t, f.fetchBackoff.Len()) + }) + }) + } +} + +func TestFetchPeerBackoffDoesNotEscalate(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + f := newBlockReleaseFetcher(t) + defer f.wm.witnessCache.Stop() + for range 8 { + f.backoffFetchPeer("slow") + require.False(t, f.acquireFetchSlot("slow")) + time.Sleep(fetchPeerBackoff - time.Second) + f.backoffFetchPeer("slow") + require.False(t, f.acquireFetchSlot("slow")) + time.Sleep(time.Second) + require.True(t, f.acquireFetchSlot("slow"), "overlapping timeouts extended the cooldown") + f.releaseFetchSlot("slow") + } + }) +} + +func TestFetchPeerBackoffBound(t *testing.T) { + f := newBlockReleaseFetcher(t) + defer f.wm.witnessCache.Stop() + for i := 0; i <= maxFetchBackoffPeers; i++ { + f.backoffFetchPeer(fmt.Sprintf("peer-%d", i)) + } + require.Equal(t, maxFetchBackoffPeers, f.fetchBackoff.Len()) + require.False(t, f.acquireFetchSlot(fmt.Sprintf("peer-%d", maxFetchBackoffPeers))) + require.True(t, f.acquireFetchSlot("healthy")) + f.releaseFetchSlot("healthy") +} + +func TestFetchPeerBackoffConcurrentAdmission(t *testing.T) { + f := newBlockReleaseFetcher(t) + defer f.wm.witnessCache.Stop() + var wg sync.WaitGroup + for range 8 { + wg.Go(func() { + for range 100 { + f.backoffFetchPeer("slow") + if f.acquireFetchSlot("slow") { + t.Error("acquired a slot during cooldown") + f.releaseFetchSlot("slow") + } + if f.acquireFetchSlot("healthy") { + f.releaseFetchSlot("healthy") + } + } + }) + } + wg.Wait() + require.Zero(t, f.fetchSlotsTotal) + require.Empty(t, f.fetchSlots) +} + +func TestFetchPeerBackoffExpiredEntry(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + f := newBlockReleaseFetcher(t) + defer f.wm.witnessCache.Stop() + f.backoffFetchPeer("slow") + time.Sleep(fetchPeerBackoff) + f.backoffFetchPeer("slow") + require.False(t, f.acquireFetchSlot("slow")) + time.Sleep(fetchPeerBackoff) + require.True(t, f.acquireFetchSlot("slow")) + f.releaseFetchSlot("slow") + }) +} diff --git a/eth/fetcher/block_fetcher_request.go b/eth/fetcher/block_fetcher_request.go index dfb4b4fc5e..41272bb04a 100644 --- a/eth/fetcher/block_fetcher_request.go +++ b/eth/fetcher/block_fetcher_request.go @@ -72,7 +72,8 @@ func (f *BlockFetcher) runHeaderRequest(peer string, req headerRequest) { case res := <-resCh: f.deliverHeaders(peer, req, res) case <-timeout.C: - log.Debug("BlockFetcher: header fetch timed out", "peer", peer, "hash", req.hash, "timeout", 2*fetchTimeout) + log.Debug("BlockFetcher: header fetch timed out", "peer", peer, "hash", req.hash, "timeout", 2*fetchTimeout, "backoff", fetchPeerBackoff) + f.backoffFetchPeer(peer) f.notifyFetchRelease(true, owned) case <-f.quit: } @@ -127,7 +128,8 @@ func (f *BlockFetcher) runBodyRequest(peer string, req bodyRequest) { case res := <-resCh: f.deliverBodies(peer, req, res) case <-timeout.C: - log.Debug("BlockFetcher: body fetch timed out", "peer", peer, "hashes", req.hashes, "timeout", 2*fetchTimeout) + log.Debug("BlockFetcher: body fetch timed out", "peer", peer, "hashes", req.hashes, "timeout", 2*fetchTimeout, "backoff", fetchPeerBackoff) + f.backoffFetchPeer(peer) f.notifyFetchRelease(false, req.announces) case <-f.quit: } diff --git a/eth/fetcher/block_fetcher_timeout_recovery_test.go b/eth/fetcher/block_fetcher_timeout_recovery_test.go index 74a6eb9cdc..64cea9dab1 100644 --- a/eth/fetcher/block_fetcher_timeout_recovery_test.go +++ b/eth/fetcher/block_fetcher_timeout_recovery_test.go @@ -2,6 +2,7 @@ package fetcher import ( "testing" + "testing/synctest" "time" "github.com/ethereum/go-ethereum/common" @@ -18,27 +19,30 @@ func TestBlockFetchTimeoutKeepsPeerAndAllowsRetry(t *testing.T) { name = "header" } t.Run(name, func(t *testing.T) { - t.Parallel() - tester := newTester(false) - defer tester.fetcher.Stop() - imported := make(chan struct{}, 1) - tester.fetcher.importedHook = func(*types.Header, *types.Block) { imported <- struct{}{} } - request, started := requestDelayedBlock(t, tester, blocks, block, headers) - prepareBlockTimeoutState(t, tester.fetcher, block.Hash(), headers) - awaitBlockTimeout(t, request, started) - assertBlockTimeoutReleased(t, tester.fetcher, block.Hash(), headers) - assertBlockPeerRetained(t, tester) - err := tester.fetcher.Notify("slow", block.Hash(), block.NumberU64(), time.Now(), - tester.makeHeaderFetcher("slow", blocks, 0), tester.makeBodyFetcher("slow", blocks, 0), nil) - if err != nil { - t.Fatal(err) - } - select { - case <-imported: - case <-time.After(3 * time.Second): - t.Fatal("timed-out block could not be fetched again") - } - assertBlockPeerRetained(t, tester) + synctest.Test(t, func(t *testing.T) { + tester := newTester(false) + defer tester.fetcher.Stop() + defer tester.fetcher.wm.witnessCache.Stop() + imported := make(chan struct{}, 1) + tester.fetcher.importedHook = func(*types.Header, *types.Block) { imported <- struct{}{} } + request, started := requestDelayedBlock(t, tester, blocks, block, headers) + prepareBlockTimeoutState(t, tester.fetcher, block.Hash(), headers) + awaitBlockTimeout(t, request, started) + assertBlockTimeoutReleased(t, tester.fetcher, block.Hash(), headers) + assertBlockPeerRetained(t, tester) + err := tester.fetcher.Notify("slow", block.Hash(), block.NumberU64(), time.Now(), + tester.makeHeaderFetcher("slow", blocks, 0), tester.makeBodyFetcher("slow", blocks, 0), nil) + if err != nil { + t.Fatal(err) + } + assertBlockFetchCooldown(t, tester.fetcher, imported) + select { + case <-imported: + case <-time.After(3 * time.Second): + t.Fatal("timed-out block could not be fetched again") + } + assertBlockPeerRetained(t, tester) + }) }) } } From 2b88eb61baacb5851e657726ac677186694191bd Mon Sep 17 00:00:00 2001 From: Vikram Bhattacharjee Date: Thu, 22 Oct 2026 16:16:22 +0530 Subject: [PATCH 05/15] eth, ethapi: recover private relay submissions and fetch scheduling --- eth/fetcher/block_fetcher.go | 37 +++---- eth/fetcher/block_fetcher_discard_test.go | 65 +++++++++++++ eth/fetcher/block_fetcher_schedule.go | 1 + eth/fetcher/block_fetcher_timer.go | 59 ++++++++++++ eth/fetcher/block_fetcher_timer_test.go | 112 ++++++++++++++++++++++ internal/ethapi/api.go | 18 +--- internal/ethapi/api_private.go | 42 ++++++++ internal/ethapi/api_private_test.go | 49 ++++++++++ 8 files changed, 341 insertions(+), 42 deletions(-) create mode 100644 eth/fetcher/block_fetcher_discard_test.go create mode 100644 eth/fetcher/block_fetcher_timer.go create mode 100644 eth/fetcher/block_fetcher_timer_test.go create mode 100644 internal/ethapi/api_private.go diff --git a/eth/fetcher/block_fetcher.go b/eth/fetcher/block_fetcher.go index 6c99ec178c..b30ccbda9e 100644 --- a/eth/fetcher/block_fetcher.go +++ b/eth/fetcher/block_fetcher.go @@ -125,10 +125,11 @@ type peerDropFn func(id string) // blockAnnounce is the hash notification of the availability of a new block in the // network. type blockAnnounce struct { - hash common.Hash // Hash of the block being announced - number uint64 // Number of the block being announced (0 = unknown | old protocol) - header *types.Header // Header of the block partially reassembled (new protocol) - time time.Time // Timestamp of the announcement + hash common.Hash // Hash of the block being announced + number uint64 // Number of the block being announced (0 = unknown | old protocol) + header *types.Header // Header of the block partially reassembled (new protocol) + time time.Time // Timestamp of the announcement + deferred bool // The gather window elapsed but a request could not start origin string // Identifier of the peer originating the notification @@ -248,6 +249,8 @@ type BlockFetcher struct { fetchSlots map[string]int fetchSlotsTotal int fetchBackoff lru.BasicLRU[string, time.Time] // guarded by mu + fetchTimerAt time.Time + fetchRetryAt time.Time fetchRetry time.Duration completeRetry time.Duration @@ -577,6 +580,7 @@ func (f *BlockFetcher) loop() { // Also check if witness manager is handling it if (number+maxUncleDist < height) || (f.light && f.getHeader(hash) != nil) || (!f.light && f.getBlock(hash) != nil) { // If known, forget about it + f.forgetHash(hash) f.forgetBlock(hash) continue } @@ -641,9 +645,7 @@ func (f *BlockFetcher) loop() { f.announceChangeHook(notification.hash, true) } - if len(f.announced) == 1 { - f.rescheduleFetch(fetchTimer, false) - } + f.rearmFreshFetch(fetchTimer, notification) case op := <-f.inject: // A direct block insertion was requested @@ -918,25 +920,8 @@ func (f *BlockFetcher) loop() { // rescheduleFetch resets the specified fetch timer to the next blockAnnounce timeout. func (f *BlockFetcher) rescheduleFetch(fetch *time.Timer, deferred bool) { - // Short circuit if no blocks are announced - if len(f.announced) == 0 { - return - } - // Schedule announcement retrieval quickly for light mode - // since server won't send any headers to client. - if f.light { - fetch.Reset(nextFetchRetry(lightTimeout, deferred, &f.fetchRetry)) - return - } - // Otherwise find the earliest expiring announcement - earliest := time.Now() - for _, announces := range f.announced { - if earliest.After(announces[0].time) { - earliest = announces[0].time - } - } - - fetch.Reset(nextFetchRetry(arriveTimeout-time.Since(earliest), deferred, &f.fetchRetry)) + f.fetchRetryAt = time.Now().Add(nextFetchRetry(0, deferred, &f.fetchRetry)) + f.resetFetchTimer(fetch) } // retryAfter floors a timer delay for a pass that left announcements behind diff --git a/eth/fetcher/block_fetcher_discard_test.go b/eth/fetcher/block_fetcher_discard_test.go new file mode 100644 index 0000000000..352f4fd47b --- /dev/null +++ b/eth/fetcher/block_fetcher_discard_test.go @@ -0,0 +1,65 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package fetcher + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestBlockFetchDiscardReleasesAlternatives(t *testing.T) { + for _, old := range []bool{false, true} { + name := "known" + if old { + name = "too old" + } + t.Run(name, func(t *testing.T) { + tester := newTester(false) + f := tester.fetcher + defer f.Stop() + hashes, blocks := makeChain(maxUncleDist+4, 83, genesis) + block := blocks[hashes[len(hashes)-3]] + resume := pauseBlockTimeoutLoop(t, f) + f.enqueue("owner", nil, block, nil) + hash := block.Hash() + f.completing[hash] = &blockAnnounce{origin: "owner", time: time.Now()} + f.announced[hash] = []*blockAnnounce{{origin: "alternate", time: time.Now()}} + f.announces["alternate"] = 1 + tester.lock.Lock() + head := block + if old { + head = blocks[hashes[0]] + } else { + tester.blocks[hash] = block + } + tester.headers[head.Hash()] = head.Header() + tester.blocks[head.Hash()] = head + tester.hashes = append(tester.hashes, head.Hash()) + tester.lock.Unlock() + resume() + resume = pauseBlockTimeoutLoop(t, f) + defer resume() + require.Nil(t, f.queued[hash]) + require.Nil(t, f.completing[hash]) + require.Nil(t, f.announced[hash]) + require.Empty(t, f.announces) + require.Empty(t, f.queues) + }) + } +} diff --git a/eth/fetcher/block_fetcher_schedule.go b/eth/fetcher/block_fetcher_schedule.go index 6093bc269b..cdeb6acbf6 100644 --- a/eth/fetcher/block_fetcher_schedule.go +++ b/eth/fetcher/block_fetcher_schedule.go @@ -60,6 +60,7 @@ func (f *BlockFetcher) prepareHeaderFetch(hash common.Hash, announces []*blockAn if time.Since(announces[0].time) <= wait { return nil, false } + announces[0].deferred = true if f.fetchInProgress(hash) { return nil, true } diff --git a/eth/fetcher/block_fetcher_timer.go b/eth/fetcher/block_fetcher_timer.go new file mode 100644 index 0000000000..3f6531c98f --- /dev/null +++ b/eth/fetcher/block_fetcher_timer.go @@ -0,0 +1,59 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package fetcher + +import "time" + +// Fresh hashes keep their gather deadlines even while other hashes wait for +// capacity. Incoming announcements never move the existing retry deadline. +func (f *BlockFetcher) resetFetchTimer(timer *time.Timer) { + var earliest time.Time + for _, announces := range f.announced { + announce := announces[0] + deadline := f.gatherDeadline(announce) + if announce.deferred { + deadline = f.fetchRetryAt + } + if earliest.IsZero() || deadline.Before(earliest) { + earliest = deadline + } + } + f.fetchTimerAt = earliest + if !earliest.IsZero() { + timer.Reset(time.Until(earliest)) + } +} + +// New announcements only compare deadlines; scanning all retained alternatives +// on every incoming announcement would make bursts quadratic in queue size. +func (f *BlockFetcher) rearmFreshFetch(timer *time.Timer, announce *blockAnnounce) { + if f.announced[announce.hash][0] != announce { + return + } + deadline := f.gatherDeadline(announce) + if f.fetchTimerAt.IsZero() || deadline.Before(f.fetchTimerAt) { + f.fetchTimerAt = deadline + timer.Reset(time.Until(deadline)) + } +} + +func (f *BlockFetcher) gatherDeadline(announce *blockAnnounce) time.Time { + if f.light { + return announce.time.Add(lightTimeout) + } + return announce.time.Add(arriveTimeout) +} diff --git a/eth/fetcher/block_fetcher_timer_test.go b/eth/fetcher/block_fetcher_timer_test.go new file mode 100644 index 0000000000..a30a19fc6f --- /dev/null +++ b/eth/fetcher/block_fetcher_timer_test.go @@ -0,0 +1,112 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package fetcher + +import ( + "testing" + "testing/synctest" + "time" + + "github.com/stretchr/testify/require" +) + +func TestHeaderSchedulingFreshDeadlineDuringBackoff(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + p := newScheduleProbe(t) + f := p.fetcher + stalled := p.announce("alternate", time.Second) + f.fetching[stalled] = &blockAnnounce{origin: "silent", time: time.Now()} + require.True(t, f.scheduleHeaderFetches()) + f.fetchRetry = time.Second + timer := time.NewTimer(time.Hour) + defer timer.Stop() + f.rescheduleFetch(timer, true) + retryAt := f.fetchRetryAt + time.Sleep(time.Millisecond) + fresh := p.announce("available", 0) + start := time.Now() + f.rearmFreshFetch(timer, f.announced[fresh][0]) + <-timer.C + require.Equal(t, arriveTimeout, time.Since(start)) + require.Equal(t, retryAt, f.fetchRetryAt) + require.Equal(t, time.Second, f.fetchRetry) + require.True(t, f.scheduleHeaderFetches()) + require.Equal(t, fresh, <-p.started) + require.Len(t, f.announced[stalled], 1) + }) +} + +func TestHeaderSchedulingAnnouncementPreservesRetryDeadline(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + p := newScheduleProbe(t) + f := p.fetcher + hash := p.announce("alternate", time.Second) + f.completing[hash] = &blockAnnounce{origin: "silent"} + require.True(t, f.scheduleHeaderFetches()) + timer := time.NewTimer(time.Hour) + defer timer.Stop() + f.fetchRetry = time.Second + f.rescheduleFetch(timer, true) + deadline := f.fetchRetryAt + time.Sleep(800 * time.Millisecond) + fresh := p.announce("fresh", 0) + f.rearmFreshFetch(timer, f.announced[fresh][0]) + <-timer.C + require.Equal(t, deadline, time.Now()) + }) +} + +func TestHeaderSchedulingInitialTimer(t *testing.T) { + for _, light := range []bool{false, true} { + synctest.Test(t, func(t *testing.T) { + p := newScheduleProbe(t) + f := p.fetcher + f.light = light + timer := time.NewTimer(time.Hour) + defer timer.Stop() + fresh := p.announce("fresh", 0) + start := time.Now() + f.rearmFreshFetch(timer, f.announced[fresh][0]) + <-timer.C + expected := arriveTimeout + if light { + expected = lightTimeout + } + require.Equal(t, expected, time.Since(start)) + }) + } +} + +func TestHeaderSchedulingDeferredTimer(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + p := newScheduleProbe(t) + f := p.fetcher + hash := p.announce("alternate", time.Second) + f.completing[hash] = &blockAnnounce{origin: "silent"} + require.True(t, f.scheduleHeaderFetches()) + timer := time.NewTimer(time.Hour) + defer timer.Stop() + start := time.Now() + f.rescheduleFetch(timer, true) + <-timer.C + require.Equal(t, fetchRetryInterval, time.Since(start)) + require.True(t, f.scheduleHeaderFetches()) + f.rescheduleFetch(timer, true) + <-timer.C + require.Equal(t, 3*fetchRetryInterval, time.Since(start)) + }) +} diff --git a/internal/ethapi/api.go b/internal/ethapi/api.go index a47cae10c4..20b87695f4 100644 --- a/internal/ethapi/api.go +++ b/internal/ethapi/api.go @@ -2501,23 +2501,9 @@ func (api *TransactionAPI) SendRawTransactionPrivate(ctx context.Context, input finish := api.b.BeginPrivateTx(tx.Hash()) defer finish(false) - hash, err := SubmitTransaction(ctx, api.b, tx) + _, err := SubmitTransaction(ctx, api.b, tx) finish(err == nil) - if err != nil { - return hash, err - } - - // Submit the private transaction directly to BPs - if api.b.PrivateTxEnabled() { - // Return an error here to inform user that private tx submission failed as it is critical. - // Note that it will be retried in background. - if err := api.b.SubmitPrivateTx(tx); err != nil { - log.Error("Private tx accepted locally but submission to bp failed", "err", err) - return tx.Hash(), fmt.Errorf("private tx accepted locally, submission failed. reason: %w", err) - } - } - - return hash, err + return api.forwardPrivateTransaction(tx, err) } func (api *TransactionAPI) CheckPreconfStatus(ctx context.Context, hash common.Hash) (bool, error) { diff --git a/internal/ethapi/api_private.go b/internal/ethapi/api_private.go new file mode 100644 index 0000000000..f0936f2bc2 --- /dev/null +++ b/internal/ethapi/api_private.go @@ -0,0 +1,42 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package ethapi + +import ( + "fmt" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/txpool/locals" + "github.com/ethereum/go-ethereum/core/types" +) + +// A temporary local rejection must not prevent a producer with room from +// accepting the transaction. The caller separately records local admission. +func (api *TransactionAPI) forwardPrivateTransaction(tx *types.Transaction, localErr error) (common.Hash, error) { + if localErr != nil && (!api.b.PrivateTxEnabled() || !locals.IsTemporaryReject(localErr)) { + return common.Hash{}, localErr + } + if api.b.PrivateTxEnabled() { + if err := api.b.SubmitPrivateTx(tx); err != nil { + if localErr != nil { + return common.Hash{}, fmt.Errorf("private tx rejected locally: %w; submission failed: %w", localErr, err) + } + return tx.Hash(), fmt.Errorf("private tx accepted locally, submission failed. reason: %w", err) + } + } + return tx.Hash(), nil +} diff --git a/internal/ethapi/api_private_test.go b/internal/ethapi/api_private_test.go index 71eb7d7a77..8dba695e39 100644 --- a/internal/ethapi/api_private_test.go +++ b/internal/ethapi/api_private_test.go @@ -191,3 +191,52 @@ func TestSendRawTransactionPrivateKnownPublicRemainsVisible(t *testing.T) { require.Zero(t, admissions, "known duplicate must not enter admission") require.False(t, store.IsTxPrivate(tx.Hash())) } + +func TestSendRawTransactionPrivateTemporaryForwarding(t *testing.T) { + for _, tc := range []struct { + name string + localErr error + relay, failRelay, forward bool + }{ + {"temporary relay", txpool.ErrUnderpriced, true, false, true}, + {"temporary relay failed", txpool.ErrUnderpriced, true, true, true}, + {"temporary producer", txpool.ErrUnderpriced, false, false, false}, + {"permanent relay", core.ErrNonceTooLow, true, false, false}, + } { + t.Run(tc.name, func(t *testing.T) { + genesis := &core.Genesis{Config: params.TestChainConfig, Alloc: types.GenesisAlloc{}} + base := newTestBackend(t, 0, genesis, ethash.NewFaker(), nil) + defer base.chain.Stop() + base.privateTxEnabled, base.acceptPrivateTxs = tc.relay, true + store := relay.NewPrivateTxStore() + defer store.Close() + base.beginPrivateTxFn = store.Begin + calls := 0 + relayErr := errors.New("relay unavailable") + base.submitPrivateTxFn = func(tx *types.Transaction) error { + calls++ + require.False(t, store.IsTxPrivate(tx.Hash()), "rejected local admission was committed") + if tc.failRelay { + return relayErr + } + return nil + } + backend := &privateSubmissionBackend{testBackend: base, send: func(context.Context, *types.Transaction) error { return tc.localErr }} + api := NewTransactionAPI(backend, new(AddrLocker)) + raw, tx := makeSelfSignedRaw(t, api, base.acc.Address) + hash, err := api.SendRawTransactionPrivate(t.Context(), raw) + require.Equal(t, tc.forward, calls == 1) + if tc.forward && !tc.failRelay { + require.NoError(t, err) + require.Equal(t, tx.Hash(), hash) + } else { + require.ErrorIs(t, err, tc.localErr) + require.Equal(t, common.Hash{}, hash) + } + if tc.failRelay { + require.ErrorIs(t, err, relayErr) + } + require.False(t, store.IsTxPrivate(tx.Hash())) + }) + } +} From a8a30f1c2e69cab23609e44385b4f7d9ae750a1f Mon Sep 17 00:00:00 2001 From: Vikram Bhattacharjee Date: Thu, 22 Oct 2026 18:21:24 +0530 Subject: [PATCH 06/15] eth, txpool: account for queue offers and recover deferred block bodies --- core/txpool/legacypool/rebroadcast.go | 2 +- core/txpool/legacypool/rebroadcast_attempt.go | 48 +++++++ .../legacypool/rebroadcast_attempt_test.go | 67 ++++++++++ .../legacypool/rebroadcast_fairness_test.go | 5 + .../legacypool/rebroadcast_scheduling_test.go | 1 + core/txpool/legacypool/rebroadcast_test.go | 4 +- core/txpool/rebroadcast_attempt.go | 46 +++++++ core/txpool/txpool_test.go | 72 ++++++++++ eth/api_backend.go | 13 +- eth/api_backend_private.go | 37 ++++++ eth/api_backend_private_test.go | 8 +- eth/api_backend_public_privacy_test.go | 62 +++++++++ eth/fetcher/block_fetcher.go | 25 ++-- eth/fetcher/block_fetcher_body_retry.go | 71 ++++++++++ eth/fetcher/block_fetcher_body_retry_test.go | 125 ++++++++++++++++++ eth/fetcher/block_fetcher_schedule.go | 4 + eth/handler.go | 10 +- eth/rebroadcast.go | 20 ++- eth/rebroadcast_attempt_test.go | 73 ++++++++++ eth/relay/private_tx_errors.go | 21 +++ internal/ethapi/api.go | 2 +- internal/ethapi/api_private.go | 13 ++ 22 files changed, 694 insertions(+), 35 deletions(-) create mode 100644 core/txpool/legacypool/rebroadcast_attempt.go create mode 100644 core/txpool/legacypool/rebroadcast_attempt_test.go create mode 100644 core/txpool/rebroadcast_attempt.go create mode 100644 eth/api_backend_private.go create mode 100644 eth/api_backend_public_privacy_test.go create mode 100644 eth/fetcher/block_fetcher_body_retry.go create mode 100644 eth/fetcher/block_fetcher_body_retry_test.go create mode 100644 eth/rebroadcast_attempt_test.go create mode 100644 eth/relay/private_tx_errors.go diff --git a/core/txpool/legacypool/rebroadcast.go b/core/txpool/legacypool/rebroadcast.go index 3b7ab3dea6..f7f8b3cac3 100644 --- a/core/txpool/legacypool/rebroadcast.go +++ b/core/txpool/legacypool/rebroadcast.go @@ -42,10 +42,10 @@ func (pool *LegacyPool) canRebroadcast(tx *types.Transaction) bool { } func (pool *LegacyPool) publishRebroadcastTransactions(txs []*types.Transaction) { - pool.recordRebroadcastAttempts(txs) explicit := pool.rebroadcastAckFeed.Send(core.StuckTxsEvent{Txs: txs}) legacy := pool.rebroadcastTxFeed.Send(core.StuckTxsEvent{Txs: txs}) if explicit == 0 || legacy > 0 { + pool.recordRebroadcastAttempts(txs) hashes := make([]common.Hash, len(txs)) for i, tx := range txs { hashes[i] = tx.Hash() diff --git a/core/txpool/legacypool/rebroadcast_attempt.go b/core/txpool/legacypool/rebroadcast_attempt.go new file mode 100644 index 0000000000..603efe4a52 --- /dev/null +++ b/core/txpool/legacypool/rebroadcast_attempt.go @@ -0,0 +1,48 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package legacypool + +import ( + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" +) + +// RebroadcastAttempt records actual queue offers independently of acceptance. +// The batch owns one attempt per hash, even when several peers refuse it. +func (pool *LegacyPool) RebroadcastAttempt(txs []*types.Transaction) func([]common.Hash) { + remaining := make(map[common.Hash]*types.Transaction, len(txs)) + for _, tx := range txs { + remaining[tx.Hash()] = tx + } + return func(hashes []common.Hash) { + pool.mu.Lock() + defer pool.mu.Unlock() + now := time.Now() + for _, hash := range hashes { + tx := remaining[hash] + if tx == nil { + continue + } + delete(remaining, hash) + if pool.all.Get(hash) == tx { + pool.lastRebroadcastAttempt[hash] = now + } + } + } +} diff --git a/core/txpool/legacypool/rebroadcast_attempt_test.go b/core/txpool/legacypool/rebroadcast_attempt_test.go new file mode 100644 index 0000000000..29cef8873f --- /dev/null +++ b/core/txpool/legacypool/rebroadcast_attempt_test.go @@ -0,0 +1,67 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package legacypool + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core" + "github.com/ethereum/go-ethereum/core/types" +) + +func TestRebroadcastSuppressedOldBatch(t *testing.T) { + pool, _, from, tx := setupRebroadcastTest(t, time.Hour, 24*time.Hour, 200) + defer pool.Close() + ch := make(chan core.StuckTxsEvent, 1) + sub := pool.SubscribeRebroadcastTransactionsWithAcknowledgement(ch) + defer sub.Unsubscribe() + pool.mu.Lock() + setTxAge(pool, from, 48*time.Hour) + batch := pool.identifyStuckTransactions() + pool.mu.Unlock() + require.Len(t, batch, 1) + pool.publishRebroadcastTransactions(batch) + <-ch + pool.mu.RLock() + require.Empty(t, pool.lastRebroadcastAttempt) + require.Len(t, pool.identifyStuckTransactions(), 1) + pool.mu.RUnlock() + attempt := pool.RebroadcastAttempt(batch) + attempt([]common.Hash{{99}, tx.Hash()}) + pool.mu.RLock() + first := pool.lastRebroadcastAttempt[tx.Hash()] + require.False(t, first.IsZero()) + require.Empty(t, pool.lastRebroadcast) + require.Empty(t, pool.identifyStuckTransactions()) + pool.mu.RUnlock() + attempt([]common.Hash{tx.Hash()}) + pool.mu.RLock() + require.Equal(t, first, pool.lastRebroadcastAttempt[tx.Hash()]) + pool.mu.RUnlock() + late := pool.RebroadcastAttempt([]*types.Transaction{tx}) + pool.mu.Lock() + pool.removeTx(tx.Hash(), true, false) + pool.mu.Unlock() + late([]common.Hash{tx.Hash()}) + pool.mu.RLock() + defer pool.mu.RUnlock() + require.Empty(t, pool.lastRebroadcastAttempt) +} diff --git a/core/txpool/legacypool/rebroadcast_fairness_test.go b/core/txpool/legacypool/rebroadcast_fairness_test.go index 58bf493886..e2ced264bf 100644 --- a/core/txpool/legacypool/rebroadcast_fairness_test.go +++ b/core/txpool/legacypool/rebroadcast_fairness_test.go @@ -121,6 +121,11 @@ func publishRebroadcastFairnessBatch(t *testing.T, pool *LegacyPool, ch <-chan c t.Helper() pool.publishRebroadcastTransactions(batch) delivered := receiveRebroadcastEvent(t, ch) + offered := make([]common.Hash, len(delivered.Txs)) + for i, tx := range delivered.Txs { + offered[i] = tx.Hash() + } + pool.RebroadcastAttempt(delivered.Txs)(offered) var accepted []common.Hash if partial { for _, tx := range delivered.Txs { diff --git a/core/txpool/legacypool/rebroadcast_scheduling_test.go b/core/txpool/legacypool/rebroadcast_scheduling_test.go index 08c36c919f..f51aeac695 100644 --- a/core/txpool/legacypool/rebroadcast_scheduling_test.go +++ b/core/txpool/legacypool/rebroadcast_scheduling_test.go @@ -45,6 +45,7 @@ func TestRebroadcastAttemptWithoutQueueAcceptance(t *testing.T) { } pool.publishRebroadcastTransactions(batch) <-ch + pool.RebroadcastAttempt(batch)([]common.Hash{tx.Hash()}) pool.RebroadcastAcknowledgement(batch)(nil) pool.mu.Lock() defer pool.mu.Unlock() diff --git a/core/txpool/legacypool/rebroadcast_test.go b/core/txpool/legacypool/rebroadcast_test.go index 45936c605d..eaef251275 100644 --- a/core/txpool/legacypool/rebroadcast_test.go +++ b/core/txpool/legacypool/rebroadcast_test.go @@ -63,8 +63,8 @@ func TestRebroadcastSelectionDoesNotRecordSend(t *testing.T) { attempted := !pool.lastRebroadcastAttempt[tx.Hash()].IsZero() stillEligible := pool.identifyStuckTransactions() pool.mu.RUnlock() - if !attempted || len(stillEligible) != 0 { - t.Fatal("an unacknowledged attempt must still be bounded") + if attempted || len(stillEligible) != 1 { + t.Fatal("an unoffered batch must remain eligible") } pool.RebroadcastAcknowledgement(batch.Txs)([]common.Hash{tx.Hash()}) pool.mu.RLock() diff --git a/core/txpool/rebroadcast_attempt.go b/core/txpool/rebroadcast_attempt.go new file mode 100644 index 0000000000..a6512d9244 --- /dev/null +++ b/core/txpool/rebroadcast_attempt.go @@ -0,0 +1,46 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package txpool + +import ( + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" +) + +// RebroadcastAttempt forwards queue-offer reports to supporting subpools. +func (p *TxPool) RebroadcastAttempt(txs []*types.Transaction) func([]common.Hash) { + if p == nil { + return nil + } + var callbacks []func([]common.Hash) + for _, subpool := range p.subpools { + pool, ok := subpool.(interface { + RebroadcastAttempt([]*types.Transaction) func([]common.Hash) + }) + if !ok { + continue + } + if callback := pool.RebroadcastAttempt(txs); callback != nil { + callbacks = append(callbacks, callback) + } + } + return func(hashes []common.Hash) { + for _, callback := range callbacks { + callback(hashes) + } + } +} diff --git a/core/txpool/txpool_test.go b/core/txpool/txpool_test.go index 042fc396fd..19732d0a13 100644 --- a/core/txpool/txpool_test.go +++ b/core/txpool/txpool_test.go @@ -185,3 +185,75 @@ func TestSetSpeculativeState(t *testing.T) { t.Fatal("speculative state was not forwarded to supporting subpool") } } + +type attemptTestSubPool struct { + SubPool + txs []*types.Transaction + callback func([]common.Hash) +} + +func (p *attemptTestSubPool) RebroadcastAttempt(txs []*types.Transaction) func([]common.Hash) { + p.txs = txs + return p.callback +} + +func TestRebroadcastAttemptWithoutCallbacks(t *testing.T) { + var absent *TxPool + if callback := absent.RebroadcastAttempt(nil); callback != nil { + t.Fatal("nil pool returned an acknowledgement callback") + } + for _, tc := range []struct { + name string + pools []SubPool + }{ + {"empty", nil}, + {"unsupported", []SubPool{new(plainTestSubPool)}}, + {"nil callback", []SubPool{new(attemptTestSubPool)}}, + } { + t.Run(tc.name, func(t *testing.T) { + pool := &TxPool{subpools: tc.pools} + callback := pool.RebroadcastAttempt(nil) + if callback == nil { + t.Fatal("initialized pool did not return a no-op callback") + } + callback([]common.Hash{{1}}) + }) + } +} + +func TestRebroadcastAttemptForwardsToSubpools(t *testing.T) { + txs := []*types.Transaction{ + types.NewTx(&types.LegacyTx{Nonce: 1}), + types.NewTx(&types.LegacyTx{Nonce: 2}), + } + var received [2][][]common.Hash + subpools := []*attemptTestSubPool{{}, {}} + for i, subpool := range subpools { + subpool.callback = func(hashes []common.Hash) { + received[i] = append(received[i], slices.Clone(hashes)) + } + } + pool := &TxPool{subpools: []SubPool{subpools[0], new(plainTestSubPool), new(attemptTestSubPool), subpools[1]}} + callback := pool.RebroadcastAttempt(txs) + for i, subpool := range subpools { + if !slices.Equal(subpool.txs, txs) || len(received[i]) != 0 { + t.Fatal("creating acknowledgement did not preserve the batch or acknowledged prematurely") + } + } + if callback == nil { + t.Fatal("missing acknowledgement callback") + } + for _, tx := range txs { + callback([]common.Hash{tx.Hash()}) + } + for i, batches := range received { + if len(batches) != len(txs) { + t.Fatalf("subpool %d received %d acknowledgements, want %d", i, len(batches), len(txs)) + } + for j, hashes := range batches { + if !slices.Equal(hashes, []common.Hash{txs[j].Hash()}) { + t.Fatalf("subpool %d received wrong acknowledged hashes: %v", i, hashes) + } + } + } +} diff --git a/eth/api_backend.go b/eth/api_backend.go index b4ccf6519b..80a64f9ab2 100644 --- a/eth/api_backend.go +++ b/eth/api_backend.go @@ -408,19 +408,20 @@ func (b *EthAPIBackend) SubscribeLogsEvent(ch chan<- []*types.Log) event.Subscri } func (b *EthAPIBackend) SendTx(ctx context.Context, signedTx *types.Transaction) error { + if b.isPrivateTx(signedTx.Hash()) { + return relay.ErrPrivateTxConflict + } err := b.eth.txPool.Add([]*types.Transaction{signedTx}, false)[0] + if b.isPrivateTx(signedTx.Hash()) { + return relay.ErrPrivateTxConflict + } // If the local transaction tracker is not configured, returns whatever // returned from the txpool. if b.eth.localTxTracker == nil { return err } - if b.relay != nil { - if getter := b.relay.GetPrivateTxGetter(); getter != nil && getter.IsTxPrivate(signedTx.Hash()) { - // The ordinary retry journal cannot preserve private transaction metadata. - return err - } - } + // If the transaction fails with an error indicating it is invalid, or if there is // very little chance it will be accepted later (e.g., the gas price is below the // configured minimum, or the sender has insufficient funds to cover the cost), diff --git a/eth/api_backend_private.go b/eth/api_backend_private.go new file mode 100644 index 0000000000..87654495fa --- /dev/null +++ b/eth/api_backend_private.go @@ -0,0 +1,37 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package eth + +import ( + "context" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" +) + +func (b *EthAPIBackend) isPrivateTx(hash common.Hash) bool { + if b.relay == nil { + return false + } + getter := b.relay.GetPrivateTxGetter() + return getter != nil && getter.IsTxPrivate(hash) +} + +// SendPrivateTx reports actual pool admission and never enters the public journal. +func (b *EthAPIBackend) SendPrivateTx(ctx context.Context, tx *types.Transaction) error { + return b.eth.txPool.Add([]*types.Transaction{tx}, false)[0] +} diff --git a/eth/api_backend_private_test.go b/eth/api_backend_private_test.go index 3098485477..2392b68828 100644 --- a/eth/api_backend_private_test.go +++ b/eth/api_backend_private_test.go @@ -58,7 +58,11 @@ func TestSendTxPrivateAdmission(t *testing.T) { if private { b.RecordPrivateTx(tx.Hash()) } - err := b.SendTx(t.Context(), tx) + send := b.SendTx + if private { + send = b.SendPrivateTx + } + err := send(t.Context(), tx) if private { require.ErrorIs(t, err, txpool.ErrInflightTxLimitReached) } else { @@ -82,7 +86,7 @@ func TestSendTxPrivateJournal(t *testing.T) { }, time.Second, time.Millisecond) private, public := makeTx(0, nil, nil, key), makeTx(1, nil, nil, key) b.RecordPrivateTx(private.Hash()) - require.NoError(t, b.SendTx(t.Context(), private)) + require.NoError(t, b.SendPrivateTx(t.Context(), private)) require.NoError(t, b.SendTx(t.Context(), public)) require.True(t, b.eth.txPool.Has(private.Hash())) require.True(t, b.eth.txPool.Has(public.Hash())) diff --git a/eth/api_backend_public_privacy_test.go b/eth/api_backend_public_privacy_test.go new file mode 100644 index 0000000000..1db6cc59ff --- /dev/null +++ b/eth/api_backend_public_privacy_test.go @@ -0,0 +1,62 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package eth + +import ( + "math/big" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/eth/relay" + "github.com/ethereum/go-ethereum/internal/ethapi" +) + +func TestSendTxPrivateEvictedPublicResubmission(t *testing.T) { + for _, tracker := range []bool{false, true} { + t.Run(map[bool]string{false: "no tracker", true: "tracker"}[tracker], func(t *testing.T) { + b := privateSubmissionBackend(t, tracker) + tx := makeTx(0, nil, nil, key) + raw, err := tx.MarshalBinary() + require.NoError(t, err) + api := ethapi.NewTransactionAPI(b, new(ethapi.AddrLocker)) + _, err = api.SendRawTransactionPrivate(t.Context(), raw) + require.NoError(t, err) + require.NoError(t, b.eth.txPool.Sync()) + b.eth.txPool.SetGasTip(new(big.Int).Add(tx.GasTipCap(), big.NewInt(1))) + require.False(t, b.eth.txPool.Has(tx.Hash())) + require.True(t, b.isPrivateTx(tx.Hash())) + b.eth.txPool.SetGasTip(big.NewInt(0)) + hash, err := api.SendRawTransaction(t.Context(), raw) + require.ErrorIs(t, err, relay.ErrPrivateTxConflict) + require.Equal(t, common.Hash{}, hash) + require.False(t, b.eth.txPool.Has(tx.Hash())) + require.True(t, b.isPrivateTx(tx.Hash())) + }) + } +} +func TestSendTxPrivatePendingPublicConflict(t *testing.T) { + b := privateSubmissionBackend(t, true) + tx := makeTx(0, nil, nil, key) + finish := b.BeginPrivateTx(tx.Hash()) + require.ErrorIs(t, b.SendTx(t.Context(), tx), relay.ErrPrivateTxConflict) + require.False(t, b.eth.txPool.Has(tx.Hash())) + finish(false) + require.NoError(t, b.SendTx(t.Context(), tx)) + require.False(t, b.isPrivateTx(tx.Hash())) +} diff --git a/eth/fetcher/block_fetcher.go b/eth/fetcher/block_fetcher.go index b30ccbda9e..ed8b3b558f 100644 --- a/eth/fetcher/block_fetcher.go +++ b/eth/fetcher/block_fetcher.go @@ -131,7 +131,8 @@ type blockAnnounce struct { time time.Time // Timestamp of the announcement deferred bool // The gather window elapsed but a request could not start - origin string // Identifier of the peer originating the notification + bodyDeferred bool + origin string // Identifier of the peer originating the notification fetchHeader headerRequesterFn // Fetcher function to retrieve the header of an announced block fetchBodies bodyRequesterFn // Fetcher function to retrieve the body of an announced block @@ -252,6 +253,8 @@ type BlockFetcher struct { fetchTimerAt time.Time fetchRetryAt time.Time fetchRetry time.Duration + completeTimerAt time.Time + completeRetryAt time.Time completeRetry time.Duration // Block cache @@ -696,6 +699,7 @@ func (f *BlockFetcher) loop() { deferred := f.scheduleBodyFetches() // Schedule the next fetch if blocks are still pending f.rescheduleComplete(completeTimer, deferred) + f.rescheduleFetch(fetchTimer, f.scheduleHeaderFetches()) case filter := <-f.headerFilter: // Headers arrived from a remote peer. Extract those that were explicitly @@ -791,9 +795,7 @@ func (f *BlockFetcher) loop() { // Move announce from fetching to fetched state f.fetched[hash] = append(f.fetched[hash], announce) delete(f.fetching, hash) - if len(f.fetched) == 1 { - f.rescheduleComplete(completeTimer, false) - } + f.rearmFreshCompletion(completeTimer, announce) } // Schedule the header for light fetcher import for _, announce := range lightHeaders { @@ -939,19 +941,8 @@ func retryAfter(delay time.Duration, deferred bool) time.Duration { // rescheduleComplete resets the specified completion timer to the next fetch timeout. func (f *BlockFetcher) rescheduleComplete(complete *time.Timer, deferred bool) { - // Short circuit if no headers are fetched - if len(f.fetched) == 0 { - return - } - // Otherwise find the earliest expiring announcement - earliest := time.Now() - for _, announces := range f.fetched { - if earliest.After(announces[0].time) { - earliest = announces[0].time - } - } - - complete.Reset(nextFetchRetry(gatherSlack-time.Since(earliest), deferred, &f.completeRetry)) + f.completeRetryAt = time.Now().Add(nextFetchRetry(0, deferred, &f.completeRetry)) + f.resetCompletionTimer(complete) } // enqueue schedules a new header or block import operation, if the component diff --git a/eth/fetcher/block_fetcher_body_retry.go b/eth/fetcher/block_fetcher_body_retry.go new file mode 100644 index 0000000000..e647551e04 --- /dev/null +++ b/eth/fetcher/block_fetcher_body_retry.go @@ -0,0 +1,71 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package fetcher + +import ( + "time" + + "github.com/ethereum/go-ethereum/common" +) + +// A header held by a cooling supplier must not prevent healthy alternatives +// from retrieving the block. No body request has been admitted at this stage. +func (f *BlockFetcher) rerouteCoolingBody(hash common.Hash, announces []*blockAnnounce) bool { + f.mu.Lock() + defer f.mu.Unlock() + now := time.Now() + for _, announce := range announces { + until, ok := f.fetchBackoff.Peek(announce.origin) + if !ok || !now.Before(until) { + return false + } + } + for _, alternate := range f.announced[hash] { + until, ok := f.fetchBackoff.Peek(alternate.origin) + if !ok || !now.Before(until) { + delete(f.fetched, hash) + return true + } + } + return false +} + +func (f *BlockFetcher) resetCompletionTimer(timer *time.Timer) { + var earliest time.Time + for _, announces := range f.fetched { + announce := announces[0] + deadline := announce.time.Add(gatherSlack) + if announce.bodyDeferred { + deadline = f.completeRetryAt + } + if earliest.IsZero() || deadline.Before(earliest) { + earliest = deadline + } + } + f.completeTimerAt = earliest + if !earliest.IsZero() { + timer.Reset(time.Until(earliest)) + } +} + +func (f *BlockFetcher) rearmFreshCompletion(timer *time.Timer, announce *blockAnnounce) { + deadline := announce.time.Add(gatherSlack) + if f.completeTimerAt.IsZero() || deadline.Before(f.completeTimerAt) { + f.completeTimerAt = deadline + timer.Reset(time.Until(deadline)) + } +} diff --git a/eth/fetcher/block_fetcher_body_retry_test.go b/eth/fetcher/block_fetcher_body_retry_test.go new file mode 100644 index 0000000000..8d6ab2dd91 --- /dev/null +++ b/eth/fetcher/block_fetcher_body_retry_test.go @@ -0,0 +1,125 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package fetcher + +import ( + "testing" + "testing/synctest" + "time" + + "github.com/stretchr/testify/require" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + ethproto "github.com/ethereum/go-ethereum/eth/protocols/eth" +) + +func TestBodyRetryCoolingSupplier(t *testing.T) { + for _, mode := range []string{"healthy alternative", "cooling alternative", "no alternative", "supplier healthy", "expired cooldown"} { + t.Run(mode, func(t *testing.T) { + p := newScheduleProbe(t) + f := p.fetcher + hash := p.announce("other", time.Second) + f.fetched[hash] = []*blockAnnounce{{origin: "slow", time: time.Now()}} + if mode == "no alternative" { + f.dropAnnouncements(hash, f.announced[hash]) + } + if mode != "supplier healthy" { + f.backoffFetchPeer("slow") + } + if mode == "expired cooldown" { + f.fetchBackoff.Add("slow", time.Now().Add(-time.Second)) + } + if mode == "cooling alternative" { + f.backoffFetchPeer("other") + } + want := mode == "healthy alternative" + require.Equal(t, want, f.rerouteCoolingBody(hash, f.fetched[hash])) + if want { + require.Nil(t, f.fetched[hash]) + require.Equal(t, 1, f.announces["other"]) + require.False(t, f.scheduleHeaderFetches()) + require.Equal(t, hash, <-p.started) + require.Equal(t, "other", f.fetching[hash].origin) + } else { + require.Len(t, f.fetched[hash], 1) + } + }) + } +} +func TestBodyRetryFreshCompletionDeadline(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + p := newScheduleProbe(t) + f := p.fetcher + stalled := common.Hash{1} + f.fetched[stalled] = []*blockAnnounce{{origin: "slow", time: time.Now().Add(-time.Second)}} + f.backoffFetchPeer("slow") + require.True(t, f.scheduleBodyFetches()) + f.completeRetry = time.Second + timer := time.NewTimer(time.Hour) + defer timer.Stop() + f.rescheduleComplete(timer, true) + retryAt := f.completeRetryAt + fresh := &blockAnnounce{origin: "fresh", time: time.Now()} + f.fetched[common.Hash{2}] = []*blockAnnounce{fresh} + start := time.Now() + f.rearmFreshCompletion(timer, fresh) + <-timer.C + require.Equal(t, gatherSlack, time.Since(start)) + require.Equal(t, retryAt, f.completeRetryAt) + require.Equal(t, time.Second, f.completeRetry) + delete(f.fetched, common.Hash{2}) + f.resetCompletionTimer(timer) + <-timer.C + require.Equal(t, retryAt, time.Now()) + }) +} + +func TestBodyRetryLateHeaderUsesAlternative(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + hashes, blocks := makeChain(1, 85, genesis) + block := blocks[hashes[0]] + tester := newTester(false) + f := tester.fetcher + defer f.Stop() + defer f.wm.witnessCache.Stop() + imported := make(chan struct{}, 1) + f.importedHook = func(*types.Header, *types.Block) { imported <- struct{}{} } + requests := make(chan chan *ethproto.Response, 1) + request := ðproto.Request{Cancel: make(chan struct{})} + fetch := func(_ common.Hash, sink chan *ethproto.Response) (*ethproto.Request, error) { + requests <- sink + return request, nil + } + require.NoError(t, f.Notify("slow", block.Hash(), block.NumberU64(), time.Now(), fetch, tester.makeBodyFetcher("slow", blocks, 0), nil)) + sink := <-requests + f.backoffFetchPeer("slow") + start := time.Now() + require.NoError(t, f.Notify("healthy", block.Hash(), block.NumberU64(), time.Now(), tester.makeHeaderFetcher("healthy", blocks, 0), tester.makeBodyFetcher("healthy", blocks, 0), nil)) + headers := ethproto.BlockHeadersRequest{block.Header()} + sink <- ðproto.Response{Req: request, Res: &headers, Done: make(chan error, 1)} + select { + case <-imported: + case <-time.After(2 * time.Second): + t.Fatal("healthy alternative did not recover block during supplier cooldown") + } + require.Less(t, time.Since(start), fetchPeerBackoff) + resume := pauseBlockTimeoutLoop(t, f) + defer resume() + require.Empty(t, f.fetched) + }) +} diff --git a/eth/fetcher/block_fetcher_schedule.go b/eth/fetcher/block_fetcher_schedule.go index cdeb6acbf6..5f513ee3f6 100644 --- a/eth/fetcher/block_fetcher_schedule.go +++ b/eth/fetcher/block_fetcher_schedule.go @@ -186,6 +186,10 @@ func (f *BlockFetcher) scheduleBodyFetches() bool { deferred := false for hash, announces := range f.fetched { + if f.rerouteCoolingBody(hash, announces) { + continue + } + announces[0].bodyDeferred = true announce := f.acquireBodyAnnouncer(announces, admitted) if announce == nil { deferred = true diff --git a/eth/handler.go b/eth/handler.go index bbd4ad6f10..9053d9533d 100644 --- a/eth/handler.go +++ b/eth/handler.go @@ -918,7 +918,7 @@ func (h *handler) BroadcastTransactions(txs types.Transactions) { h.broadcastTransactions(txs, nil) } -func (h *handler) broadcastTransactions(txs types.Transactions, onBroadcast func([]common.Hash)) bool { +func (h *handler) broadcastTransactions(txs types.Transactions, onBroadcast func([]common.Hash), onAttempt ...func([]common.Hash)) bool { var ( blobTxs int // Number of blob transactions to announce only largeTxs int // Number of large transactions to announce only @@ -962,8 +962,8 @@ func (h *handler) broadcastTransactions(txs types.Transactions, onBroadcast func assignTransactionPeers(tx.Hash(), peers, directSet, txset, annos) } - directCount := queueTransactions(txset, false, onBroadcast) - annCount := queueTransactions(annos, true, onBroadcast) + directCount := queueTransactions(txset, false, onBroadcast, onAttempt...) + annCount := queueTransactions(annos, true, onBroadcast, onAttempt...) log.Debug("Distributed transactions", "plaintxs", len(txs)-blobTxs-largeTxs-conditionalTxs, "blobtxs", blobTxs, "largetxs", largeTxs, "conditionaltxs", conditionalTxs, "bcastcount", directCount, "anncount", annCount) @@ -1091,7 +1091,7 @@ func (h *handler) stuckTxBroadcastLoop() { } func (h *handler) rebroadcastStuckTransactions(txs types.Transactions, onBroadcast func([]common.Hash)) bool { - if !h.canRebroadcast() { + if h.disableTxPropagation || !h.canRebroadcast() { return false } hashes := make([]common.Hash, len(txs)) @@ -1099,7 +1099,7 @@ func (h *handler) rebroadcastStuckTransactions(txs types.Transactions, onBroadca hashes[i] = tx.Hash() } h.peers.ForgetTransactions(hashes) - return h.broadcastTransactions(txs, onBroadcast) + return h.broadcastTransactions(txs, onBroadcast, h.rebroadcastAttempt(txs)) } // enableSyncedFeatures enables the post-sync functionalities when the initial diff --git a/eth/rebroadcast.go b/eth/rebroadcast.go index 6da792a028..ad85c809f3 100644 --- a/eth/rebroadcast.go +++ b/eth/rebroadcast.go @@ -43,6 +43,15 @@ func (h *handler) rebroadcastAcknowledgement(txs []*types.Transaction) func([]co return nil } +func (h *handler) rebroadcastAttempt(txs []*types.Transaction) func([]common.Hash) { + if pool, ok := h.txpool.(interface { + RebroadcastAttempt([]*types.Transaction) func([]common.Hash) + }); ok { + return pool.RebroadcastAttempt(txs) + } + return nil +} + func assignTransactionPeers(hash common.Hash, peers []*ethPeer, directSet map[*ethPeer]struct{}, txset, annos map[*ethPeer][]common.Hash) { for _, peer := range peers { if peer.KnownTransaction(hash) { @@ -56,13 +65,14 @@ func assignTransactionPeers(hash common.Hash, peers []*ethPeer, directSet map[*e } } -func queueTransactions(peers map[*ethPeer][]common.Hash, announce bool, onBroadcast func([]common.Hash)) int { +func queueTransactions(peers map[*ethPeer][]common.Hash, announce bool, onBroadcast func([]common.Hash), onAttempt ...func([]common.Hash)) int { count := 0 for peer, hashes := range peers { send := peer.QueueTransactions if announce { send = peer.QueuePooledTransactionHashes } + reportQueueAttempt(hashes, onAttempt) if retained := send(hashes); len(retained) > 0 { count += len(retained) if onBroadcast != nil { @@ -107,3 +117,11 @@ func rebroadcastHeadTD(chain *core.BlockChain, hash common.Hash) *big.Int { } return nil } + +func reportQueueAttempt(hashes []common.Hash, callbacks []func([]common.Hash)) { + for _, report := range callbacks { + if report != nil { + report(hashes) + } + } +} diff --git a/eth/rebroadcast_attempt_test.go b/eth/rebroadcast_attempt_test.go new file mode 100644 index 0000000000..1eb10c8838 --- /dev/null +++ b/eth/rebroadcast_attempt_test.go @@ -0,0 +1,73 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package eth + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + ethproto "github.com/ethereum/go-ethereum/eth/protocols/eth" + "github.com/ethereum/go-ethereum/p2p" + "github.com/ethereum/go-ethereum/p2p/enode" +) + +type attemptReportingPool struct { + txPool + attempted []common.Hash +} + +func (p *attemptReportingPool) RebroadcastAttempt(txs []*types.Transaction) func([]common.Hash) { + return func(hashes []common.Hash) { p.attempted = append(p.attempted, hashes...) } +} +func TestRebroadcastActualQueueAttempts(t *testing.T) { + for _, mode := range []string{"suppressed", "disabled", "no peers", "private", "rejected", "accepted"} { + t.Run(mode, func(t *testing.T) { + h, txs := rebroadcastDeliveryFixture(t, mode != "no peers" && mode != "rejected") + pool := &attemptReportingPool{txPool: h.txpool} + h.txpool = pool + switch mode { + case "suppressed": + h.synced.Store(false) + case "disabled": + h.disableTxPropagation = true + case "private": + h.privateTxGetter = &PrivateTxStore{store: map[common.Hash]struct{}{txs[0].Hash(): {}, txs[1].Hash(): {}}} + case "rejected": + app, net := p2p.MsgPipe() + peer := ethproto.NewPeer(ethproto.ETH69, p2p.NewPeer(enode.ID{2}, "closed", nil), net, pool) + require.NoError(t, h.peers.registerPeer(peer, nil, nil)) + peer.Close() + t.Cleanup(func() { app.Close(); net.Close() }) + } + accepted := h.rebroadcastStuckTransactions(txs, nil) + require.Equal(t, mode == "accepted", accepted) + if mode == "accepted" || mode == "rejected" { + require.ElementsMatch(t, []common.Hash{txs[0].Hash(), txs[1].Hash()}, pool.attempted) + } else { + require.Empty(t, pool.attempted) + } + if mode == "suppressed" { + h.synced.Store(true) + require.True(t, h.rebroadcastStuckTransactions(txs, nil)) + require.Len(t, pool.attempted, len(txs)) + } + }) + } +} diff --git a/eth/relay/private_tx_errors.go b/eth/relay/private_tx_errors.go new file mode 100644 index 0000000000..522d544306 --- /dev/null +++ b/eth/relay/private_tx_errors.go @@ -0,0 +1,21 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package relay + +import "errors" + +var ErrPrivateTxConflict = errors.New("transaction has private submission state; public submission refused") diff --git a/internal/ethapi/api.go b/internal/ethapi/api.go index 20b87695f4..1d784ddaec 100644 --- a/internal/ethapi/api.go +++ b/internal/ethapi/api.go @@ -2501,7 +2501,7 @@ func (api *TransactionAPI) SendRawTransactionPrivate(ctx context.Context, input finish := api.b.BeginPrivateTx(tx.Hash()) defer finish(false) - _, err := SubmitTransaction(ctx, api.b, tx) + _, err := SubmitTransaction(ctx, privateTxBackend{api.b}, tx) finish(err == nil) return api.forwardPrivateTransaction(tx, err) } diff --git a/internal/ethapi/api_private.go b/internal/ethapi/api_private.go index f0936f2bc2..5ed836ff7a 100644 --- a/internal/ethapi/api_private.go +++ b/internal/ethapi/api_private.go @@ -17,6 +17,7 @@ package ethapi import ( + "context" "fmt" "github.com/ethereum/go-ethereum/common" @@ -40,3 +41,15 @@ func (api *TransactionAPI) forwardPrivateTransaction(tx *types.Transaction, loca } return tx.Hash(), nil } + +// Keep validation and logging shared while selecting explicit private admission. +type privateTxBackend struct{ Backend } + +func (b privateTxBackend) SendTx(ctx context.Context, tx *types.Transaction) error { + if private, ok := b.Backend.(interface { + SendPrivateTx(context.Context, *types.Transaction) error + }); ok { + return private.SendPrivateTx(ctx, tx) + } + return b.Backend.SendTx(ctx, tx) +} From 52b09357d71c96e57f1fbd67a9f37fa0366ceded Mon Sep 17 00:00:00 2001 From: Vikram Bhattacharjee Date: Thu, 22 Oct 2026 20:28:30 +0530 Subject: [PATCH 07/15] eth: model queue offers in sync recovery regression --- eth/sync_completion_test.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/eth/sync_completion_test.go b/eth/sync_completion_test.go index 31e82f0338..ea8b51bb06 100644 --- a/eth/sync_completion_test.go +++ b/eth/sync_completion_test.go @@ -57,6 +57,13 @@ func TestEnableSyncedFeaturesAnnouncesExpiredPending(t *testing.T) { if h.rebroadcastStuckTransactions(event.Txs, pool.RebroadcastAcknowledgement(event.Txs)) { t.Fatal("initial sync must suppress periodic rebroadcast") } + // Model an actual queue offer before its retry window expires during sync. + h.txAnnouncementOnly = true + h.peers.ForgetTransactions([]common.Hash{tx.Hash()}) + if !h.broadcastTransactions(event.Txs, pool.RebroadcastAcknowledgement(event.Txs), pool.RebroadcastAttempt(event.Txs)) { + t.Fatal("fixture must offer the transaction to a peer queue") + } + expectSyncCompletionAnnouncement(t, sink, tx) default: t.Fatal("missing first rebroadcast attempt") } From dbb69db8f56842568c6ced200a9ab971f1dd90b5 Mon Sep 17 00:00:00 2001 From: Vikram Bhattacharjee Date: Thu, 22 Oct 2026 21:01:01 +0530 Subject: [PATCH 08/15] ci: use published Bor release for pipeline baseline --- .github/workflows/kurtosis-pipeline-e2e.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/.github/workflows/kurtosis-pipeline-e2e.yml b/.github/workflows/kurtosis-pipeline-e2e.yml index 5324ffe256..6d52164868 100644 --- a/.github/workflows/kurtosis-pipeline-e2e.yml +++ b/.github/workflows/kurtosis-pipeline-e2e.yml @@ -155,6 +155,27 @@ jobs: - name: Copy kurtosis config run: cp ./pos-workflows/configs/kurtosis-pipeline-e2e.yml ./kurtosis-pipeline-e2e.yml + # Keep the baseline on a released build, independent of bor:local. + - name: Use the published release for the baseline node + run: | + python3 - <<'PYTHON' + from pathlib import Path + import re + import yaml + + config = Path("kurtosis-pipeline-e2e.yml") + data = yaml.safe_load(config.read_text()) + participants = data["polygon_pos_package"]["participants"] + released = {p["el_image"] for p in participants + if re.fullmatch(r"ghcr\.io/0xpolygon/bor:v[0-9]+\.[0-9]+\.[0-9]+", p.get("el_image", ""))} + if len(released) != 1: + raise SystemExit("Expected one stable GHCR Bor release for the baseline") + for participant in participants: + if participant.get("el_image") == "0xpolygon/bor:latest": + participant["el_image"] = next(iter(released)) + config.write_text(yaml.safe_dump(data, sort_keys=False)) + PYTHON + # Load images - name: Download bor docker image uses: actions/download-artifact@v4 From 27a2047b9644260a2a113c9b0057ebd0f3db6b92 Mon Sep 17 00:00:00 2001 From: Vikram Bhattacharjee Date: Fri, 23 Oct 2026 13:28:52 +0530 Subject: [PATCH 09/15] ci: remove YAML dependency from pipeline image selection --- .github/workflows/kurtosis-pipeline-e2e.yml | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/.github/workflows/kurtosis-pipeline-e2e.yml b/.github/workflows/kurtosis-pipeline-e2e.yml index 6d52164868..5c20b10723 100644 --- a/.github/workflows/kurtosis-pipeline-e2e.yml +++ b/.github/workflows/kurtosis-pipeline-e2e.yml @@ -161,19 +161,21 @@ jobs: python3 - <<'PYTHON' from pathlib import Path import re - import yaml config = Path("kurtosis-pipeline-e2e.yml") - data = yaml.safe_load(config.read_text()) - participants = data["polygon_pos_package"]["participants"] - released = {p["el_image"] for p in participants - if re.fullmatch(r"ghcr\.io/0xpolygon/bor:v[0-9]+\.[0-9]+\.[0-9]+", p.get("el_image", ""))} + content = config.read_text() + released = set(re.findall( + r"(?m)^\s*el_image: (ghcr\.io/0xpolygon/bor:v[0-9]+\.[0-9]+\.[0-9]+)\s*$", + content, + )) if len(released) != 1: raise SystemExit("Expected one stable GHCR Bor release for the baseline") - for participant in participants: - if participant.get("el_image") == "0xpolygon/bor:latest": - participant["el_image"] = next(iter(released)) - config.write_text(yaml.safe_dump(data, sort_keys=False)) + content = re.sub( + r"(?m)^(\s*el_image: )0xpolygon/bor:latest(?=\s*$)", + lambda match: match[1] + next(iter(released)), + content, + ) + config.write_text(content) PYTHON # Load images From bdd681baba2b4d5ffbd19b5fd3584d5ed878548c Mon Sep 17 00:00:00 2001 From: Vikram Bhattacharjee Date: Fri, 23 Oct 2026 15:44:42 +0530 Subject: [PATCH 10/15] eth, relay: bound private lifetime and preserve duplicate responses --- docs/private-transactions.md | 20 +++++++ eth/api_backend.go | 8 +-- eth/api_backend_private.go | 13 +++++ eth/api_backend_private_test.go | 1 + eth/api_backend_public_privacy_test.go | 54 +++++++++++++++++++ eth/relay/private_tx_store.go | 24 ++++----- eth/relay/private_tx_store_submission_test.go | 2 +- eth/relay/private_tx_store_test.go | 28 +++++++++- eth/relay/relay_private_submission_test.go | 14 +++-- 9 files changed, 142 insertions(+), 22 deletions(-) create mode 100644 docs/private-transactions.md diff --git a/docs/private-transactions.md b/docs/private-transactions.md new file mode 100644 index 0000000000..c770e3b39f --- /dev/null +++ b/docs/private-transactions.md @@ -0,0 +1,20 @@ +# Private transaction lifetime + +A privately admitted transaction is excluded from public peer announcements and +rebroadcasts for up to 30 minutes after its last successful private admission. +The store checks expiry every minute, so public eligibility can begin up to one +minute later. An in-flight private admission keeps its reservation until the +call completes. + +After expiry, a transaction still in the pool becomes eligible for ordinary +public propagation. This fallback allows progress when all block producers +reject the transaction or relay retries are exhausted. Expiry does not remove +the transaction from the pool or shorten the pool's own retention policy. +Private submission therefore does not guarantee indefinite confidentiality. + +Before expiry, resubmitting the same transaction through a public endpoint +returns `already known` if it is still in the pool, without adding it to the +public retry journal. If it has left the pool but still has a private marker, +public resubmission returns a private submission conflict. Markers for absent +transactions are swept after a two-minute grace period when pool presence can +be checked; the 30-minute expiry applies even without a pool checker. diff --git a/eth/api_backend.go b/eth/api_backend.go index 80a64f9ab2..6352fd4dbd 100644 --- a/eth/api_backend.go +++ b/eth/api_backend.go @@ -408,12 +408,12 @@ func (b *EthAPIBackend) SubscribeLogsEvent(ch chan<- []*types.Log) event.Subscri } func (b *EthAPIBackend) SendTx(ctx context.Context, signedTx *types.Transaction) error { - if b.isPrivateTx(signedTx.Hash()) { - return relay.ErrPrivateTxConflict + if err := b.privateTxSubmissionError(signedTx.Hash()); err != nil { + return err } err := b.eth.txPool.Add([]*types.Transaction{signedTx}, false)[0] - if b.isPrivateTx(signedTx.Hash()) { - return relay.ErrPrivateTxConflict + if err := b.privateTxSubmissionError(signedTx.Hash()); err != nil { + return err } // If the local transaction tracker is not configured, returns whatever diff --git a/eth/api_backend_private.go b/eth/api_backend_private.go index 87654495fa..4066e38756 100644 --- a/eth/api_backend_private.go +++ b/eth/api_backend_private.go @@ -20,7 +20,9 @@ import ( "context" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/txpool" "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/eth/relay" ) func (b *EthAPIBackend) isPrivateTx(hash common.Hash) bool { @@ -35,3 +37,14 @@ func (b *EthAPIBackend) isPrivateTx(hash common.Hash) bool { func (b *EthAPIBackend) SendPrivateTx(ctx context.Context, tx *types.Transaction) error { return b.eth.txPool.Add([]*types.Transaction{tx}, false)[0] } + +// A duplicate does not re-admit or publicly journal a private transaction. +func (b *EthAPIBackend) privateTxSubmissionError(hash common.Hash) error { + if !b.isPrivateTx(hash) { + return nil + } + if b.eth.txPool.Has(hash) { + return txpool.ErrAlreadyKnown + } + return relay.ErrPrivateTxConflict +} diff --git a/eth/api_backend_private_test.go b/eth/api_backend_private_test.go index 2392b68828..390fab68d8 100644 --- a/eth/api_backend_private_test.go +++ b/eth/api_backend_private_test.go @@ -87,6 +87,7 @@ func TestSendTxPrivateJournal(t *testing.T) { private, public := makeTx(0, nil, nil, key), makeTx(1, nil, nil, key) b.RecordPrivateTx(private.Hash()) require.NoError(t, b.SendPrivateTx(t.Context(), private)) + require.ErrorIs(t, b.SendTx(t.Context(), private), txpool.ErrAlreadyKnown) require.NoError(t, b.SendTx(t.Context(), public)) require.True(t, b.eth.txPool.Has(private.Hash())) require.True(t, b.eth.txPool.Has(public.Hash())) diff --git a/eth/api_backend_public_privacy_test.go b/eth/api_backend_public_privacy_test.go index 1db6cc59ff..a6009f3f11 100644 --- a/eth/api_backend_public_privacy_test.go +++ b/eth/api_backend_public_privacy_test.go @@ -23,6 +23,9 @@ import ( "github.com/stretchr/testify/require" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/txpool" + "github.com/ethereum/go-ethereum/core/txpool/legacypool" + "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/eth/relay" "github.com/ethereum/go-ethereum/internal/ethapi" ) @@ -60,3 +63,54 @@ func TestSendTxPrivatePendingPublicConflict(t *testing.T) { require.NoError(t, b.SendTx(t.Context(), tx)) require.False(t, b.isPrivateTx(tx.Hash())) } + +func TestSendTxPrivateResidentPublicDuplicate(t *testing.T) { + for _, tracker := range []bool{false, true} { + t.Run(map[bool]string{false: "no tracker", true: "tracker"}[tracker], func(t *testing.T) { + b := privateSubmissionBackend(t, tracker) + tx := makeTx(0, nil, nil, key) + raw, err := tx.MarshalBinary() + require.NoError(t, err) + api := ethapi.NewTransactionAPI(b, new(ethapi.AddrLocker)) + _, err = api.SendRawTransactionPrivate(t.Context(), raw) + require.NoError(t, err) + require.NoError(t, b.eth.txPool.Sync()) + _, err = api.SendRawTransaction(t.Context(), raw) + require.ErrorIs(t, err, txpool.ErrAlreadyKnown) + require.True(t, b.eth.txPool.Has(tx.Hash())) + require.True(t, b.isPrivateTx(tx.Hash())) + h := &handler{txpool: b.eth.txPool, privateTxGetter: b.relay.GetPrivateTxGetter()} + require.NotContains(t, h.pendingTransactionHashes(), tx.Hash()) + }) + } +} + +type privateAdmissionSubPool struct { + txpool.SubPool + onAdd func() +} + +func (p *privateAdmissionSubPool) Add(txs []*types.Transaction, sync bool) []error { + errors := p.SubPool.Add(txs, sync) + p.onAdd() + return errors +} + +func TestSendTxPrivateAdmissionDuringPublicCall(t *testing.T) { + b := privateSubmissionBackend(t, false) + require.NoError(t, b.eth.txPool.Close()) + tx := makeTx(0, nil, nil, key) + config := legacypool.DefaultConfig + config.Journal = "" + subpool := &privateAdmissionSubPool{ + SubPool: legacypool.New(config, b.eth.blockchain), + onAdd: func() { b.RecordPrivateTx(tx.Hash()) }, + } + pool, err := txpool.New(config.PriceLimit, b.eth.blockchain, []txpool.SubPool{subpool}) + require.NoError(t, err) + b.eth.txPool = pool + b.relay.SetTxPoolChecker(pool.Has) + require.ErrorIs(t, b.SendTx(t.Context(), tx), txpool.ErrAlreadyKnown) + require.True(t, pool.Has(tx.Hash())) + require.True(t, b.isPrivateTx(tx.Hash())) +} diff --git a/eth/relay/private_tx_store.go b/eth/relay/private_tx_store.go index 6cdc13caa7..6075e298b4 100644 --- a/eth/relay/private_tx_store.go +++ b/eth/relay/private_tx_store.go @@ -14,8 +14,9 @@ import ( ) const ( - privateTxGracePeriod = 2 * time.Minute // min age before txpool presence check applies - sweepInterval = 1 * time.Minute // how often the sweep goroutine runs + privateTxTTL = 30 * time.Minute // release stalled transactions for public propagation + privateTxGracePeriod = 2 * time.Minute // min age before txpool presence check applies + sweepInterval = 1 * time.Minute // how often the sweep goroutine runs ) var privateTxStoreSizeGauge = metrics.NewRegisteredGauge("relay/privatetx/store/size", nil) @@ -44,7 +45,7 @@ type PrivateTxStore struct { txsAdded atomic.Uint64 txsPurged atomic.Uint64 // deleted by an explicit call txsDeleted atomic.Uint64 // deleted because tx got included - txsExpired atomic.Uint64 // deleted by sweep after txpool eviction + txsExpired atomic.Uint64 // deleted by sweep after expiry or txpool eviction closeCh chan struct{} } @@ -148,7 +149,7 @@ func (s *PrivateTxStore) SetTxPoolChecker(checker TxPoolChecker) { s.txPoolChecker = checker } -// Pool residence can exceed any fixed TTL; only absence permits expiration. +// Release stalled private transactions without removing them from the pool. func (s *PrivateTxStore) sweep() { ticker := time.NewTicker(sweepInterval) defer ticker.Stop() @@ -175,24 +176,19 @@ func (s *PrivateTxStore) sweepOnce() { // Snapshot under the read lock. s.mu.RLock() checker := s.txPoolChecker - if checker == nil { - // Keep markers until pool ownership can be checked. - s.mu.RUnlock() - return - } entries := make([]entry, 0, len(s.txs)) for h, t := range s.txs { entries = append(entries, entry{h, t}) } s.mu.RUnlock() - // Filter transactions without holding lock. An entry is removed only if the - // txpool no longer holds it and the grace period has elapsed. + // Check pool presence outside the lock. TTL expiry also releases transactions + // that remain pending after all producer retries have failed. now := time.Now() toDelete := make([]entry, 0) for _, entry := range entries { age := now.Sub(entry.addedAt) - if age > privateTxGracePeriod && !checker(entry.hash) { + if privateTxExpired(age, checker, entry.hash) { toDelete = append(toDelete, entry) } } @@ -241,3 +237,7 @@ func (s *PrivateTxStore) report() { func (s *PrivateTxStore) Close() { close(s.closeCh) } + +func privateTxExpired(age time.Duration, checker TxPoolChecker, hash common.Hash) bool { + return age >= privateTxTTL || (age > privateTxGracePeriod && checker != nil && !checker(hash)) +} diff --git a/eth/relay/private_tx_store_submission_test.go b/eth/relay/private_tx_store_submission_test.go index 95179af81c..b889827686 100644 --- a/eth/relay/private_tx_store_submission_test.go +++ b/eth/relay/private_tx_store_submission_test.go @@ -168,7 +168,7 @@ func TestPrivateTxStoreSubmissionCommitProtectsStaleSweep(t *testing.T) { defer s.Close() hash := common.Hash{1} s.mu.Lock() - s.txs[hash] = time.Now().Add(-time.Hour) + s.txs[hash] = time.Now().Add(-11 * time.Minute) s.mu.Unlock() checked, release, done := make(chan struct{}), make(chan struct{}), make(chan struct{}) defer close(release) diff --git a/eth/relay/private_tx_store_test.go b/eth/relay/private_tx_store_test.go index 17025cc5aa..500dcd18da 100644 --- a/eth/relay/private_tx_store_test.go +++ b/eth/relay/private_tx_store_test.go @@ -1,6 +1,7 @@ package relay import ( + "fmt" "math/big" "sync" "testing" @@ -218,7 +219,9 @@ func TestPrivateTxStoreSweepPoolRetention(t *testing.T) { {"absent past grace", 3 * time.Minute, true, false, false}, {"absent past former TTL", 11 * time.Minute, true, false, false}, {"present past former TTL", 11 * time.Minute, true, true, true}, - {"present for a day", 24 * time.Hour, true, true, true}, + {"present before TTL", privateTxTTL - time.Minute, true, true, true}, + {"present past TTL", privateTxTTL + time.Minute, true, true, false}, + {"unknown past TTL", privateTxTTL + time.Minute, false, false, false}, {"unknown past grace", 3 * time.Minute, false, false, true}, {"unknown past former TTL", 11 * time.Minute, false, false, true}, } { @@ -251,7 +254,7 @@ func TestPrivateTxStoreSweepCheckerAvailability(t *testing.T) { hash := common.HexToHash("0x1") store.Add(hash) store.mu.Lock() - store.txs[hash] = time.Now().Add(-time.Hour) + store.txs[hash] = time.Now().Add(-11 * time.Minute) store.mu.Unlock() store.sweepOnce() @@ -351,3 +354,24 @@ func TestPrivateTxStoreSetTxPoolChecker(t *testing.T) { store.SetTxPoolChecker(checker) require.NotNil(t, store.txPoolChecker, "txPoolChecker should be set after calling SetTxPoolChecker") } + +func TestPrivateTxExpiryBoundary(t *testing.T) { + for _, age := range []time.Duration{privateTxTTL - time.Nanosecond, privateTxTTL, privateTxTTL + time.Nanosecond} { + for _, present := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/present=%t", age, present), func(t *testing.T) { + checker := TxPoolChecker(nil) + if present { + checker = func(common.Hash) bool { return true } + } + require.Equal(t, age >= privateTxTTL, privateTxExpired(age, checker, common.Hash{1})) + }) + } + } +} + +func TestPrivateTxGraceBoundary(t *testing.T) { + checker := func(common.Hash) bool { return false } + for _, age := range []time.Duration{privateTxGracePeriod - time.Nanosecond, privateTxGracePeriod, privateTxGracePeriod + time.Nanosecond} { + require.Equal(t, age > privateTxGracePeriod, privateTxExpired(age, checker, common.Hash{1})) + } +} diff --git a/eth/relay/relay_private_submission_test.go b/eth/relay/relay_private_submission_test.go index 10ab1293d5..a47c03e994 100644 --- a/eth/relay/relay_private_submission_test.go +++ b/eth/relay/relay_private_submission_test.go @@ -49,7 +49,7 @@ func TestPrivateTxRelayReservation(t *testing.T) { } } -func TestPrivateTxRelayFailureStaysPrivate(t *testing.T) { +func TestPrivateTxRelayFailureExpires(t *testing.T) { store := NewPrivateTxStore() defer store.Close() s := &RelayService{privateTxStore: store} @@ -59,11 +59,19 @@ func TestPrivateTxRelayFailureStaysPrivate(t *testing.T) { t.Fatal("unconfigured relay unexpectedly accepted the transaction") } store.mu.Lock() - store.txs[tx.Hash()] = time.Now().Add(-24 * time.Hour) + store.txs[tx.Hash()] = time.Now().Add(-11 * time.Minute) store.mu.Unlock() store.SetTxPoolChecker(func(hash common.Hash) bool { return hash == tx.Hash() }) store.sweepOnce() if !store.IsTxPrivate(tx.Hash()) { - t.Fatal("relay failure or elapsed time made a retained private transaction public") + t.Fatal("relay failure released the transaction before the private window ended") + } + + store.mu.Lock() + store.txs[tx.Hash()] = time.Now().Add(-privateTxTTL - time.Minute) + store.mu.Unlock() + store.sweepOnce() + if s.GetPrivateTxGetter().IsTxPrivate(tx.Hash()) { + t.Fatal("expired pool-resident transaction is still hidden from public propagation") } } From 499d67470d49290f4d2b44935c8d011054bc314c Mon Sep 17 00:00:00 2001 From: Vikram Bhattacharjee Date: Fri, 23 Oct 2026 18:10:21 +0530 Subject: [PATCH 11/15] eth/fetcher: retry omitted bodies after partial responses --- eth/fetcher/block_fetcher.go | 4 +- .../block_fetcher_body_request_test.go | 2 +- eth/fetcher/block_fetcher_partial_test.go | 132 ++++++++++++++++++ eth/fetcher/block_fetcher_release.go | 22 ++- eth/fetcher/block_fetcher_request.go | 6 +- 5 files changed, 158 insertions(+), 8 deletions(-) create mode 100644 eth/fetcher/block_fetcher_partial_test.go diff --git a/eth/fetcher/block_fetcher.go b/eth/fetcher/block_fetcher.go index ed8b3b558f..e348c915d9 100644 --- a/eth/fetcher/block_fetcher.go +++ b/eth/fetcher/block_fetcher.go @@ -676,7 +676,9 @@ func (f *BlockFetcher) loop() { f.forgetHash(hash) // This calls wm.forget f.forgetBlock(hash) // This calls wm.forget case release := <-f.fetchRelease: - f.releaseFetchOwnership(release) + if f.releaseFetchOwnership(release) { + f.rescheduleComplete(completeTimer, true) + } case op := <-f.witnessRetry: // An import failed with a witness accepted on the WIT2 size oracle diff --git a/eth/fetcher/block_fetcher_body_request_test.go b/eth/fetcher/block_fetcher_body_request_test.go index 4ed3e2865e..93cd8e09e6 100644 --- a/eth/fetcher/block_fetcher_body_request_test.go +++ b/eth/fetcher/block_fetcher_body_request_test.go @@ -184,7 +184,7 @@ func TestFetchSlotBodyResponseReleasesMissing(t *testing.T) { if f.queued[hashes[0]] == nil || f.completing[hashes[0]] != owned[0] { t.Error("partial response lost its delivered body before import") } - } else if len(f.completing) != 0 || len(f.queued) != 0 { + } else if len(f.completing) != 0 || len(f.queued) != 0 || len(f.fetched) != 0 { t.Error("empty response retained body ownership or queued a block") } }) diff --git a/eth/fetcher/block_fetcher_partial_test.go b/eth/fetcher/block_fetcher_partial_test.go new file mode 100644 index 0000000000..d6e24114b6 --- /dev/null +++ b/eth/fetcher/block_fetcher_partial_test.go @@ -0,0 +1,132 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package fetcher + +import ( + "math/big" + "testing" + "testing/synctest" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + ethproto "github.com/ethereum/go-ethereum/eth/protocols/eth" + "github.com/ethereum/go-ethereum/rlp" + "github.com/ethereum/go-ethereum/trie" + "github.com/stretchr/testify/require" +) + +type partialBodyRequest struct { + count int + at time.Time +} + +func TestBlockFetchPartialSingleSupplier(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + tester := newTester(false) + f := tester.fetcher + defer f.Stop() + defer f.wm.witnessCache.Stop() + blocks := make(map[common.Hash]*types.Block) + parent := genesis + for i := 1; i <= 3; i++ { + tx := types.NewTx(&types.LegacyTx{Nonce: uint64(i), Data: make([]byte, 1024*1024)}) + block := types.NewBlock(&types.Header{Number: big.NewInt(int64(i)), ParentHash: parent.Hash()}, &types.Body{Transactions: []*types.Transaction{tx}}, nil, trie.NewStackTrie(nil)) + blocks[block.Hash()] = block + parent = block + } + requests := make(chan partialBodyRequest, 4) + imported := make(chan common.Hash, 3) + f.importedHook = func(_ *types.Header, b *types.Block) { imported <- b.Hash() } + fetch := limitedBodyFetcher(t, blocks, requests) + for hash, block := range blocks { + require.NoError(t, f.Notify("only-peer", hash, block.NumberU64(), time.Now(), tester.makeHeaderFetcher("only-peer", blocks, 0), fetch, nil)) + } + for range 3 { + select { + case <-imported: + case <-time.After(3 * time.Second): + t.Fatal("partial response stranded a block without another announcement") + } + } + first, retry := <-requests, <-requests + require.Equal(t, 3, first.count) + require.Equal(t, 1, retry.count) + require.GreaterOrEqual(t, retry.at.Sub(first.at), fetchRetryInterval) + require.Empty(t, requests) + resume := pauseBlockTimeoutLoop(t, f) + defer resume() + require.Empty(t, f.fetched) + require.Empty(t, f.announced) + require.Empty(t, f.announces) + require.Zero(t, f.fetchSlotsTotal) + }) +} + +func limitedBodyFetcher(t *testing.T, blocks map[common.Hash]*types.Block, requests chan<- partialBodyRequest) bodyRequesterFn { + t.Helper() + return func(hashes []common.Hash, sink chan *ethproto.Response) (*ethproto.Request, error) { + requests <- partialBodyRequest{count: len(hashes), at: time.Now()} + var bodies ethproto.BlockBodiesResponse + size := 0 + for _, hash := range hashes { + // Match ServiceGetBlockBodiesQuery's soft response limit. + if size >= 2*1024*1024 { + break + } + block := blocks[hash] + body := ðproto.BlockBody{Transactions: block.Transactions(), Uncles: block.Uncles()} + encoded, err := rlp.EncodeToBytes(body) + require.NoError(t, err) + size += len(encoded) + bodies = append(bodies, body) + } + req := ðproto.Request{Cancel: make(chan struct{})} + go func() { sink <- ðproto.Response{Req: req, Res: &bodies, Done: make(chan error, 1)} }() + return req, nil + } +} + +func TestBlockFetchPartialReleaseOwnership(t *testing.T) { + for _, mode := range []string{"retry", "empty", "headers", "new owner", "queued"} { + t.Run(mode, func(t *testing.T) { + f := newBlockReleaseFetcher(t) + a := addBlockReleaseRequest(f, mode == "headers", 1, "peer") + release := blockFetchRelease{announces: []*blockAnnounce{a}, retryBodies: mode != "empty", headers: mode == "headers"} + if mode == "new owner" { + copy := *a + f.completing[a.hash] = © + } + if mode == "queued" { + f.queued[a.hash] = &blockOrHeaderInject{} + } + require.Equal(t, mode == "retry", f.releaseFetchOwnership(release)) + if mode != "retry" { + require.Empty(t, f.fetched) + return + } + retry := f.fetched[a.hash][0] + require.NotSame(t, a, retry) + require.True(t, retry.bodyDeferred) + require.Equal(t, a.origin, retry.origin) + delete(f.fetched, a.hash) + f.completing[a.hash] = retry + require.False(t, f.releaseFetchOwnership(release)) + require.Same(t, retry, f.completing[a.hash]) + }) + } +} diff --git a/eth/fetcher/block_fetcher_release.go b/eth/fetcher/block_fetcher_release.go index 912dc23a77..a14c1f972c 100644 --- a/eth/fetcher/block_fetcher_release.go +++ b/eth/fetcher/block_fetcher_release.go @@ -22,8 +22,9 @@ import "github.com/ethereum/go-ethereum/common" // block reaching a later stage, covering timeouts as well as dispatch failures // and responses that did not carry the requested data. type blockFetchRelease struct { - headers bool - announces []*blockAnnounce + retryBodies bool + headers bool + announces []*blockAnnounce } // fetchAnnouncements snapshots the announcements a scheduled request owns, so @@ -43,8 +44,12 @@ func (f *BlockFetcher) notifyFetchRelease(headers bool, announces []*blockAnnoun if len(announces) == 0 { return } + f.notifyRequestRelease(blockFetchRelease{headers: headers, announces: announces}) +} + +func (f *BlockFetcher) notifyRequestRelease(release blockFetchRelease) { select { - case f.fetchRelease <- blockFetchRelease{headers: headers, announces: announces}: + case f.fetchRelease <- release: case <-f.quit: } } @@ -52,10 +57,11 @@ func (f *BlockFetcher) notifyFetchRelease(headers bool, announces []*blockAnnoun // releaseFetchOwnership runs on the fetcher loop so a finished request cannot // erase a newer attempt for the same hash, nor a block that has already // advanced to the import queue or to witness retrieval. -func (f *BlockFetcher) releaseFetchOwnership(release blockFetchRelease) { +func (f *BlockFetcher) releaseFetchOwnership(release blockFetchRelease) bool { f.mu.Lock() defer f.mu.Unlock() + requeued := false pending := f.completing if release.headers { pending = f.fetching @@ -70,5 +76,13 @@ func (f *BlockFetcher) releaseFetchOwnership(release blockFetchRelease) { // Scheduling already released this announcement's count; the counts // still held belong to other announcements from the same peer. delete(pending, announce.hash) + if release.retryBodies && !release.headers { + // A fresh token prevents an old release from clearing the retry. + retry := *announce + retry.bodyDeferred = true + f.fetched[announce.hash] = []*blockAnnounce{&retry} + requeued = true + } } + return requeued } diff --git a/eth/fetcher/block_fetcher_request.go b/eth/fetcher/block_fetcher_request.go index 41272bb04a..66b785cd70 100644 --- a/eth/fetcher/block_fetcher_request.go +++ b/eth/fetcher/block_fetcher_request.go @@ -153,6 +153,8 @@ func (f *BlockFetcher) deliverBodies(peer string, req bodyRequest, res *eth.Resp // Withdrawals are ignored here, since the block fetcher is not used post-merge. txs, uncles, _ := bodies.Unpack() metrics.RecordPerItemDuration(blockBodyItemDownloadTimer, res.Time, len(txs)) - f.FilterBodies(peer, txs, uncles, time.Now(), req.announcedAt) - f.notifyFetchRelease(false, req.announces) + remaining, _ := f.FilterBodies(peer, txs, uncles, time.Now(), req.announcedAt) + // Retry omissions only when the response made progress; empty or unmatched + // responses must not create a perpetual retry loop. + f.notifyRequestRelease(blockFetchRelease{announces: req.announces, retryBodies: len(remaining) < len(txs)}) } From eaeae9c206d9bc8db1af391655528069f5b84f55 Mon Sep 17 00:00:00 2001 From: Vikram Bhattacharjee Date: Fri, 23 Oct 2026 19:43:08 +0530 Subject: [PATCH 12/15] core/txpool, eth/relay: retain retries after refused queue offers --- .../legacypool/rebroadcast_attempt_test.go | 28 +++++++++++++++++++ .../legacypool/rebroadcast_boundaries_test.go | 1 + .../legacypool/rebroadcast_fairness_test.go | 13 +++------ .../legacypool/rebroadcast_scheduling_test.go | 3 -- .../legacypool/rebroadcast_selection.go | 6 ++-- eth/relay/CLAUDE.md | 11 ++++++-- 6 files changed, 44 insertions(+), 18 deletions(-) diff --git a/core/txpool/legacypool/rebroadcast_attempt_test.go b/core/txpool/legacypool/rebroadcast_attempt_test.go index 29cef8873f..8c02df6771 100644 --- a/core/txpool/legacypool/rebroadcast_attempt_test.go +++ b/core/txpool/legacypool/rebroadcast_attempt_test.go @@ -65,3 +65,31 @@ func TestRebroadcastSuppressedOldBatch(t *testing.T) { defer pool.mu.RUnlock() require.Empty(t, pool.lastRebroadcastAttempt) } + +func TestRebroadcastOldRefusedOffersRetryUntilAccepted(t *testing.T) { + pool, _, from, tx := setupRebroadcastTest(t, time.Minute, 10*time.Minute, 200) + defer pool.Close() + pool.mu.Lock() + setTxAge(pool, from, 31*time.Minute) + pool.mu.Unlock() + for range 3 { + pool.mu.RLock() + batch := pool.identifyStuckTransactions() + pool.mu.RUnlock() + require.Equal(t, []*types.Transaction{tx}, batch) + pool.RebroadcastAttempt(batch)([]common.Hash{tx.Hash()}) + pool.RebroadcastAcknowledgement(batch)(nil) + pool.mu.RLock() + require.Empty(t, pool.identifyStuckTransactions(), "refusal must respect the retry interval") + require.Empty(t, pool.lastRebroadcast) + pool.mu.RUnlock() + advanceRebroadcastTime(pool, from, 2*time.Minute) + } + pool.RebroadcastAcknowledgement([]*types.Transaction{tx})([]common.Hash{tx.Hash()}) + advanceRebroadcastTime(pool, from, 2*time.Minute) + pool.mu.RLock() + defer pool.mu.RUnlock() + require.NotZero(t, pool.lastRebroadcast[tx.Hash()]) + require.Empty(t, pool.identifyStuckTransactions(), "accepted old transactions must obey the maximum age") + require.Same(t, tx, pool.all.Get(tx.Hash())) +} diff --git a/core/txpool/legacypool/rebroadcast_boundaries_test.go b/core/txpool/legacypool/rebroadcast_boundaries_test.go index 8abd9c6970..434adca617 100644 --- a/core/txpool/legacypool/rebroadcast_boundaries_test.go +++ b/core/txpool/legacypool/rebroadcast_boundaries_test.go @@ -53,6 +53,7 @@ func TestRebroadcastEligibilityBoundaries(t *testing.T) { if tc.sinceAttempt > 0 { last = now.Add(-tc.sinceAttempt) } + pool.lastRebroadcast = map[common.Hash]time.Time{tx.Hash(): last} if got := pool.rebroadcastEligible(tx, last, now, big.NewInt(tc.baseFee), big.NewInt(tc.minTip)); got != tc.want { t.Fatalf("eligible=%t, want %t", got, tc.want) } diff --git a/core/txpool/legacypool/rebroadcast_fairness_test.go b/core/txpool/legacypool/rebroadcast_fairness_test.go index e2ced264bf..f96b542895 100644 --- a/core/txpool/legacypool/rebroadcast_fairness_test.go +++ b/core/txpool/legacypool/rebroadcast_fairness_test.go @@ -72,7 +72,7 @@ func testRebroadcastRetriesAcrossNewBatches(t *testing.T, partial bool) { t.Error("new arrivals prevented every retry of the eligible lowest nonce") } advanceRebroadcastTime(pool, from, interval+interval/2) - assertRebroadcastRetryExpiry(t, pool, from, head) + assertRebroadcastUnacceptedRetry(t, pool, from, head) } func addRebroadcastArrivals(t *testing.T, pool *LegacyPool, key *ecdsa.PrivateKey, first uint64, count int, age time.Duration) { @@ -138,12 +138,12 @@ func publishRebroadcastFairnessBatch(t *testing.T, pool *LegacyPool, ch <-chan c pool.RebroadcastAcknowledgement(delivered.Txs)(accepted) } -func assertRebroadcastRetryExpiry(t *testing.T, pool *LegacyPool, from common.Address, head *types.Transaction) { +func assertRebroadcastUnacceptedRetry(t *testing.T, pool *LegacyPool, from common.Address, head *types.Transaction) { t.Helper() pool.mu.RLock() defer pool.mu.RUnlock() - if pool.rebroadcastEligible(head, pool.rebroadcastLastActivity(head.Hash()), time.Now(), pool.strandedBaseFee(), pool.gasTip.Load().ToBig()) { - t.Error("retried transaction remained eligible beyond maximum age") + if !pool.rebroadcastEligible(head, pool.rebroadcastLastActivity(head.Hash()), time.Now(), pool.strandedBaseFee(), pool.gasTip.Load().ToBig()) { + t.Error("unaccepted transaction lost eligibility beyond maximum age") } if pool.all.Get(head.Hash()) != head || !pool.pending[from].Contains(head.Nonce()) { t.Error("rebroadcast expiry removed the transaction from pending") @@ -151,9 +151,4 @@ func assertRebroadcastRetryExpiry(t *testing.T, pool *LegacyPool, from common.Ad if !pool.lastRebroadcast[head.Hash()].IsZero() { t.Error("unaccepted lowest nonce was recorded as queue-accepted") } - for _, tx := range pool.identifyStuckTransactions() { - if tx == head { - t.Error("expired transaction was selected again") - } - } } diff --git a/core/txpool/legacypool/rebroadcast_scheduling_test.go b/core/txpool/legacypool/rebroadcast_scheduling_test.go index f51aeac695..4fb027763b 100644 --- a/core/txpool/legacypool/rebroadcast_scheduling_test.go +++ b/core/txpool/legacypool/rebroadcast_scheduling_test.go @@ -57,9 +57,6 @@ func TestRebroadcastAttemptWithoutQueueAcceptance(t *testing.T) { } pool.lastRebroadcastAttempt[tx.Hash()] = time.Now().Add(-2 * time.Hour) want := 1 - if age > pool.config.RebroadcastMaxAge { - want = 0 - } if got := len(pool.identifyStuckTransactions()); got != want { t.Fatalf("after cooldown: got %d candidates, want %d", got, want) } diff --git a/core/txpool/legacypool/rebroadcast_selection.go b/core/txpool/legacypool/rebroadcast_selection.go index b1ff0ae321..d0106b333e 100644 --- a/core/txpool/legacypool/rebroadcast_selection.go +++ b/core/txpool/legacypool/rebroadcast_selection.go @@ -61,9 +61,9 @@ func (pool *LegacyPool) rebroadcastEligible(tx *types.Transaction, last, now tim if now.Sub(last) < pool.config.RebroadcastInterval { return false } - // Allow one first attempt after a late fee drop. Further attempts obey - // the age limit even when no peer has accepted the transaction. - if pool.config.RebroadcastMaxAge > 0 && age > pool.config.RebroadcastMaxAge { + // Refused queue offers delay retries but do not consume the late + // propagation opportunity after a fee drop or private-marker expiry. + if !pool.lastRebroadcast[tx.Hash()].IsZero() && pool.config.RebroadcastMaxAge > 0 && age > pool.config.RebroadcastMaxAge { return false } } diff --git a/eth/relay/CLAUDE.md b/eth/relay/CLAUDE.md index e514997741..f4e37edff4 100644 --- a/eth/relay/CLAUDE.md +++ b/eth/relay/CLAUDE.md @@ -79,9 +79,14 @@ eth/relay/ **Failure recovery:** - A relay error can occur after local pool admission; the RPC error reports that - distinction. Exhausted producer retries do not authorize public gossip. -- Privacy markers stay while the pool retains the transaction. Expiring a marker - alone would publish the original private payload through ordinary propagation. + distinction. Exhausting producer retries does not immediately publish the + transaction; the private window still applies. +- Committed privacy markers expire 30 minutes after the last successful private + admission, checked every minute, even while the transaction remains in the pool. + Expiry makes the original payload eligible for ordinary public propagation; + private submission does not promise indefinite confidentiality. An in-flight + admission keeps its reservation until completion, and stale sweeps cannot + remove a refreshed marker. See [private transaction lifetime](../../docs/private-transactions.md). - The sender can sign a replacement at the same nonce with a sufficient fee bump. Submit it through the private endpoint to retain privacy, or the public endpoint to explicitly publish that replacement. This also permits a cancellation From 82e7cbac664082f2e295fb9d757d3a30e5aeab6c Mon Sep 17 00:00:00 2001 From: Vikram Bhattacharjee Date: Mon, 26 Oct 2026 13:40:20 +0530 Subject: [PATCH 13/15] core/txpool, eth: narrow PR to rebroadcast and downloader timeouts --- docs/private-transactions.md | 20 -- eth/api_backend.go | 11 - eth/api_backend_private.go | 50 --- eth/api_backend_private_lifecycle_test.go | 156 --------- eth/api_backend_private_replacement_test.go | 91 ----- eth/api_backend_private_test.go | 102 ------ eth/api_backend_public_privacy_test.go | 116 ------- eth/fetcher/block_fetcher.go | 323 ++++++++++++++---- eth/fetcher/block_fetcher_admission.go | 148 -------- eth/fetcher/block_fetcher_admission_test.go | 176 ---------- eth/fetcher/block_fetcher_alternates.go | 75 ---- eth/fetcher/block_fetcher_alternates_test.go | 166 --------- eth/fetcher/block_fetcher_backoff.go | 38 --- eth/fetcher/block_fetcher_backoff_test.go | 139 -------- .../block_fetcher_body_request_test.go | 192 ----------- eth/fetcher/block_fetcher_body_retry.go | 71 ---- eth/fetcher/block_fetcher_body_retry_test.go | 125 ------- eth/fetcher/block_fetcher_bounds_test.go | 315 ----------------- eth/fetcher/block_fetcher_discard_test.go | 65 ---- eth/fetcher/block_fetcher_partial_test.go | 132 ------- eth/fetcher/block_fetcher_pending.go | 44 --- eth/fetcher/block_fetcher_pending_test.go | 319 ----------------- eth/fetcher/block_fetcher_release.go | 88 ----- eth/fetcher/block_fetcher_release_test.go | 321 ----------------- eth/fetcher/block_fetcher_request.go | 160 --------- eth/fetcher/block_fetcher_schedule.go | 252 -------------- .../block_fetcher_schedule_edges_test.go | 94 ----- eth/fetcher/block_fetcher_schedule_test.go | 248 -------------- .../block_fetcher_timeout_recovery_test.go | 276 --------------- eth/fetcher/block_fetcher_timer.go | 59 ---- eth/fetcher/block_fetcher_timer_test.go | 112 ------ eth/filters/IBackend.go | 14 - eth/handler.go | 17 +- eth/relay/CLAUDE.md | 17 - eth/relay/private_tx_errors.go | 21 -- eth/relay/private_tx_store.go | 41 ++- eth/relay/private_tx_store_submission.go | 49 --- eth/relay/private_tx_store_submission_test.go | 198 ----------- eth/relay/private_tx_store_test.go | 189 +++------- eth/relay/relay.go | 22 +- eth/relay/relay_private_submission_test.go | 77 ----- eth/sync.go | 18 +- eth/sync_completion.go | 88 ----- eth/sync_completion_retry_test.go | 224 ------------ eth/sync_completion_test.go | 267 --------------- internal/ethapi/api.go | 30 +- internal/ethapi/api_private.go | 55 --- internal/ethapi/api_private_test.go | 242 ------------- internal/ethapi/api_test.go | 42 +-- internal/ethapi/backend.go | 1 - internal/ethapi/transaction_args_test.go | 1 - 51 files changed, 379 insertions(+), 5718 deletions(-) delete mode 100644 docs/private-transactions.md delete mode 100644 eth/api_backend_private.go delete mode 100644 eth/api_backend_private_lifecycle_test.go delete mode 100644 eth/api_backend_private_replacement_test.go delete mode 100644 eth/api_backend_private_test.go delete mode 100644 eth/api_backend_public_privacy_test.go delete mode 100644 eth/fetcher/block_fetcher_admission.go delete mode 100644 eth/fetcher/block_fetcher_admission_test.go delete mode 100644 eth/fetcher/block_fetcher_alternates.go delete mode 100644 eth/fetcher/block_fetcher_alternates_test.go delete mode 100644 eth/fetcher/block_fetcher_backoff.go delete mode 100644 eth/fetcher/block_fetcher_backoff_test.go delete mode 100644 eth/fetcher/block_fetcher_body_request_test.go delete mode 100644 eth/fetcher/block_fetcher_body_retry.go delete mode 100644 eth/fetcher/block_fetcher_body_retry_test.go delete mode 100644 eth/fetcher/block_fetcher_bounds_test.go delete mode 100644 eth/fetcher/block_fetcher_discard_test.go delete mode 100644 eth/fetcher/block_fetcher_partial_test.go delete mode 100644 eth/fetcher/block_fetcher_pending.go delete mode 100644 eth/fetcher/block_fetcher_pending_test.go delete mode 100644 eth/fetcher/block_fetcher_release.go delete mode 100644 eth/fetcher/block_fetcher_release_test.go delete mode 100644 eth/fetcher/block_fetcher_request.go delete mode 100644 eth/fetcher/block_fetcher_schedule.go delete mode 100644 eth/fetcher/block_fetcher_schedule_edges_test.go delete mode 100644 eth/fetcher/block_fetcher_schedule_test.go delete mode 100644 eth/fetcher/block_fetcher_timeout_recovery_test.go delete mode 100644 eth/fetcher/block_fetcher_timer.go delete mode 100644 eth/fetcher/block_fetcher_timer_test.go delete mode 100644 eth/relay/private_tx_errors.go delete mode 100644 eth/relay/private_tx_store_submission.go delete mode 100644 eth/relay/private_tx_store_submission_test.go delete mode 100644 eth/relay/relay_private_submission_test.go delete mode 100644 eth/sync_completion.go delete mode 100644 eth/sync_completion_retry_test.go delete mode 100644 eth/sync_completion_test.go delete mode 100644 internal/ethapi/api_private.go delete mode 100644 internal/ethapi/api_private_test.go diff --git a/docs/private-transactions.md b/docs/private-transactions.md deleted file mode 100644 index c770e3b39f..0000000000 --- a/docs/private-transactions.md +++ /dev/null @@ -1,20 +0,0 @@ -# Private transaction lifetime - -A privately admitted transaction is excluded from public peer announcements and -rebroadcasts for up to 30 minutes after its last successful private admission. -The store checks expiry every minute, so public eligibility can begin up to one -minute later. An in-flight private admission keeps its reservation until the -call completes. - -After expiry, a transaction still in the pool becomes eligible for ordinary -public propagation. This fallback allows progress when all block producers -reject the transaction or relay retries are exhausted. Expiry does not remove -the transaction from the pool or shorten the pool's own retention policy. -Private submission therefore does not guarantee indefinite confidentiality. - -Before expiry, resubmitting the same transaction through a public endpoint -returns `already known` if it is still in the pool, without adding it to the -public retry journal. If it has left the pool but still has a private marker, -public resubmission returns a private submission conflict. Markers for absent -transactions are swept after a two-minute grace period when pool presence can -be checked; the 30-minute expiry applies even without a pool checker. diff --git a/eth/api_backend.go b/eth/api_backend.go index 6352fd4dbd..29609f3b70 100644 --- a/eth/api_backend.go +++ b/eth/api_backend.go @@ -408,20 +408,13 @@ func (b *EthAPIBackend) SubscribeLogsEvent(ch chan<- []*types.Log) event.Subscri } func (b *EthAPIBackend) SendTx(ctx context.Context, signedTx *types.Transaction) error { - if err := b.privateTxSubmissionError(signedTx.Hash()); err != nil { - return err - } err := b.eth.txPool.Add([]*types.Transaction{signedTx}, false)[0] - if err := b.privateTxSubmissionError(signedTx.Hash()); err != nil { - return err - } // If the local transaction tracker is not configured, returns whatever // returned from the txpool. if b.eth.localTxTracker == nil { return err } - // If the transaction fails with an error indicating it is invalid, or if there is // very little chance it will be accepted later (e.g., the gas price is below the // configured minimum, or the sender has insufficient funds to cover the cost), @@ -894,10 +887,6 @@ func (b *EthAPIBackend) RecordPrivateTx(hash common.Hash) { b.relay.RecordPrivateTx(hash) } -func (b *EthAPIBackend) BeginPrivateTx(hash common.Hash) func(bool) { - return b.relay.BeginPrivateTx(hash) -} - func (b *EthAPIBackend) PurgePrivateTx(hash common.Hash) { b.relay.PurgePrivateTx(hash) } diff --git a/eth/api_backend_private.go b/eth/api_backend_private.go deleted file mode 100644 index 4066e38756..0000000000 --- a/eth/api_backend_private.go +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright 2026 The go-ethereum Authors -// This file is part of the go-ethereum library. -// -// The go-ethereum library is free software: you can redistribute it and/or modify -// it under the terms of the GNU Lesser General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// The go-ethereum library is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Lesser General Public License for more details. -// -// You should have received a copy of the GNU Lesser General Public License -// along with the go-ethereum library. If not, see . - -package eth - -import ( - "context" - - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/core/txpool" - "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/eth/relay" -) - -func (b *EthAPIBackend) isPrivateTx(hash common.Hash) bool { - if b.relay == nil { - return false - } - getter := b.relay.GetPrivateTxGetter() - return getter != nil && getter.IsTxPrivate(hash) -} - -// SendPrivateTx reports actual pool admission and never enters the public journal. -func (b *EthAPIBackend) SendPrivateTx(ctx context.Context, tx *types.Transaction) error { - return b.eth.txPool.Add([]*types.Transaction{tx}, false)[0] -} - -// A duplicate does not re-admit or publicly journal a private transaction. -func (b *EthAPIBackend) privateTxSubmissionError(hash common.Hash) error { - if !b.isPrivateTx(hash) { - return nil - } - if b.eth.txPool.Has(hash) { - return txpool.ErrAlreadyKnown - } - return relay.ErrPrivateTxConflict -} diff --git a/eth/api_backend_private_lifecycle_test.go b/eth/api_backend_private_lifecycle_test.go deleted file mode 100644 index 815d6382fe..0000000000 --- a/eth/api_backend_private_lifecycle_test.go +++ /dev/null @@ -1,156 +0,0 @@ -// Copyright 2026 The go-ethereum Authors -// This file is part of the go-ethereum library. -// -// The go-ethereum library is free software: you can redistribute it and/or modify -// it under the terms of the GNU Lesser General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// The go-ethereum library is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Lesser General Public License for more details. -// -// You should have received a copy of the GNU Lesser General Public License -// along with the go-ethereum library. If not, see . - -package eth - -import ( - "errors" - "slices" - "testing" - - "github.com/holiman/uint256" - - "github.com/ethereum/go-ethereum/core/txpool" - "github.com/ethereum/go-ethereum/internal/ethapi" - "github.com/ethereum/go-ethereum/params" -) - -func TestSendTxPrivatePublicDuplicate(t *testing.T) { - b := privateSubmissionBackend(t, false) - tx := makeTx(0, nil, nil, key) - if err := b.SendTx(t.Context(), tx); err != nil { - t.Fatal(err) - } - if err := b.eth.txPool.Sync(); err != nil { - t.Fatal(err) - } - initial := &handler{txpool: b.eth.txPool, privateTxGetter: b.relay.GetPrivateTxGetter()} - if !slices.Contains(initial.pendingTransactionHashes(), tx.Hash()) { - t.Fatal("fixture transaction was not publicly announceable before private call") - } - raw, err := tx.MarshalBinary() - if err != nil { - t.Fatal(err) - } - api := ethapi.NewTransactionAPI(b, new(ethapi.AddrLocker)) - _, err = api.SendRawTransactionPrivate(t.Context(), raw) - if !errors.Is(err, txpool.ErrAlreadyKnown) { - t.Fatalf("private resubmit error = %v", err) - } - if b.relay.GetPrivateTxGetter().IsTxPrivate(tx.Hash()) { - t.Error("failed private resubmit marked an already-public transaction private") - } - h := &handler{txpool: b.eth.txPool, privateTxGetter: b.relay.GetPrivateTxGetter()} - if !slices.Contains(h.pendingTransactionHashes(), tx.Hash()) { - t.Error("already-public transaction disappeared from production pending announcements") - } -} - -func TestSendTxPrivateRejectedThenPublic(t *testing.T) { - b := privateSubmissionBackend(t, true) - first := pricedSetCodeTx(0, 250000, uint256.NewInt(25*params.GWei), uint256.NewInt(25*params.GWei), key, []unsignedAuth{{nonce: 0, key: key}}) - if err := b.SendTx(t.Context(), first); err != nil { - t.Fatal(err) - } - if err := b.eth.txPool.Sync(); err != nil { - t.Fatal(err) - } - tx := makeTx(1, nil, nil, key) - raw, err := tx.MarshalBinary() - if err != nil { - t.Fatal(err) - } - api := ethapi.NewTransactionAPI(b, new(ethapi.AddrLocker)) - _, err = api.SendRawTransactionPrivate(t.Context(), raw) - if !errors.Is(err, txpool.ErrInflightTxLimitReached) { - t.Fatalf("private submission error = %v", err) - } - if b.eth.txPool.Has(tx.Hash()) { - t.Fatal("rejected transaction entered pool") - } - if b.relay.GetPrivateTxGetter().IsTxPrivate(tx.Hash()) { - t.Error("rejected transaction left a private marker") - } - b.eth.txPool.Clear() - if err := b.SendTx(t.Context(), makeTx(0, nil, nil, key)); err != nil { - t.Fatal(err) - } - if err := b.SendTx(t.Context(), tx); err != nil { - t.Fatal(err) - } - if err := b.eth.txPool.Sync(); err != nil { - t.Fatal(err) - } - if !b.eth.txPool.Has(tx.Hash()) { - t.Fatal("later public submission was not admitted") - } - h := &handler{txpool: b.eth.txPool, privateTxGetter: b.relay.GetPrivateTxGetter()} - if !slices.Contains(h.pendingTransactionHashes(), tx.Hash()) { - t.Error("later public submission was suppressed by failed private marker") - } -} - -func TestSendTxPrivateRejectedMarkers(t *testing.T) { - b := privateSubmissionBackend(t, true) - first := pricedSetCodeTx(0, 250000, uint256.NewInt(25*params.GWei), uint256.NewInt(25*params.GWei), key, []unsignedAuth{{nonce: 0, key: key}}) - if err := b.SendTx(t.Context(), first); err != nil { - t.Fatal(err) - } - api := ethapi.NewTransactionAPI(b, new(ethapi.AddrLocker)) - retained := 0 - for nonce := uint64(1); nonce <= 32; nonce++ { - tx := makeTx(nonce, nil, nil, key) - raw, err := tx.MarshalBinary() - if err != nil { - t.Fatal(err) - } - _, err = api.SendRawTransactionPrivate(t.Context(), raw) - if err == nil { - t.Fatalf("submission %d unexpectedly admitted", nonce) - } - if b.eth.txPool.Has(tx.Hash()) { - t.Fatal("rejected transaction entered pool") - } - if b.relay.GetPrivateTxGetter().IsTxPrivate(tx.Hash()) { - retained++ - } - } - if retained != 0 { - t.Fatalf("%d of 32 failed submissions retained markers", retained) - } -} - -func TestSendTxPrivateExistingCommitment(t *testing.T) { - b := privateSubmissionBackend(t, false) - tx := makeTx(0, nil, nil, key) - raw, err := tx.MarshalBinary() - if err != nil { - t.Fatal(err) - } - api := ethapi.NewTransactionAPI(b, new(ethapi.AddrLocker)) - if _, err = api.SendRawTransactionPrivate(t.Context(), raw); err != nil { - t.Fatal(err) - } - if !b.relay.GetPrivateTxGetter().IsTxPrivate(tx.Hash()) { - t.Fatal("successful private submission did not commit its marker") - } - if _, err = api.SendRawTransactionPrivate(t.Context(), raw); !errors.Is(err, txpool.ErrAlreadyKnown) { - t.Fatalf("repeat private error = %v", err) - } - if !b.relay.GetPrivateTxGetter().IsTxPrivate(tx.Hash()) { - t.Fatal("already-known private retry removed its previous commitment") - } -} diff --git a/eth/api_backend_private_replacement_test.go b/eth/api_backend_private_replacement_test.go deleted file mode 100644 index 99aacaa257..0000000000 --- a/eth/api_backend_private_replacement_test.go +++ /dev/null @@ -1,91 +0,0 @@ -// Copyright 2026 The go-ethereum Authors -// This file is part of the go-ethereum library. -// -// The go-ethereum library is free software: you can redistribute it and/or modify -// it under the terms of the GNU Lesser General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// The go-ethereum library is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Lesser General Public License for more details. -// -// You should have received a copy of the GNU Lesser General Public License -// along with the go-ethereum library. If not, see . - -package eth - -import ( - "errors" - "math/big" - "testing" - - "github.com/stretchr/testify/require" - - "github.com/ethereum/go-ethereum/core/txpool" - "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/internal/ethapi" - "github.com/ethereum/go-ethereum/params" -) - -type privateRelayFailureBackend struct { - *EthAPIBackend - relayCalls int -} - -func (b *privateRelayFailureBackend) PrivateTxEnabled() bool { return true } - -func (b *privateRelayFailureBackend) SubmitPrivateTx(*types.Transaction) error { - b.relayCalls++ - return errors.New("producer rejected transaction") -} - -func TestSendTxPrivateRelayFailureReplacement(t *testing.T) { - for _, private := range []bool{false, true} { - t.Run(map[bool]string{false: "public", true: "private"}[private], func(t *testing.T) { - b := &privateRelayFailureBackend{EthAPIBackend: privateSubmissionBackend(t, false)} - api := ethapi.NewTransactionAPI(b, new(ethapi.AddrLocker)) - original := makeTx(0, nil, nil, key) - raw, err := original.MarshalBinary() - require.NoError(t, err) - hash, err := api.SendRawTransactionPrivate(t.Context(), raw) - require.ErrorContains(t, err, "accepted locally, submission failed") - require.Equal(t, original.Hash(), hash) - require.NoError(t, b.eth.txPool.Sync()) - require.Equal(t, txpool.TxStatusPending, b.TxStatus(hash)) - require.True(t, b.relay.GetPrivateTxGetter().IsTxPrivate(hash)) - h := &handler{txpool: b.eth.txPool, privateTxGetter: b.relay.GetPrivateTxGetter()} - require.NotContains(t, h.pendingTransactionHashes(), hash) - - next := makeTx(1, nil, nil, key) - require.NoError(t, b.SendTx(t.Context(), next)) - replacement := makeTx(0, big.NewInt(50*params.GWei), big.NewInt(0), key) - raw, err = replacement.MarshalBinary() - require.NoError(t, err) - if private { - hash, err = api.SendRawTransactionPrivate(t.Context(), raw) - require.ErrorContains(t, err, "accepted locally, submission failed") - require.Equal(t, 2, b.relayCalls) - } else { - hash, err = api.SendRawTransaction(t.Context(), raw) - require.NoError(t, err) - require.Equal(t, 1, b.relayCalls) - } - require.Equal(t, replacement.Hash(), hash) - require.NoError(t, b.eth.txPool.Sync()) - require.False(t, b.eth.txPool.Has(original.Hash())) - require.Equal(t, txpool.TxStatusPending, b.TxStatus(replacement.Hash())) - require.Equal(t, txpool.TxStatusPending, b.TxStatus(next.Hash())) - require.Equal(t, private, b.relay.GetPrivateTxGetter().IsTxPrivate(replacement.Hash())) - announcements := h.pendingTransactionHashes() - require.NotContains(t, announcements, original.Hash()) - require.Contains(t, announcements, next.Hash()) - if private { - require.NotContains(t, announcements, replacement.Hash()) - } else { - require.Contains(t, announcements, replacement.Hash()) - } - }) - } -} diff --git a/eth/api_backend_private_test.go b/eth/api_backend_private_test.go deleted file mode 100644 index 390fab68d8..0000000000 --- a/eth/api_backend_private_test.go +++ /dev/null @@ -1,102 +0,0 @@ -// Copyright 2026 The go-ethereum Authors -// This file is part of the go-ethereum library. -// -// The go-ethereum library is free software: you can redistribute it and/or modify -// it under the terms of the GNU Lesser General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// The go-ethereum library is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Lesser General Public License for more details. -// -// You should have received a copy of the GNU Lesser General Public License -// along with the go-ethereum library. If not, see . - -package eth - -import ( - "io" - "os" - "path/filepath" - "testing" - "time" - - "github.com/holiman/uint256" - "github.com/stretchr/testify/require" - - "github.com/ethereum/go-ethereum/core/txpool" - "github.com/ethereum/go-ethereum/core/txpool/locals" - "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/eth/relay" - "github.com/ethereum/go-ethereum/params" - "github.com/ethereum/go-ethereum/rlp" -) - -func privateSubmissionBackend(t *testing.T, withLocal bool) *EthAPIBackend { - t.Helper() - b := initBackend(withLocal) - b.relay = relay.Init(false, false, false, true, nil) - b.relay.SetTxPoolChecker(b.eth.txPool.Has) - t.Cleanup(func() { - b.relay.Close() - require.NoError(t, b.eth.txPool.Close()) - b.eth.blockchain.Stop() - }) - return b -} - -func TestSendTxPrivateAdmission(t *testing.T) { - for _, private := range []bool{false, true} { - t.Run(map[bool]string{false: "public", true: "private"}[private], func(t *testing.T) { - b := privateSubmissionBackend(t, true) - first := pricedSetCodeTx(0, 250000, uint256.NewInt(25*params.GWei), uint256.NewInt(25*params.GWei), key, []unsignedAuth{{nonce: 0, key: key}}) - require.NoError(t, b.SendTx(t.Context(), first)) - require.NoError(t, b.eth.txPool.Sync()) - tx := makeTx(1, nil, nil, key) - if private { - b.RecordPrivateTx(tx.Hash()) - } - send := b.SendTx - if private { - send = b.SendPrivateTx - } - err := send(t.Context(), tx) - if private { - require.ErrorIs(t, err, txpool.ErrInflightTxLimitReached) - } else { - require.NoError(t, err) - } - require.False(t, b.eth.txPool.Has(tx.Hash())) - }) - } -} - -func TestSendTxPrivateJournal(t *testing.T) { - b := privateSubmissionBackend(t, false) - path := filepath.Join(t.TempDir(), "transactions.rlp") - tracker := locals.New(path, time.Minute, gspec.Config, b.eth.txPool) - b.eth.localTxTracker = tracker - require.NoError(t, tracker.Start()) - t.Cleanup(func() { require.NoError(t, tracker.Stop()) }) - require.Eventually(t, func() bool { - _, err := os.Stat(path) - return err == nil - }, time.Second, time.Millisecond) - private, public := makeTx(0, nil, nil, key), makeTx(1, nil, nil, key) - b.RecordPrivateTx(private.Hash()) - require.NoError(t, b.SendPrivateTx(t.Context(), private)) - require.ErrorIs(t, b.SendTx(t.Context(), private), txpool.ErrAlreadyKnown) - require.NoError(t, b.SendTx(t.Context(), public)) - require.True(t, b.eth.txPool.Has(private.Hash())) - require.True(t, b.eth.txPool.Has(public.Hash())) - file, err := os.Open(path) - require.NoError(t, err) - t.Cleanup(func() { require.NoError(t, file.Close()) }) - stream := rlp.NewStream(file, 0) - var saved types.Transaction - require.NoError(t, stream.Decode(&saved)) - require.Equal(t, public.Hash(), saved.Hash(), "only public transactions belong in the ordinary retry journal") - require.ErrorIs(t, stream.Decode(&saved), io.EOF) -} diff --git a/eth/api_backend_public_privacy_test.go b/eth/api_backend_public_privacy_test.go deleted file mode 100644 index a6009f3f11..0000000000 --- a/eth/api_backend_public_privacy_test.go +++ /dev/null @@ -1,116 +0,0 @@ -// Copyright 2026 The go-ethereum Authors -// This file is part of the go-ethereum library. -// -// The go-ethereum library is free software: you can redistribute it and/or modify -// it under the terms of the GNU Lesser General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// The go-ethereum library is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Lesser General Public License for more details. -// -// You should have received a copy of the GNU Lesser General Public License -// along with the go-ethereum library. If not, see . - -package eth - -import ( - "math/big" - "testing" - - "github.com/stretchr/testify/require" - - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/core/txpool" - "github.com/ethereum/go-ethereum/core/txpool/legacypool" - "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/eth/relay" - "github.com/ethereum/go-ethereum/internal/ethapi" -) - -func TestSendTxPrivateEvictedPublicResubmission(t *testing.T) { - for _, tracker := range []bool{false, true} { - t.Run(map[bool]string{false: "no tracker", true: "tracker"}[tracker], func(t *testing.T) { - b := privateSubmissionBackend(t, tracker) - tx := makeTx(0, nil, nil, key) - raw, err := tx.MarshalBinary() - require.NoError(t, err) - api := ethapi.NewTransactionAPI(b, new(ethapi.AddrLocker)) - _, err = api.SendRawTransactionPrivate(t.Context(), raw) - require.NoError(t, err) - require.NoError(t, b.eth.txPool.Sync()) - b.eth.txPool.SetGasTip(new(big.Int).Add(tx.GasTipCap(), big.NewInt(1))) - require.False(t, b.eth.txPool.Has(tx.Hash())) - require.True(t, b.isPrivateTx(tx.Hash())) - b.eth.txPool.SetGasTip(big.NewInt(0)) - hash, err := api.SendRawTransaction(t.Context(), raw) - require.ErrorIs(t, err, relay.ErrPrivateTxConflict) - require.Equal(t, common.Hash{}, hash) - require.False(t, b.eth.txPool.Has(tx.Hash())) - require.True(t, b.isPrivateTx(tx.Hash())) - }) - } -} -func TestSendTxPrivatePendingPublicConflict(t *testing.T) { - b := privateSubmissionBackend(t, true) - tx := makeTx(0, nil, nil, key) - finish := b.BeginPrivateTx(tx.Hash()) - require.ErrorIs(t, b.SendTx(t.Context(), tx), relay.ErrPrivateTxConflict) - require.False(t, b.eth.txPool.Has(tx.Hash())) - finish(false) - require.NoError(t, b.SendTx(t.Context(), tx)) - require.False(t, b.isPrivateTx(tx.Hash())) -} - -func TestSendTxPrivateResidentPublicDuplicate(t *testing.T) { - for _, tracker := range []bool{false, true} { - t.Run(map[bool]string{false: "no tracker", true: "tracker"}[tracker], func(t *testing.T) { - b := privateSubmissionBackend(t, tracker) - tx := makeTx(0, nil, nil, key) - raw, err := tx.MarshalBinary() - require.NoError(t, err) - api := ethapi.NewTransactionAPI(b, new(ethapi.AddrLocker)) - _, err = api.SendRawTransactionPrivate(t.Context(), raw) - require.NoError(t, err) - require.NoError(t, b.eth.txPool.Sync()) - _, err = api.SendRawTransaction(t.Context(), raw) - require.ErrorIs(t, err, txpool.ErrAlreadyKnown) - require.True(t, b.eth.txPool.Has(tx.Hash())) - require.True(t, b.isPrivateTx(tx.Hash())) - h := &handler{txpool: b.eth.txPool, privateTxGetter: b.relay.GetPrivateTxGetter()} - require.NotContains(t, h.pendingTransactionHashes(), tx.Hash()) - }) - } -} - -type privateAdmissionSubPool struct { - txpool.SubPool - onAdd func() -} - -func (p *privateAdmissionSubPool) Add(txs []*types.Transaction, sync bool) []error { - errors := p.SubPool.Add(txs, sync) - p.onAdd() - return errors -} - -func TestSendTxPrivateAdmissionDuringPublicCall(t *testing.T) { - b := privateSubmissionBackend(t, false) - require.NoError(t, b.eth.txPool.Close()) - tx := makeTx(0, nil, nil, key) - config := legacypool.DefaultConfig - config.Journal = "" - subpool := &privateAdmissionSubPool{ - SubPool: legacypool.New(config, b.eth.blockchain), - onAdd: func() { b.RecordPrivateTx(tx.Hash()) }, - } - pool, err := txpool.New(config.PriceLimit, b.eth.blockchain, []txpool.SubPool{subpool}) - require.NoError(t, err) - b.eth.txPool = pool - b.relay.SetTxPoolChecker(pool.Has) - require.ErrorIs(t, b.SendTx(t.Context(), tx), txpool.ErrAlreadyKnown) - require.True(t, pool.Has(tx.Hash())) - require.True(t, b.isPrivateTx(tx.Hash())) -} diff --git a/eth/fetcher/block_fetcher.go b/eth/fetcher/block_fetcher.go index e348c915d9..9a2d919f35 100644 --- a/eth/fetcher/block_fetcher.go +++ b/eth/fetcher/block_fetcher.go @@ -19,11 +19,11 @@ package fetcher import ( "errors" + "math/rand" "sync" "time" "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/common/lru" "github.com/ethereum/go-ethereum/common/prque" "github.com/ethereum/go-ethereum/consensus" "github.com/ethereum/go-ethereum/core/stateless" @@ -39,10 +39,6 @@ const ( arriveTimeout = 500 * time.Millisecond // Time allowance before an announced block/transaction is explicitly requested gatherSlack = 100 * time.Millisecond // Interval used to collate almost-expired announces with fetches fetchTimeout = 5 * time.Second // Maximum allotted time to return an explicitly requested block/transaction - - // fetchRetryInterval is how long announcements that admission could not - // place wait before being reconsidered. - fetchRetryInterval = 20 * time.Millisecond ) const ( @@ -125,14 +121,12 @@ type peerDropFn func(id string) // blockAnnounce is the hash notification of the availability of a new block in the // network. type blockAnnounce struct { - hash common.Hash // Hash of the block being announced - number uint64 // Number of the block being announced (0 = unknown | old protocol) - header *types.Header // Header of the block partially reassembled (new protocol) - time time.Time // Timestamp of the announcement - deferred bool // The gather window elapsed but a request could not start + hash common.Hash // Hash of the block being announced + number uint64 // Number of the block being announced (0 = unknown | old protocol) + header *types.Header // Header of the block partially reassembled (new protocol) + time time.Time // Timestamp of the announcement - bodyDeferred bool - origin string // Identifier of the peer originating the notification + origin string // Identifier of the peer originating the notification fetchHeader headerRequesterFn // Fetcher function to retrieve the header of an announced block fetchBodies bodyRequesterFn // Fetcher function to retrieve the body of an announced block @@ -231,7 +225,6 @@ type BlockFetcher struct { bodyFilter chan chan *bodyFilterTask done chan common.Hash - fetchRelease chan blockFetchRelease witnessRetry chan *blockOrHeaderInject // Import failed with a size-oracle-accepted witness: forget, then re-fetch the witness quit chan struct{} @@ -245,18 +238,6 @@ type BlockFetcher struct { fetched map[common.Hash][]*blockAnnounce // Blocks with headers fetched, scheduled for body retrieval completing map[common.Hash]*blockAnnounce // Blocks with headers, currently body-completing - // Outstanding request accounting, bounding workers that are blocked in - // dispatch before their response deadline exists. - fetchSlots map[string]int - fetchSlotsTotal int - fetchBackoff lru.BasicLRU[string, time.Time] // guarded by mu - fetchTimerAt time.Time - fetchRetryAt time.Time - fetchRetry time.Duration - completeTimerAt time.Time - completeRetryAt time.Time - completeRetry time.Duration - // Block cache queue *prque.Prque[int64, *blockOrHeaderInject] // Queue containing the import operations (block number sorted) queues map[string]int // Per peer block counts to prevent memory exhaustion @@ -298,7 +279,6 @@ func NewBlockFetcher(light bool, getHeader HeaderRetrievalFn, getBlock blockRetr headerFilter: make(chan chan *headerFilterTask), bodyFilter: make(chan chan *bodyFilterTask), done: make(chan common.Hash), - fetchRelease: make(chan blockFetchRelease), witnessRetry: make(chan *blockOrHeaderInject), quit: make(chan struct{}), announces: make(map[string]int), @@ -306,8 +286,6 @@ func NewBlockFetcher(light bool, getHeader HeaderRetrievalFn, getBlock blockRetr fetching: make(map[common.Hash]*blockAnnounce), fetched: make(map[common.Hash][]*blockAnnounce), completing: make(map[common.Hash]*blockAnnounce), - fetchSlots: make(map[string]int), - fetchBackoff: lru.NewBasicLRU[string, time.Time](maxFetchBackoffPeers), queue: prque.New[int64, *blockOrHeaderInject](nil), queues: make(map[string]int), queued: make(map[common.Hash]*blockOrHeaderInject), @@ -540,12 +518,17 @@ func (f *BlockFetcher) loop() { for { // Clean up any expired block fetches - expiredHashes := f.expiredFetches() + f.mu.RLock() + expiredHashes := make([]common.Hash, 0) + for hash, announce := range f.fetching { + if time.Since(announce.time) > fetchTimeout { + expiredHashes = append(expiredHashes, hash) + } + } + f.mu.RUnlock() for _, hash := range expiredHashes { - f.mu.Lock() - delete(f.fetching, hash) - f.mu.Unlock() + f.forgetHash(hash) } // Import any queued blocks that could potentially fit height := f.chainHeight() @@ -583,7 +566,6 @@ func (f *BlockFetcher) loop() { // Also check if witness manager is handling it if (number+maxUncleDist < height) || (f.light && f.getHeader(hash) != nil) || (!f.light && f.getBlock(hash) != nil) { // If known, forget about it - f.forgetHash(hash) f.forgetBlock(hash) continue } @@ -615,10 +597,9 @@ func (f *BlockFetcher) loop() { // A block was announced, make sure the peer isn't DOSing us blockAnnounceInMeter.Mark(1) - count, ok := f.admitAnnouncement(notification.origin) - if !ok { - log.Debug("Declined outstanding announce", "peer", notification.origin, - "limit", hashLimit, "origins", len(f.announces), "originLimit", maxPendingAnnounceOrigins) + count := f.announces[notification.origin] + 1 + if count > hashLimit { + log.Debug("Peer exceeded outstanding announces", "peer", notification.origin, "limit", hashLimit) blockAnnounceDOSMeter.Mark(1) break } @@ -633,8 +614,11 @@ func (f *BlockFetcher) loop() { blockAnnounceDropMeter.Mark(1) break } - // Keep distinct alternatives while another peer serves the block. - if f.hasAnnouncer(notification.hash, notification.origin) { + // All is well, schedule the announce if block's not yet downloading or completing or pending witness + if _, ok := f.fetching[notification.hash]; ok { + break + } + if _, ok := f.completing[notification.hash]; ok { break } if f.wm.isPending(notification.hash) { @@ -648,7 +632,9 @@ func (f *BlockFetcher) loop() { f.announceChangeHook(notification.hash, true) } - f.rearmFreshFetch(fetchTimer, notification) + if len(f.announced) == 1 { + f.rescheduleFetch(fetchTimer) + } case op := <-f.inject: // A direct block insertion was requested @@ -675,10 +661,6 @@ func (f *BlockFetcher) loop() { // A pending import finished, remove all traces of the notification f.forgetHash(hash) // This calls wm.forget f.forgetBlock(hash) // This calls wm.forget - case release := <-f.fetchRelease: - if f.releaseFetchOwnership(release) { - f.rescheduleComplete(completeTimer, true) - } case op := <-f.witnessRetry: // An import failed with a witness accepted on the WIT2 size oracle @@ -692,16 +674,176 @@ func (f *BlockFetcher) loop() { case <-fetchTimer.C: // At least one block's timer ran out, check for needing retrieval - deferred := f.scheduleHeaderFetches() + request := make(map[string][]common.Hash) + + for hash, announces := range f.announced { + // In current LES protocol(les2/les3), only header announce is + // available, no need to wait too much time for header broadcast. + timeout := arriveTimeout - gatherSlack + if f.light { + timeout = 0 + } + + if time.Since(announces[0].time) > timeout { + // Determine if we should proceed to fetch the header for this hash. + shouldScheduleFetch := !f.wm.isPending(hash) && // Not handled by witness manager + f.queued[hash] == nil && // Not already in the import queue + f.fetched[hash] == nil && // Header not already fetched + f.completing[hash] == nil && // Body not already being completed + ((f.light && f.getHeader(hash) == nil) || (!f.light && f.getBlock(hash) == nil)) // Not in DB + + if shouldScheduleFetch { + announceToUse := announces[rand.Intn(len(announces))] + f.forgetHash(hash) // This also removes from f.announced + + request[announceToUse.origin] = append(request[announceToUse.origin], hash) + f.fetching[hash] = announceToUse + } else { + for _, annItem := range announces { // 'announces' is f.announced[hash] + f.announces[annItem.origin]-- + if f.announces[annItem.origin] <= 0 { + delete(f.announces, annItem.origin) + } + } + delete(f.announced, hash) + if f.announceChangeHook != nil { + f.announceChangeHook(hash, false) + } + } + } + } + // Send out all block header requests + for peer, hashes := range request { + log.Debug("Fetching scheduled headers", "peer", peer, "list", hashes) + if len(hashes) == 0 { + continue + } + + // Create a closure of the fetch and schedule in on a new thread + announceCopy := *f.fetching[hashes[0]] // Copy announce data for goroutine + go func(p string, hs []common.Hash, ann blockAnnounce) { + if f.fetchingHook != nil { + f.fetchingHook(hs) + } + fetchHeader := ann.fetchHeader // Use copied data + announcedAt := ann.time + + for _, h := range hs { + headerFetchMeter.Mark(1) + go func(h common.Hash) { + resCh := make(chan *eth.Response) + if fetchHeader == nil { + return + } // Check before using + req, err := fetchHeader(h, resCh) + if err != nil { + log.Debug("Failed to request header", "peer", p, "hash", h, "err", err) + return + } + defer req.Close() + + timeout := time.NewTimer(2 * fetchTimeout) // 2x leeway before dropping the peer + defer timeout.Stop() + + select { + case res := <-resCh: + if res == nil { + return + } // Channel closed + res.Done <- nil + headersResponse, ok := res.Res.(*eth.BlockHeadersRequest) + if !ok || headersResponse == nil { + return + } // Invalid response type + headers := *headersResponse + metrics.RecordPerItemDuration(blockHeaderItemDownloadTimer, res.Time, len(headers)) + f.FilterHeaders(p, headers, time.Now(), announcedAt) + + case <-timeout.C: + log.Debug("BlockFetcher: header fetch timed out, dropping peer", "peer", p, "hash", h, "timeout", 2*fetchTimeout) + f.dropPeer(p) + case <-f.quit: + return // Fetcher stopped + } + }(h) + } + }(peer, hashes, announceCopy) + } // Schedule the next fetch if blocks are still pending - f.rescheduleFetch(fetchTimer, deferred) + f.rescheduleFetch(fetchTimer) case <-completeTimer.C: // At least one header's timer ran out, retrieve everything - deferred := f.scheduleBodyFetches() + request := make(map[string][]common.Hash) + + for hash, announces := range f.fetched { + // Pick a random peer to retrieve from, reset all others + announce := announces[rand.Intn(len(announces))] + + f.forgetHash(hash) // Removes from f.fetched + + // If the block still didn't arrive, queue for completion + // Also check if witness manager is already handling it + if !f.wm.isPending(hash) && f.getBlock(hash) == nil { + request[announce.origin] = append(request[announce.origin], hash) + f.completing[hash] = announce + } + } + // Send out all block body requests + for peer, hashes := range request { + log.Debug("Fetching scheduled bodies", "peer", peer, "list", hashes) + if len(hashes) == 0 { + continue + } + + announceCopy := *f.completing[hashes[0]] // Copy announce data for goroutine + go func(p string, hs []common.Hash, ann blockAnnounce) { + if f.completingHook != nil { + f.completingHook(hs) + } + fetchBodies := ann.fetchBodies // Use copied data + bodyFetchMeter.Mark(int64(len(hs))) + announcedAt := ann.time + + resCh := make(chan *eth.Response) + if fetchBodies == nil { + return + } // Check before using + req, err := fetchBodies(hs, resCh) + if err != nil { + log.Debug("Failed to request bodies", "peer", p, "hashes", hs, "err", err) + return + } + defer req.Close() + + timeout := time.NewTimer(2 * fetchTimeout) // 2x leeway before dropping the peer + defer timeout.Stop() + + select { + case res := <-resCh: + if res == nil { + return + } // Channel closed + res.Done <- nil + bodyResponse, ok := res.Res.(*eth.BlockBodiesResponse) + if !ok || bodyResponse == nil { + return + } // Invalid response type + // Ignoring withdrawals here, since the block fetcher is not used post-merge. + txs, uncles, _ := bodyResponse.Unpack() + metrics.RecordPerItemDuration(blockBodyItemDownloadTimer, res.Time, len(txs)) + f.FilterBodies(p, txs, uncles, time.Now(), announcedAt) + + case <-timeout.C: + log.Debug("BlockFetcher: body fetch timed out, dropping peer", "peer", p, "hashes", hs, "timeout", 2*fetchTimeout) + f.dropPeer(p) + case <-f.quit: + return // Fetcher stopped + } + }(peer, hashes, announceCopy) + } // Schedule the next fetch if blocks are still pending - f.rescheduleComplete(completeTimer, deferred) - f.rescheduleFetch(fetchTimer, f.scheduleHeaderFetches()) + f.rescheduleComplete(completeTimer) case filter := <-f.headerFilter: // Headers arrived from a remote peer. Extract those that were explicitly @@ -797,7 +939,9 @@ func (f *BlockFetcher) loop() { // Move announce from fetching to fetched state f.fetched[hash] = append(f.fetched[hash], announce) delete(f.fetching, hash) - f.rearmFreshCompletion(completeTimer, announce) + if len(f.fetched) == 1 { + f.rescheduleComplete(completeTimer) + } } // Schedule the header for light fetcher import for _, announce := range lightHeaders { @@ -923,28 +1067,43 @@ func (f *BlockFetcher) loop() { } // rescheduleFetch resets the specified fetch timer to the next blockAnnounce timeout. -func (f *BlockFetcher) rescheduleFetch(fetch *time.Timer, deferred bool) { - f.fetchRetryAt = time.Now().Add(nextFetchRetry(0, deferred, &f.fetchRetry)) - f.resetFetchTimer(fetch) -} - -// retryAfter floors a timer delay for a pass that left announcements behind -// because admission was full. Without the floor the timer would re-fire at once -// on an announcement that is already past due and spin until capacity frees. -// Passes that placed everything keep their natural delay, including an -// immediate one. -func retryAfter(delay time.Duration, deferred bool) time.Duration { - if deferred && delay < fetchRetryInterval { - return fetchRetryInterval +func (f *BlockFetcher) rescheduleFetch(fetch *time.Timer) { + // Short circuit if no blocks are announced + if len(f.announced) == 0 { + return + } + // Schedule announcement retrieval quickly for light mode + // since server won't send any headers to client. + if f.light { + fetch.Reset(lightTimeout) + return + } + // Otherwise find the earliest expiring announcement + earliest := time.Now() + for _, announces := range f.announced { + if earliest.After(announces[0].time) { + earliest = announces[0].time + } } - return delay + fetch.Reset(arriveTimeout - time.Since(earliest)) } // rescheduleComplete resets the specified completion timer to the next fetch timeout. -func (f *BlockFetcher) rescheduleComplete(complete *time.Timer, deferred bool) { - f.completeRetryAt = time.Now().Add(nextFetchRetry(0, deferred, &f.completeRetry)) - f.resetCompletionTimer(complete) +func (f *BlockFetcher) rescheduleComplete(complete *time.Timer) { + // Short circuit if no headers are fetched + if len(f.fetched) == 0 { + return + } + // Otherwise find the earliest expiring announcement + earliest := time.Now() + for _, announces := range f.fetched { + if earliest.After(announces[0].time) { + earliest = announces[0].time + } + } + + complete.Reset(gatherSlack - time.Since(earliest)) } // enqueue schedules a new header or block import operation, if the component @@ -1204,12 +1363,32 @@ func (f *BlockFetcher) forgetHash(hash common.Hash) { f.announceChangeHook(hash, false) } } - // Scheduling already released the credit for anything past the announced - // stage, so these are metadata removals only. Debiting again would free - // quota a peer never gave back and let its pending records grow. - delete(f.fetching, hash) + // Remove any pending fetches and decrement the DOS counters + if announce := f.fetching[hash]; announce != nil { + f.announces[announce.origin]-- + if f.announces[announce.origin] <= 0 { + delete(f.announces, announce.origin) + } + delete(f.fetching, hash) + } + + // Remove any pending completion requests and decrement the DOS counters + for _, announce := range f.fetched[hash] { + f.announces[announce.origin]-- + if f.announces[announce.origin] <= 0 { + delete(f.announces, announce.origin) + } + } delete(f.fetched, hash) - delete(f.completing, hash) + + // Remove any pending completions and decrement the DOS counters + if announce := f.completing[hash]; announce != nil { + f.announces[announce.origin]-- + if f.announces[announce.origin] <= 0 { + delete(f.announces, announce.origin) + } + delete(f.completing, hash) + } // Notify witness manager to forget any pending state f.wm.forget(hash) diff --git a/eth/fetcher/block_fetcher_admission.go b/eth/fetcher/block_fetcher_admission.go deleted file mode 100644 index af277a34c1..0000000000 --- a/eth/fetcher/block_fetcher_admission.go +++ /dev/null @@ -1,148 +0,0 @@ -// Copyright 2026 The go-ethereum Authors -// This file is part of the go-ethereum library. -// -// The go-ethereum library is free software: you can redistribute it and/or modify -// it under the terms of the GNU Lesser General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// The go-ethereum library is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Lesser General Public License for more details. -// -// You should have received a copy of the GNU Lesser General Public License -// along with the go-ethereum library. If not, see . - -package fetcher - -import ( - "math/rand" - "time" -) - -// Request dispatch is synchronous and completes only once the message has been -// written to the peer, which happens before the worker creates its response -// timer. A peer that stops draining its connection therefore holds workers for -// longer than the response deadline suggests, so outstanding requests are -// bounded on admission rather than relying on that deadline. -const ( - // A peer legitimately needs one outstanding request per block it announced - // but has not served yet. At a 1.5s block time and a 5s fetch deadline that - // is under four, so 16 absorbs catch-up bursts while still bounding a peer - // that never drains. - maxPeerFetchRequests = 16 - - // Peers reconnecting under fresh identities each get their own per-peer - // budget while already dispatched workers from earlier identities may still - // be waiting, so an aggregate ceiling covers that overlap. 256 is five times - // the default maxpeers at one outstanding request each, which keeps honest - // peers clear of the ceiling in steady state. - maxTotalFetchRequests = 256 -) - -// acquireFetchSlot reserves capacity for one outstanding request to peer. It -// never blocks and never queues, so a refused caller simply leaves its -// announcements for another round or another announcer. Called from the loop, -// before the worker is spawned. -func (f *BlockFetcher) acquireFetchSlot(peer string) bool { - return f.acquireFetchSlotWithin(peer, maxPeerFetchRequests) -} - -// acquireFetchSlotWithin is acquireFetchSlot against a lowered per-peer limit, -// used to hold capacity back for a peer's pending body request. -func (f *BlockFetcher) acquireFetchSlotWithin(peer string, limit int) bool { - f.mu.Lock() - defer f.mu.Unlock() - - if until, ok := f.fetchBackoff.Peek(peer); ok { - if time.Now().Before(until) { - return false - } - f.fetchBackoff.Remove(peer) - } - if f.fetchSlotsTotal >= maxTotalFetchRequests { - return false - } - if f.fetchSlots[peer] >= limit { - return false - } - f.fetchSlots[peer]++ - f.fetchSlotsTotal++ - - return true -} - -// releaseFetchSlot returns capacity taken by acquireFetchSlot. Workers defer it -// immediately so every exit releases exactly once. The per-peer entry -// disappears with its last outstanding request, while workers that are still -// running keep theirs counted across a same-id reconnect. -func (f *BlockFetcher) releaseFetchSlot(peer string) { - f.mu.Lock() - defer f.mu.Unlock() - - count, ok := f.fetchSlots[peer] - if !ok { - return - } - if count <= 1 { - delete(f.fetchSlots, peer) - } else { - f.fetchSlots[peer] = count - 1 - } - f.fetchSlotsTotal-- -} - -// acquireAnnouncer reserves a slot for one announcer of a hash, starting from a -// random offset so a single peer does not end up owning every announced hash. -// An announcer whose already fetched headers still need a body keeps one slot -// in reserve for that grouped body request, so header refills cannot starve it -// while headers and bodies still pipeline normally below the limit. -// -// Headers waiting for a body are also capped in aggregate. The per-peer reserve -// alone lets a fresh identity keep taking capacity that earlier identities' -// bodies then cannot get, so admission stops once that backlog reaches the -// node-wide ceiling. -// -// Returns nil when no announcer qualifies, leaving the alternatives in place -// instead of discarding them along with the unavailable peer. -func (f *BlockFetcher) acquireAnnouncer(announces []*blockAnnounce, awaitingBodies map[string]bool) *blockAnnounce { - if len(announces) == 0 || len(f.fetched) >= maxTotalFetchRequests { - return nil - } - offset := rand.Intn(len(announces)) - for i := range announces { - candidate := announces[(offset+i)%len(announces)] - limit := maxPeerFetchRequests - if awaitingBodies[candidate.origin] { - limit-- - } - if f.acquireFetchSlotWithin(candidate.origin, limit) { - return candidate - } - } - - return nil -} - -// acquireBodyAnnouncer is acquireAnnouncer for body retrieval, where one -// request carries a batch of hashes and so costs a single slot. Hashes landing -// on a peer already admitted this round ride that peer's slot. -func (f *BlockFetcher) acquireBodyAnnouncer(announces []*blockAnnounce, admitted map[string]bool) *blockAnnounce { - if len(announces) == 0 { - return nil - } - offset := rand.Intn(len(announces)) - for i := range announces { - candidate := announces[(offset+i)%len(announces)] - if admitted[candidate.origin] { - return candidate - } - if f.acquireFetchSlot(candidate.origin) { - admitted[candidate.origin] = true - return candidate - } - } - - return nil -} diff --git a/eth/fetcher/block_fetcher_admission_test.go b/eth/fetcher/block_fetcher_admission_test.go deleted file mode 100644 index 07bb8c14e3..0000000000 --- a/eth/fetcher/block_fetcher_admission_test.go +++ /dev/null @@ -1,176 +0,0 @@ -// Copyright 2026 The go-ethereum Authors -// This file is part of the go-ethereum library. -// -// The go-ethereum library is free software: you can redistribute it and/or modify -// it under the terms of the GNU Lesser General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// The go-ethereum library is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Lesser General Public License for more details. -// -// You should have received a copy of the GNU Lesser General Public License -// along with the go-ethereum library. If not, see . - -package fetcher - -import ( - "testing" - "time" - - "github.com/ethereum/go-ethereum/common" -) - -func TestFetchSlotPerPeerBound(t *testing.T) { - f := newBlockReleaseFetcher(t) - for i := 0; i < maxPeerFetchRequests; i++ { - if !f.acquireFetchSlot("peer") { - t.Fatalf("peer refused at %d of %d", i, maxPeerFetchRequests) - } - } - if f.acquireFetchSlot("peer") { - t.Fatal("peer admitted past its bound") - } - if !f.acquireFetchSlot("other") { - t.Fatal("a full peer consumed another peer's capacity") - } - f.releaseFetchSlot("peer") - if !f.acquireFetchSlot("peer") { - t.Fatal("released capacity was not reusable") - } -} - -func TestFetchSlotNodeWideBound(t *testing.T) { - f := newBlockReleaseFetcher(t) - admitted := 0 - // Identity churn: each new peer id gets a fresh per-peer budget, so only - // the node-wide ceiling bounds the aggregate. - for i := 0; i < maxTotalFetchRequests+maxPeerFetchRequests; i++ { - if f.acquireFetchSlot(string(rune('a' + i%26))) { - admitted++ - } - } - if admitted != maxTotalFetchRequests { - t.Fatalf("admitted %d requests, want the node-wide ceiling %d", admitted, maxTotalFetchRequests) - } - if f.fetchSlotsTotal != maxTotalFetchRequests { - t.Fatalf("total accounting = %d, want %d", f.fetchSlotsTotal, maxTotalFetchRequests) - } -} - -func TestFetchSlotAccountingClearsWhenIdle(t *testing.T) { - f := newBlockReleaseFetcher(t) - for _, peer := range []string{"a", "a", "b"} { - if !f.acquireFetchSlot(peer) { - t.Fatalf("peer %q refused", peer) - } - } - for _, peer := range []string{"a", "a", "b"} { - f.releaseFetchSlot(peer) - } - if len(f.fetchSlots) != 0 || f.fetchSlotsTotal != 0 { - t.Fatalf("bookkeeping survived the last worker: %v total=%d", f.fetchSlots, f.fetchSlotsTotal) - } - // A release without a matching acquire must not corrupt the totals a - // reconnecting peer's live workers still rely on. - f.releaseFetchSlot("a") - if !f.acquireFetchSlot("a") || f.fetchSlotsTotal != 1 { - t.Fatalf("unmatched release corrupted accounting: total=%d", f.fetchSlotsTotal) - } -} - -func TestAcquireAnnouncerPrefersAvailablePeer(t *testing.T) { - f := newBlockReleaseFetcher(t) - for i := 0; i < maxPeerFetchRequests; i++ { - f.acquireFetchSlot("full") - } - announces := []*blockAnnounce{ - {hash: common.Hash{1}, origin: "full"}, - {hash: common.Hash{1}, origin: "healthy"}, - } - for i := 0; i < 8; i++ { - announce := f.acquireAnnouncer(announces, nil) - if announce == nil || announce.origin != "healthy" { - t.Fatalf("round %d chose %v, want the peer with capacity", i, announce) - } - f.releaseFetchSlot(announce.origin) - } -} - -func TestAcquireAnnouncerRefusesWhenAllFull(t *testing.T) { - f := newBlockReleaseFetcher(t) - for _, peer := range []string{"a", "b"} { - for i := 0; i < maxPeerFetchRequests; i++ { - f.acquireFetchSlot(peer) - } - } - announces := []*blockAnnounce{ - {hash: common.Hash{1}, origin: "a"}, - {hash: common.Hash{1}, origin: "b"}, - } - if announce := f.acquireAnnouncer(announces, nil); announce != nil { - t.Fatalf("admitted %q with no capacity", announce.origin) - } - if f.acquireAnnouncer(nil, nil) != nil { - t.Fatal("admitted an empty announcer set") - } -} - -func TestAcquireAnnouncerReservesSlotForWaitingBodies(t *testing.T) { - f := newBlockReleaseFetcher(t) - announces := []*blockAnnounce{{hash: common.Hash{1}, origin: "waiting"}} - waiting := map[string]bool{"waiting": true} - - // Headers still pipeline alongside a pending body, up to the reserve. - for i := 0; i < maxPeerFetchRequests-1; i++ { - if f.acquireAnnouncer(announces, waiting) == nil { - t.Fatalf("header %d refused below the reserved limit", i) - } - } - if f.acquireAnnouncer(announces, waiting) != nil { - t.Fatal("header request consumed the slot reserved for the waiting body") - } - if !f.acquireFetchSlot("waiting") { - t.Fatal("the reserved slot was not available to the body request") - } - if f.acquireAnnouncer(announces, nil) != nil { - t.Fatal("peer admitted past its outstanding-request bound") - } -} - -func TestAcquireBodyAnnouncerChargesOnePerBatch(t *testing.T) { - f := newBlockReleaseFetcher(t) - admitted := make(map[string]bool) - announces := []*blockAnnounce{{hash: common.Hash{1}, origin: "peer"}} - for i := 0; i < maxPeerFetchRequests+4; i++ { - if f.acquireBodyAnnouncer(announces, admitted) == nil { - t.Fatalf("hash %d refused although the batch already holds a slot", i) - } - } - if f.fetchSlots["peer"] != 1 || f.fetchSlotsTotal != 1 { - t.Fatalf("batch took %d slots, want 1", f.fetchSlots["peer"]) - } -} - -func TestRetryAfterFloorsOnlyDeferredPasses(t *testing.T) { - for _, tc := range []struct { - name string - delay time.Duration - deferred bool - want time.Duration - }{ - {"placed pass keeps an immediate retry", -time.Second, false, -time.Second}, - {"placed pass keeps its delay", time.Second, false, time.Second}, - {"deferred pass floors an expired delay", -time.Second, true, fetchRetryInterval}, - {"deferred pass floors a short delay", fetchRetryInterval - 1, true, fetchRetryInterval}, - {"deferred pass keeps a longer delay", time.Second, true, time.Second}, - } { - t.Run(tc.name, func(t *testing.T) { - if got := retryAfter(tc.delay, tc.deferred); got != tc.want { - t.Fatalf("retryAfter(%v, %t) = %v, want %v", tc.delay, tc.deferred, got, tc.want) - } - }) - } -} diff --git a/eth/fetcher/block_fetcher_alternates.go b/eth/fetcher/block_fetcher_alternates.go deleted file mode 100644 index 00c6ed9465..0000000000 --- a/eth/fetcher/block_fetcher_alternates.go +++ /dev/null @@ -1,75 +0,0 @@ -// Copyright 2026 The go-ethereum Authors -// This file is part of the go-ethereum library. -// -// The go-ethereum library is free software: you can redistribute it and/or modify -// it under the terms of the GNU Lesser General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// The go-ethereum library is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Lesser General Public License for more details. -// -// You should have received a copy of the GNU Lesser General Public License -// along with the go-ethereum library. If not, see . - -package fetcher - -import ( - "time" - - "github.com/ethereum/go-ethereum/common" -) - -// Alternatives retain their announcement credits until tried or forgotten. -// A timeout therefore cannot discard the only notification from a healthy peer. -func (f *BlockFetcher) consumeAnnouncement(hash common.Hash, origin string) { - announces := f.announced[hash] - remaining := announces[:0] - for _, announce := range announces { - if announce.origin != origin { - remaining = append(remaining, announce) - continue - } - f.announces[origin]-- - if f.announces[origin] <= 0 { - delete(f.announces, origin) - } - } - clear(announces[len(remaining):]) - if len(remaining) > 0 { - f.announced[hash] = remaining - return - } - delete(f.announced, hash) - if f.announceChangeHook != nil { - f.announceChangeHook(hash, false) - } -} - -func (f *BlockFetcher) hasAnnouncer(hash common.Hash, origin string) bool { - for _, active := range []*blockAnnounce{f.fetching[hash], f.completing[hash]} { - if active != nil && active.origin == origin { - return true - } - } - for _, announces := range [][]*blockAnnounce{f.announced[hash], f.fetched[hash]} { - for _, announce := range announces { - if announce.origin == origin { - return true - } - } - } - return false -} - -func nextFetchRetry(delay time.Duration, deferred bool, backoff *time.Duration) time.Duration { - if !deferred { - *backoff = 0 - return delay - } - previous := *backoff - *backoff = min(retryAfter(2*previous, true), time.Second) - return max(delay, *backoff) -} diff --git a/eth/fetcher/block_fetcher_alternates_test.go b/eth/fetcher/block_fetcher_alternates_test.go deleted file mode 100644 index 5db35f8e9a..0000000000 --- a/eth/fetcher/block_fetcher_alternates_test.go +++ /dev/null @@ -1,166 +0,0 @@ -// Copyright 2026 The go-ethereum Authors -// This file is part of the go-ethereum library. -// -// The go-ethereum library is free software: you can redistribute it and/or modify -// it under the terms of the GNU Lesser General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// The go-ethereum library is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Lesser General Public License for more details. -// -// You should have received a copy of the GNU Lesser General Public License -// along with the go-ethereum library. If not, see . - -package fetcher - -import ( - "testing" - "testing/synctest" - "time" - - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/core/types" -) - -func TestHeaderSchedulingSkipsSaturatedLookups(t *testing.T) { - p := newScheduleProbe(t) - f := p.fetcher - for range maxPeerFetchRequests { - if !f.acquireFetchSlot("busy") { - t.Fatal("could not fill slots") - } - } - for range hashLimit { - p.announce("busy", time.Second) - } - lookups := 0 - f.getBlock = func(common.Hash) *types.Block { lookups++; return nil } - for range 10 { - if !f.scheduleHeaderFetches() { - t.Fatal("saturated announcements were not deferred") - } - } - if lookups != 0 { - t.Fatalf("full slots caused %d chain lookups", lookups) - } - f.releaseFetchSlot("busy") - f.scheduleHeaderFetches() - if lookups != 1 { - t.Fatalf("one available slot caused %d lookups, want 1", lookups) - } -} - -func TestBlockFetchTimeoutUsesAlternative(t *testing.T) { - hashes, blocks := makeChain(1, 42, genesis) - block := blocks[hashes[0]] - for _, headers := range []bool{true, false} { - name := "body" - if headers { - name = "header" - } - t.Run(name, func(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - tester := newTester(false) - defer tester.fetcher.Stop() - defer tester.fetcher.wm.witnessCache.Stop() - imported := make(chan struct{}, 1) - tester.fetcher.importedHook = func(*types.Header, *types.Block) { imported <- struct{}{} } - request, _ := requestDelayedBlock(t, tester, blocks, block, headers) - if err := tester.fetcher.Notify("healthy", block.Hash(), block.NumberU64(), time.Now(), - tester.makeHeaderFetcher("healthy", blocks, 0), tester.makeBodyFetcher("healthy", blocks, 0), nil); err != nil { - t.Fatal(err) - } - time.Sleep(3 * fetchTimeout) - synctest.Wait() - select { - case <-imported: - default: - t.Fatal("the healthy peer's only announcement was lost on timeout") - } - select { - case <-request.Cancel: - default: - t.Fatal("timed-out request remained open") - } - assertBlockPeerRetained(t, tester) - }) - }) - } -} - -func TestHeaderSchedulingPreservesAlternativeCredits(t *testing.T) { - p := newScheduleProbe(t) - f := p.fetcher - hash := p.announce("slow", time.Second) - slow := f.announced[hash][0] - healthy := &blockAnnounce{hash: hash, origin: "healthy", time: time.Now().Add(-time.Second)} - f.announced[hash] = append(f.announced[hash], healthy) - f.announces["healthy"] = 1 - f.admitHeaderRequest(hash, slow) - if f.announces["slow"] != 0 || f.announces["healthy"] != 1 || len(f.announced[hash]) != 1 { - t.Fatal("selection consumed an alternative's announcement credit") - } - if !f.hasAnnouncer(hash, "slow") || !f.hasAnnouncer(hash, "healthy") || f.hasAnnouncer(hash, "other") { - t.Fatal("pending and active announcers were not deduplicated") - } - f.releaseFetchOwnership(blockFetchRelease{headers: true, announces: []*blockAnnounce{slow}}) - f.admitHeaderRequest(hash, healthy) - if len(f.announces) != 0 || len(f.announced) != 0 || f.fetching[hash] != healthy { - t.Fatal("retry did not consume exactly the alternative's credit") - } - f.releaseFetchOwnership(blockFetchRelease{headers: true, announces: []*blockAnnounce{slow}}) - if f.fetching[hash] != healthy { - t.Fatal("old request release erased the alternative") - } -} - -func TestFetchDeferredRetryBackoff(t *testing.T) { - var backoff time.Duration - for _, want := range []time.Duration{20 * time.Millisecond, 40 * time.Millisecond, 80 * time.Millisecond} { - if got := nextFetchRetry(-time.Second, true, &backoff); got != want { - t.Fatalf("retry delay = %v, want %v", got, want) - } - } - for range 10 { - nextFetchRetry(0, true, &backoff) - } - if backoff != time.Second { - t.Fatalf("retry ceiling = %v, want 1s", backoff) - } - if got := nextFetchRetry(2*time.Second, true, &backoff); got != 2*time.Second { - t.Fatal("retry preceded the announcement's gather window") - } - if nextFetchRetry(-time.Second, false, &backoff) != -time.Second || backoff != 0 { - t.Fatal("successful scheduling did not reset backoff") - } -} - -func TestHeaderSchedulingLightLookup(t *testing.T) { - for _, known := range []bool{false, true} { - t.Run(map[bool]string{false: "unknown", true: "known"}[known], func(t *testing.T) { - p := newScheduleProbe(t) - f := p.fetcher - f.light = true - lookups := 0 - f.getHeader = func(common.Hash) *types.Header { - lookups++ - if known { - return genesis.Header() - } - return nil - } - f.getBlock = func(common.Hash) *types.Block { t.Error("light fetch read a full block"); return nil } - hash := p.announce("peer", time.Millisecond) - f.scheduleHeaderFetches() - if lookups != 1 || (f.fetching[hash] != nil) == known { - t.Fatal("light scheduling did not use the header lookup without a gather delay") - } - if known && f.fetchSlotsTotal != 0 { - t.Fatal("known header retained a request slot") - } - }) - } -} diff --git a/eth/fetcher/block_fetcher_backoff.go b/eth/fetcher/block_fetcher_backoff.go deleted file mode 100644 index c4766926cf..0000000000 --- a/eth/fetcher/block_fetcher_backoff.go +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright 2026 The go-ethereum Authors -// This file is part of the go-ethereum library. -// -// The go-ethereum library is free software: you can redistribute it and/or modify -// it under the terms of the GNU Lesser General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// The go-ethereum library is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Lesser General Public License for more details. -// -// You should have received a copy of the GNU Lesser General Public License -// along with the go-ethereum library. If not, see . - -package fetcher - -import "time" - -const ( - fetchPeerBackoff = 10 * time.Second - // Keep cooldown metadata bounded across reconnects and peer identity churn. - maxFetchBackoffPeers = 2 * maxTotalFetchRequests -) - -// Concurrent requests can time out together. They share one cooldown instead -// of extending it each time another outstanding request expires. -func (f *BlockFetcher) backoffFetchPeer(peer string) { - f.mu.Lock() - defer f.mu.Unlock() - - now := time.Now() - if until, ok := f.fetchBackoff.Peek(peer); ok && now.Before(until) { - return - } - f.fetchBackoff.Add(peer, now.Add(fetchPeerBackoff)) -} diff --git a/eth/fetcher/block_fetcher_backoff_test.go b/eth/fetcher/block_fetcher_backoff_test.go deleted file mode 100644 index ba2e2c64bd..0000000000 --- a/eth/fetcher/block_fetcher_backoff_test.go +++ /dev/null @@ -1,139 +0,0 @@ -// Copyright 2026 The go-ethereum Authors -// This file is part of the go-ethereum library. -// -// The go-ethereum library is free software: you can redistribute it and/or modify -// it under the terms of the GNU Lesser General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// The go-ethereum library is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Lesser General Public License for more details. -// -// You should have received a copy of the GNU Lesser General Public License -// along with the go-ethereum library. If not, see . - -package fetcher - -import ( - "fmt" - "sync" - "testing" - "testing/synctest" - "time" - - "github.com/ethereum/go-ethereum/common" - "github.com/stretchr/testify/require" -) - -func assertBlockFetchCooldown(t *testing.T, f *BlockFetcher, imported <-chan struct{}) { - t.Helper() - time.Sleep(fetchPeerBackoff - time.Second) - synctest.Wait() - require.False(t, f.acquireFetchSlot("slow"), "timed-out peer acquired a new slot during cooldown") - select { - case <-imported: - t.Fatal("timed-out peer was retried before cooldown expired") - default: - } - time.Sleep(time.Second) -} - -func TestFetchPeerBackoffAdmission(t *testing.T) { - for _, bodies := range []bool{false, true} { - t.Run(fmt.Sprintf("bodies=%t", bodies), func(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - f := newBlockReleaseFetcher(t) - defer f.wm.witnessCache.Stop() - acquire := func(announces []*blockAnnounce) *blockAnnounce { - if bodies { - return f.acquireBodyAnnouncer(announces, make(map[string]bool)) - } - return f.acquireAnnouncer(announces, nil) - } - slow := &blockAnnounce{hash: common.Hash{1}, origin: "slow"} - healthy := &blockAnnounce{hash: slow.hash, origin: "healthy"} - require.True(t, f.acquireFetchSlot(slow.origin)) - f.backoffFetchPeer(slow.origin) - f.releaseFetchSlot(slow.origin) - require.Nil(t, acquire([]*blockAnnounce{slow})) - require.Same(t, healthy, acquire([]*blockAnnounce{slow, healthy})) - f.releaseFetchSlot(healthy.origin) - require.Zero(t, f.fetchSlotsTotal) - time.Sleep(fetchPeerBackoff - time.Nanosecond) - require.Nil(t, acquire([]*blockAnnounce{slow})) - time.Sleep(time.Nanosecond) - require.Same(t, slow, acquire([]*blockAnnounce{slow})) - f.releaseFetchSlot(slow.origin) - require.Zero(t, f.fetchBackoff.Len()) - }) - }) - } -} - -func TestFetchPeerBackoffDoesNotEscalate(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - f := newBlockReleaseFetcher(t) - defer f.wm.witnessCache.Stop() - for range 8 { - f.backoffFetchPeer("slow") - require.False(t, f.acquireFetchSlot("slow")) - time.Sleep(fetchPeerBackoff - time.Second) - f.backoffFetchPeer("slow") - require.False(t, f.acquireFetchSlot("slow")) - time.Sleep(time.Second) - require.True(t, f.acquireFetchSlot("slow"), "overlapping timeouts extended the cooldown") - f.releaseFetchSlot("slow") - } - }) -} - -func TestFetchPeerBackoffBound(t *testing.T) { - f := newBlockReleaseFetcher(t) - defer f.wm.witnessCache.Stop() - for i := 0; i <= maxFetchBackoffPeers; i++ { - f.backoffFetchPeer(fmt.Sprintf("peer-%d", i)) - } - require.Equal(t, maxFetchBackoffPeers, f.fetchBackoff.Len()) - require.False(t, f.acquireFetchSlot(fmt.Sprintf("peer-%d", maxFetchBackoffPeers))) - require.True(t, f.acquireFetchSlot("healthy")) - f.releaseFetchSlot("healthy") -} - -func TestFetchPeerBackoffConcurrentAdmission(t *testing.T) { - f := newBlockReleaseFetcher(t) - defer f.wm.witnessCache.Stop() - var wg sync.WaitGroup - for range 8 { - wg.Go(func() { - for range 100 { - f.backoffFetchPeer("slow") - if f.acquireFetchSlot("slow") { - t.Error("acquired a slot during cooldown") - f.releaseFetchSlot("slow") - } - if f.acquireFetchSlot("healthy") { - f.releaseFetchSlot("healthy") - } - } - }) - } - wg.Wait() - require.Zero(t, f.fetchSlotsTotal) - require.Empty(t, f.fetchSlots) -} - -func TestFetchPeerBackoffExpiredEntry(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - f := newBlockReleaseFetcher(t) - defer f.wm.witnessCache.Stop() - f.backoffFetchPeer("slow") - time.Sleep(fetchPeerBackoff) - f.backoffFetchPeer("slow") - require.False(t, f.acquireFetchSlot("slow")) - time.Sleep(fetchPeerBackoff) - require.True(t, f.acquireFetchSlot("slow")) - f.releaseFetchSlot("slow") - }) -} diff --git a/eth/fetcher/block_fetcher_body_request_test.go b/eth/fetcher/block_fetcher_body_request_test.go deleted file mode 100644 index 93cd8e09e6..0000000000 --- a/eth/fetcher/block_fetcher_body_request_test.go +++ /dev/null @@ -1,192 +0,0 @@ -// Copyright 2026 The go-ethereum Authors -// This file is part of the go-ethereum library. -// -// The go-ethereum library is free software: you can redistribute it and/or modify -// it under the terms of the GNU Lesser General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// The go-ethereum library is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Lesser General Public License for more details. -// -// You should have received a copy of the GNU Lesser General Public License -// along with the go-ethereum library. If not, see . - -package fetcher - -import ( - "errors" - "slices" - "testing" - "time" - - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/eth/protocols/eth" -) - -func TestFetchSlotBodyReleasedOnEveryExit(t *testing.T) { - for _, exit := range []string{"nil fetch", "dispatch error", "nil response", "wrong response type", "typed nil response", "shutdown"} { - t.Run(exit, func(t *testing.T) { - f := newBlockReleaseFetcher(t) - first := addBlockReleaseRequest(f, false, 1, "peer") - second := addBlockReleaseRequest(f, false, 2, "peer") - other := addBlockReleaseRequest(f, false, 3, "peer") - header := addBlockReleaseRequest(f, true, 1, "peer") - owned := []*blockAnnounce{first, second} - request := ð.Request{Cancel: make(chan struct{})} - response := bodyExitResponse(exit) - fetch := bodyExitFetcher(f, exit, request, response) - if exit == "shutdown" { - f.Stop() - } - done := startBodyRequestTest(t, f, bodyRequest{hashes: []common.Hash{first.hash, second.hash}, announces: owned, fetch: fetch}) - if exit != "shutdown" { - select { - case release := <-f.fetchRelease: - if release.headers || !slices.Equal(release.announces, owned) { - t.Fatalf("body worker returned the wrong ownership: %+v", release) - } - f.releaseFetchOwnership(release) - case <-time.After(time.Second): - t.Fatal("body worker did not return its batch ownership") - } - } - finishBodyRequestTest(t, f, done) - if exit != "shutdown" && (f.completing[first.hash] != nil || f.completing[second.hash] != nil) { - t.Fatal("completed body request retained its missing bodies") - } - if f.completing[other.hash] != other || f.fetching[header.hash] != header { - t.Fatal("body exit removed another request's ownership") - } - assertBodyRequestCompletion(t, exit, request, response) - }) - } -} - -func bodyExitResponse(exit string) *eth.Response { - if exit == "nil response" { - return nil - } - var payload interface{} = new(eth.BlockHeadersRequest) - if exit == "typed nil response" { - payload = (*eth.BlockBodiesResponse)(nil) - } - return ð.Response{Res: payload, Done: make(chan error, 1)} -} - -func bodyExitFetcher(f *BlockFetcher, exit string, request *eth.Request, response *eth.Response) bodyRequesterFn { - if exit == "nil fetch" { - return nil - } - return func(_ []common.Hash, chanRes chan *eth.Response) (*eth.Request, error) { - if exit == "dispatch error" { - return nil, errors.New("body dispatch failed") - } - if exit != "shutdown" { - go func() { - select { - case chanRes <- response: - case <-f.quit: - } - }() - } - return request, nil - } -} - -func startBodyRequestTest(t *testing.T, f *BlockFetcher, req bodyRequest) <-chan struct{} { - t.Helper() - if !f.acquireFetchSlot("peer") { - t.Fatal("could not reserve body request capacity") - } - done := make(chan struct{}) - go func() { - defer close(done) - f.runBodyRequest("peer", req) - }() - return done -} - -func finishBodyRequestTest(t *testing.T, f *BlockFetcher, done <-chan struct{}) { - t.Helper() - select { - case <-done: - case <-time.After(time.Second): - t.Fatal("body worker did not finish") - } - f.mu.RLock() - defer f.mu.RUnlock() - if len(f.fetchSlots) != 0 || f.fetchSlotsTotal != 0 { - t.Fatalf("body worker leaked capacity: peers=%v total=%d", f.fetchSlots, f.fetchSlotsTotal) - } -} - -func assertBodyRequestCompletion(t *testing.T, exit string, request *eth.Request, response *eth.Response) { - t.Helper() - if exit == "nil fetch" || exit == "dispatch error" { - return - } - select { - case <-request.Cancel: - default: - t.Error("body worker did not close its dispatched request") - } - if response != nil && exit != "shutdown" { - select { - case err := <-response.Done: - if err != nil { - t.Errorf("body response acknowledgement: %v", err) - } - default: - t.Error("body response was not acknowledged") - } - } -} - -func TestFetchSlotBodyResponseReleasesMissing(t *testing.T) { - hashes, blocks := makeChain(2, 91, genesis) - for _, partial := range []bool{false, true} { - name := "empty" - if partial { - name = "partial" - } - t.Run(name, func(t *testing.T) { - f := newBlockReleaseFetcher(t) - f.getBlock = func(common.Hash) *types.Block { return nil } - f.chainHeight = func() uint64 { return 0 } - var owned []*blockAnnounce - for _, hash := range hashes[:2] { - announce := &blockAnnounce{hash: hash, header: blocks[hash].Header(), origin: "peer", time: time.Now()} - f.completing[hash] = announce - owned = append(owned, announce) - } - var bodies eth.BlockBodiesResponse - if partial { - block := blocks[hashes[0]] - bodies = append(bodies, ð.BlockBody{Transactions: block.Transactions(), Uncles: block.Uncles()}) - } - request := ð.Request{Cancel: make(chan struct{})} - response := ð.Response{Res: &bodies, Done: make(chan error, 1)} - fetch := bodyExitFetcher(f, name, request, response) - f.Start() - done := startBodyRequestTest(t, f, bodyRequest{hashes: hashes[:2], announces: owned, fetch: fetch}) - finishBodyRequestTest(t, f, done) - assertBodyRequestCompletion(t, name, request, response) - resume := pauseBlockTimeoutLoop(t, f) - defer resume() - if f.completing[hashes[1]] != nil { - t.Error("missing body remained owned after the request ended") - } - if partial { - if f.queued[hashes[0]] == nil || f.completing[hashes[0]] != owned[0] { - t.Error("partial response lost its delivered body before import") - } - } else if len(f.completing) != 0 || len(f.queued) != 0 || len(f.fetched) != 0 { - t.Error("empty response retained body ownership or queued a block") - } - }) - } -} diff --git a/eth/fetcher/block_fetcher_body_retry.go b/eth/fetcher/block_fetcher_body_retry.go deleted file mode 100644 index e647551e04..0000000000 --- a/eth/fetcher/block_fetcher_body_retry.go +++ /dev/null @@ -1,71 +0,0 @@ -// Copyright 2026 The go-ethereum Authors -// This file is part of the go-ethereum library. -// -// The go-ethereum library is free software: you can redistribute it and/or modify -// it under the terms of the GNU Lesser General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// The go-ethereum library is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Lesser General Public License for more details. -// -// You should have received a copy of the GNU Lesser General Public License -// along with the go-ethereum library. If not, see . - -package fetcher - -import ( - "time" - - "github.com/ethereum/go-ethereum/common" -) - -// A header held by a cooling supplier must not prevent healthy alternatives -// from retrieving the block. No body request has been admitted at this stage. -func (f *BlockFetcher) rerouteCoolingBody(hash common.Hash, announces []*blockAnnounce) bool { - f.mu.Lock() - defer f.mu.Unlock() - now := time.Now() - for _, announce := range announces { - until, ok := f.fetchBackoff.Peek(announce.origin) - if !ok || !now.Before(until) { - return false - } - } - for _, alternate := range f.announced[hash] { - until, ok := f.fetchBackoff.Peek(alternate.origin) - if !ok || !now.Before(until) { - delete(f.fetched, hash) - return true - } - } - return false -} - -func (f *BlockFetcher) resetCompletionTimer(timer *time.Timer) { - var earliest time.Time - for _, announces := range f.fetched { - announce := announces[0] - deadline := announce.time.Add(gatherSlack) - if announce.bodyDeferred { - deadline = f.completeRetryAt - } - if earliest.IsZero() || deadline.Before(earliest) { - earliest = deadline - } - } - f.completeTimerAt = earliest - if !earliest.IsZero() { - timer.Reset(time.Until(earliest)) - } -} - -func (f *BlockFetcher) rearmFreshCompletion(timer *time.Timer, announce *blockAnnounce) { - deadline := announce.time.Add(gatherSlack) - if f.completeTimerAt.IsZero() || deadline.Before(f.completeTimerAt) { - f.completeTimerAt = deadline - timer.Reset(time.Until(deadline)) - } -} diff --git a/eth/fetcher/block_fetcher_body_retry_test.go b/eth/fetcher/block_fetcher_body_retry_test.go deleted file mode 100644 index 8d6ab2dd91..0000000000 --- a/eth/fetcher/block_fetcher_body_retry_test.go +++ /dev/null @@ -1,125 +0,0 @@ -// Copyright 2026 The go-ethereum Authors -// This file is part of the go-ethereum library. -// -// The go-ethereum library is free software: you can redistribute it and/or modify -// it under the terms of the GNU Lesser General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// The go-ethereum library is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Lesser General Public License for more details. -// -// You should have received a copy of the GNU Lesser General Public License -// along with the go-ethereum library. If not, see . - -package fetcher - -import ( - "testing" - "testing/synctest" - "time" - - "github.com/stretchr/testify/require" - - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/core/types" - ethproto "github.com/ethereum/go-ethereum/eth/protocols/eth" -) - -func TestBodyRetryCoolingSupplier(t *testing.T) { - for _, mode := range []string{"healthy alternative", "cooling alternative", "no alternative", "supplier healthy", "expired cooldown"} { - t.Run(mode, func(t *testing.T) { - p := newScheduleProbe(t) - f := p.fetcher - hash := p.announce("other", time.Second) - f.fetched[hash] = []*blockAnnounce{{origin: "slow", time: time.Now()}} - if mode == "no alternative" { - f.dropAnnouncements(hash, f.announced[hash]) - } - if mode != "supplier healthy" { - f.backoffFetchPeer("slow") - } - if mode == "expired cooldown" { - f.fetchBackoff.Add("slow", time.Now().Add(-time.Second)) - } - if mode == "cooling alternative" { - f.backoffFetchPeer("other") - } - want := mode == "healthy alternative" - require.Equal(t, want, f.rerouteCoolingBody(hash, f.fetched[hash])) - if want { - require.Nil(t, f.fetched[hash]) - require.Equal(t, 1, f.announces["other"]) - require.False(t, f.scheduleHeaderFetches()) - require.Equal(t, hash, <-p.started) - require.Equal(t, "other", f.fetching[hash].origin) - } else { - require.Len(t, f.fetched[hash], 1) - } - }) - } -} -func TestBodyRetryFreshCompletionDeadline(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - p := newScheduleProbe(t) - f := p.fetcher - stalled := common.Hash{1} - f.fetched[stalled] = []*blockAnnounce{{origin: "slow", time: time.Now().Add(-time.Second)}} - f.backoffFetchPeer("slow") - require.True(t, f.scheduleBodyFetches()) - f.completeRetry = time.Second - timer := time.NewTimer(time.Hour) - defer timer.Stop() - f.rescheduleComplete(timer, true) - retryAt := f.completeRetryAt - fresh := &blockAnnounce{origin: "fresh", time: time.Now()} - f.fetched[common.Hash{2}] = []*blockAnnounce{fresh} - start := time.Now() - f.rearmFreshCompletion(timer, fresh) - <-timer.C - require.Equal(t, gatherSlack, time.Since(start)) - require.Equal(t, retryAt, f.completeRetryAt) - require.Equal(t, time.Second, f.completeRetry) - delete(f.fetched, common.Hash{2}) - f.resetCompletionTimer(timer) - <-timer.C - require.Equal(t, retryAt, time.Now()) - }) -} - -func TestBodyRetryLateHeaderUsesAlternative(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - hashes, blocks := makeChain(1, 85, genesis) - block := blocks[hashes[0]] - tester := newTester(false) - f := tester.fetcher - defer f.Stop() - defer f.wm.witnessCache.Stop() - imported := make(chan struct{}, 1) - f.importedHook = func(*types.Header, *types.Block) { imported <- struct{}{} } - requests := make(chan chan *ethproto.Response, 1) - request := ðproto.Request{Cancel: make(chan struct{})} - fetch := func(_ common.Hash, sink chan *ethproto.Response) (*ethproto.Request, error) { - requests <- sink - return request, nil - } - require.NoError(t, f.Notify("slow", block.Hash(), block.NumberU64(), time.Now(), fetch, tester.makeBodyFetcher("slow", blocks, 0), nil)) - sink := <-requests - f.backoffFetchPeer("slow") - start := time.Now() - require.NoError(t, f.Notify("healthy", block.Hash(), block.NumberU64(), time.Now(), tester.makeHeaderFetcher("healthy", blocks, 0), tester.makeBodyFetcher("healthy", blocks, 0), nil)) - headers := ethproto.BlockHeadersRequest{block.Header()} - sink <- ðproto.Response{Req: request, Res: &headers, Done: make(chan error, 1)} - select { - case <-imported: - case <-time.After(2 * time.Second): - t.Fatal("healthy alternative did not recover block during supplier cooldown") - } - require.Less(t, time.Since(start), fetchPeerBackoff) - resume := pauseBlockTimeoutLoop(t, f) - defer resume() - require.Empty(t, f.fetched) - }) -} diff --git a/eth/fetcher/block_fetcher_bounds_test.go b/eth/fetcher/block_fetcher_bounds_test.go deleted file mode 100644 index 6a0f0f5256..0000000000 --- a/eth/fetcher/block_fetcher_bounds_test.go +++ /dev/null @@ -1,315 +0,0 @@ -// Copyright 2026 The go-ethereum Authors -// This file is part of the go-ethereum library. -// -// The go-ethereum library is free software: you can redistribute it and/or modify -// it under the terms of the GNU Lesser General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// The go-ethereum library is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Lesser General Public License for more details. -// -// You should have received a copy of the GNU Lesser General Public License -// along with the go-ethereum library. If not, see . - -package fetcher - -import ( - "errors" - "runtime" - "sync/atomic" - "testing" - "time" - - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/eth/protocols/eth" -) - -// stalledDispatch models a peer that stops draining its connection: the request -// callback blocks before it can hand back a Request, which is where the real -// dispatcher blocks and the reason the response deadline cannot bound workers. -type stalledDispatch struct { - entered chan struct{} - release chan struct{} - inside atomic.Int32 - peak atomic.Int32 - calls atomic.Int32 -} - -func newStalledDispatch(capacity int) *stalledDispatch { - return &stalledDispatch{ - entered: make(chan struct{}, capacity), - release: make(chan struct{}), - } -} - -func (s *stalledDispatch) headerFetcher() headerRequesterFn { - return func(common.Hash, chan *eth.Response) (*eth.Request, error) { - s.enter() - defer s.inside.Add(-1) - <-s.release - - return nil, errors.New("peer stalled") - } -} - -func (s *stalledDispatch) enter() { - s.calls.Add(1) - if depth := s.inside.Add(1); depth > s.peak.Load() { - s.peak.Store(depth) - } - select { - case s.entered <- struct{}{}: - default: - } -} - -func (s *stalledDispatch) await(t *testing.T, count int) { - t.Helper() - for i := 0; i < count; i++ { - select { - case <-s.entered: - case <-time.After(5 * time.Second): - t.Fatalf("only %d of %d requests reached dispatch", i, count) - } - } -} - -func announceSynthetic(t *testing.T, f *BlockFetcher, peer string, count int, fetch headerRequesterFn) { - t.Helper() - for i := 0; i < count; i++ { - hash := common.Hash{} - hash[0], hash[1] = byte(i), byte(i>>8) - hash[31] = peer[0] - // Number 1 keeps every hash inside the queue distance window. - if err := f.Notify(peer, hash, 1, time.Now().Add(-arriveTimeout), fetch, nil, nil); err != nil { - t.Fatal(err) - } - } -} - -func TestFetchWorkersBoundedWhenDispatchBlocks(t *testing.T) { - tester := newTester(false) - defer tester.fetcher.Stop() - - stalled := newStalledDispatch(maxPeerFetchRequests) - defer close(stalled.release) - - announceSynthetic(t, tester.fetcher, "stalled", maxPeerFetchRequests+24, stalled.headerFetcher()) - stalled.await(t, maxPeerFetchRequests) - - // Give the loop several scheduling passes to try the surplus announcements. - time.Sleep(10 * fetchRetryInterval) - - if peak := stalled.peak.Load(); peak > maxPeerFetchRequests { - t.Fatalf("%d concurrent requests reached dispatch, want at most %d", peak, maxPeerFetchRequests) - } - if calls := stalled.calls.Load(); calls > maxPeerFetchRequests { - t.Fatalf("%d dispatch attempts, want at most %d: refused announcements were redispatched", calls, maxPeerFetchRequests) - } - - tester.fetcher.mu.RLock() - held, total := tester.fetcher.fetchSlots["stalled"], tester.fetcher.fetchSlotsTotal - tester.fetcher.mu.RUnlock() - - if held != maxPeerFetchRequests || total != maxPeerFetchRequests { - t.Fatalf("slots held=%d total=%d, want %d", held, total, maxPeerFetchRequests) - } -} - -func TestFetchAdmissionKeepsHealthyPeerProgressing(t *testing.T) { - hashes, blocks := makeChain(1, 61, genesis) - block := blocks[hashes[0]] - - tester := newTester(false) - defer tester.fetcher.Stop() - - imported := make(chan struct{}, 1) - tester.fetcher.importedHook = func(*types.Header, *types.Block) { imported <- struct{}{} } - - stalled := newStalledDispatch(maxPeerFetchRequests) - defer close(stalled.release) - - announceSynthetic(t, tester.fetcher, "stalled", maxPeerFetchRequests+8, stalled.headerFetcher()) - stalled.await(t, maxPeerFetchRequests) - - err := tester.fetcher.Notify("healthy", block.Hash(), block.NumberU64(), time.Now(), - tester.makeHeaderFetcher("healthy", blocks, -gatherSlack), - tester.makeBodyFetcher("healthy", blocks, 0), nil) - if err != nil { - t.Fatal(err) - } - select { - case <-imported: - case <-time.After(5 * time.Second): - t.Fatal("a saturated peer blocked an unrelated healthy peer") - } -} - -func TestFetchAdmissionKeepsAlternativeAnnouncers(t *testing.T) { - hashes, blocks := makeChain(1, 62, genesis) - block := blocks[hashes[0]] - - tester := newTester(false) - defer tester.fetcher.Stop() - f := tester.fetcher - - imported := make(chan struct{}, 1) - f.importedHook = func(*types.Header, *types.Block) { imported <- struct{}{} } - - stalled := newStalledDispatch(maxPeerFetchRequests) - defer close(stalled.release) - - announceSynthetic(t, f, "stalled", maxPeerFetchRequests, stalled.headerFetcher()) - stalled.await(t, maxPeerFetchRequests) - - // The full peer announces first, so a refusal that discarded the whole - // announcement list would take the healthy alternative down with it. - if err := f.Notify("stalled", block.Hash(), block.NumberU64(), time.Now(), stalled.headerFetcher(), nil, nil); err != nil { - t.Fatal(err) - } - err := f.Notify("healthy", block.Hash(), block.NumberU64(), time.Now(), - tester.makeHeaderFetcher("healthy", blocks, -gatherSlack), - tester.makeBodyFetcher("healthy", blocks, 0), nil) - if err != nil { - t.Fatal(err) - } - select { - case <-imported: - case <-time.After(5 * time.Second): - t.Fatal("a full announcer prevented the healthy announcer from supplying the block") - } -} - -func TestFetchAdmissionDoesNotBusyLoop(t *testing.T) { - tester := newTester(false) - defer tester.fetcher.Stop() - - stalled := newStalledDispatch(maxPeerFetchRequests) - defer close(stalled.release) - - announceSynthetic(t, tester.fetcher, "stalled", maxPeerFetchRequests+32, stalled.headerFetcher()) - stalled.await(t, maxPeerFetchRequests) - - settled := runtime.NumGoroutine() - time.Sleep(15 * fetchRetryInterval) - - if grown := runtime.NumGoroutine() - settled; grown > maxPeerFetchRequests { - t.Fatalf("goroutines grew by %d while admission was full", grown) - } - // A spinning timer would keep redispatching the announcements it refused. - if calls := stalled.calls.Load(); calls != maxPeerFetchRequests { - t.Fatalf("%d dispatch attempts over the window, want %d", calls, maxPeerFetchRequests) - } -} - -func TestFetchSlotReleasedOnEveryExit(t *testing.T) { - for _, tc := range []struct { - name string - exit string - release bool - }{ - {"nil fetch function", "nil", true}, - {"dispatch error", "error", true}, - {"nil response", "nilres", true}, - {"wrong response type", "badtype", true}, - {"shutdown", "quit", false}, - } { - t.Run(tc.name, func(t *testing.T) { - testFetchSlotReleasedOnExit(t, tc.exit, tc.release) - }) - } -} - -func testFetchSlotReleasedOnExit(t *testing.T, exit string, wantRelease bool) { - f := newBlockReleaseFetcher(t) - released := drainFetchReleases(f) - - announce := &blockAnnounce{hash: common.Hash{1}, origin: "peer"} - f.fetching[announce.hash] = announce - if !f.acquireFetchSlot("peer") { - t.Fatal("could not take a slot") - } - req := headerRequest{hash: announce.hash, announce: announce, fetch: exitFetcher(t, f, exit)} - if exit == "quit" { - f.Stop() - } - f.runHeaderRequest("peer", req) - - f.mu.RLock() - leaked, total := len(f.fetchSlots), f.fetchSlotsTotal - f.mu.RUnlock() - - if leaked != 0 || total != 0 { - t.Fatalf("slot leaked on the %q exit: %d peers total=%d", exit, leaked, total) - } - assertRelease(t, released, announce, wantRelease) -} - -func assertRelease(t *testing.T, released <-chan blockFetchRelease, announce *blockAnnounce, want bool) { - t.Helper() - if !want { - select { - case release := <-released: - t.Fatalf("unexpected release notification: %+v", release) - case <-time.After(100 * time.Millisecond): - } - - return - } - select { - case release := <-released: - if !release.headers || len(release.announces) != 1 || release.announces[0] != announce { - t.Fatalf("release notification = %+v", release) - } - case <-time.After(5 * time.Second): - t.Fatal("exit did not return ownership of its announcement") - } -} - -func exitFetcher(t *testing.T, f *BlockFetcher, exit string) headerRequesterFn { - t.Helper() - if exit == "nil" { - return nil - } - - return func(_ common.Hash, sink chan *eth.Response) (*eth.Request, error) { - if exit == "error" { - return nil, errors.New("dispatch failed") - } - request := ð.Request{Peer: "peer"} - go func() { - switch exit { - case "nilres": - sink <- nil - case "badtype": - sink <- ð.Response{Req: request, Res: nil, Done: make(chan error, 1)} - case "quit": - <-f.quit - } - }() - - return request, nil - } -} - -// drainFetchReleases forwards release notifications so workers under test do -// not block on the loop, which is not running here. -func drainFetchReleases(f *BlockFetcher) <-chan blockFetchRelease { - released := make(chan blockFetchRelease, 8) - go func() { - for { - select { - case release := <-f.fetchRelease: - released <- release - case <-f.quit: - return - } - } - }() - - return released -} diff --git a/eth/fetcher/block_fetcher_discard_test.go b/eth/fetcher/block_fetcher_discard_test.go deleted file mode 100644 index 352f4fd47b..0000000000 --- a/eth/fetcher/block_fetcher_discard_test.go +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright 2026 The go-ethereum Authors -// This file is part of the go-ethereum library. -// -// The go-ethereum library is free software: you can redistribute it and/or modify -// it under the terms of the GNU Lesser General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// The go-ethereum library is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Lesser General Public License for more details. -// -// You should have received a copy of the GNU Lesser General Public License -// along with the go-ethereum library. If not, see . - -package fetcher - -import ( - "testing" - "time" - - "github.com/stretchr/testify/require" -) - -func TestBlockFetchDiscardReleasesAlternatives(t *testing.T) { - for _, old := range []bool{false, true} { - name := "known" - if old { - name = "too old" - } - t.Run(name, func(t *testing.T) { - tester := newTester(false) - f := tester.fetcher - defer f.Stop() - hashes, blocks := makeChain(maxUncleDist+4, 83, genesis) - block := blocks[hashes[len(hashes)-3]] - resume := pauseBlockTimeoutLoop(t, f) - f.enqueue("owner", nil, block, nil) - hash := block.Hash() - f.completing[hash] = &blockAnnounce{origin: "owner", time: time.Now()} - f.announced[hash] = []*blockAnnounce{{origin: "alternate", time: time.Now()}} - f.announces["alternate"] = 1 - tester.lock.Lock() - head := block - if old { - head = blocks[hashes[0]] - } else { - tester.blocks[hash] = block - } - tester.headers[head.Hash()] = head.Header() - tester.blocks[head.Hash()] = head - tester.hashes = append(tester.hashes, head.Hash()) - tester.lock.Unlock() - resume() - resume = pauseBlockTimeoutLoop(t, f) - defer resume() - require.Nil(t, f.queued[hash]) - require.Nil(t, f.completing[hash]) - require.Nil(t, f.announced[hash]) - require.Empty(t, f.announces) - require.Empty(t, f.queues) - }) - } -} diff --git a/eth/fetcher/block_fetcher_partial_test.go b/eth/fetcher/block_fetcher_partial_test.go deleted file mode 100644 index d6e24114b6..0000000000 --- a/eth/fetcher/block_fetcher_partial_test.go +++ /dev/null @@ -1,132 +0,0 @@ -// Copyright 2026 The go-ethereum Authors -// This file is part of the go-ethereum library. -// -// The go-ethereum library is free software: you can redistribute it and/or modify -// it under the terms of the GNU Lesser General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// The go-ethereum library is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Lesser General Public License for more details. -// -// You should have received a copy of the GNU Lesser General Public License -// along with the go-ethereum library. If not, see . - -package fetcher - -import ( - "math/big" - "testing" - "testing/synctest" - "time" - - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/core/types" - ethproto "github.com/ethereum/go-ethereum/eth/protocols/eth" - "github.com/ethereum/go-ethereum/rlp" - "github.com/ethereum/go-ethereum/trie" - "github.com/stretchr/testify/require" -) - -type partialBodyRequest struct { - count int - at time.Time -} - -func TestBlockFetchPartialSingleSupplier(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - tester := newTester(false) - f := tester.fetcher - defer f.Stop() - defer f.wm.witnessCache.Stop() - blocks := make(map[common.Hash]*types.Block) - parent := genesis - for i := 1; i <= 3; i++ { - tx := types.NewTx(&types.LegacyTx{Nonce: uint64(i), Data: make([]byte, 1024*1024)}) - block := types.NewBlock(&types.Header{Number: big.NewInt(int64(i)), ParentHash: parent.Hash()}, &types.Body{Transactions: []*types.Transaction{tx}}, nil, trie.NewStackTrie(nil)) - blocks[block.Hash()] = block - parent = block - } - requests := make(chan partialBodyRequest, 4) - imported := make(chan common.Hash, 3) - f.importedHook = func(_ *types.Header, b *types.Block) { imported <- b.Hash() } - fetch := limitedBodyFetcher(t, blocks, requests) - for hash, block := range blocks { - require.NoError(t, f.Notify("only-peer", hash, block.NumberU64(), time.Now(), tester.makeHeaderFetcher("only-peer", blocks, 0), fetch, nil)) - } - for range 3 { - select { - case <-imported: - case <-time.After(3 * time.Second): - t.Fatal("partial response stranded a block without another announcement") - } - } - first, retry := <-requests, <-requests - require.Equal(t, 3, first.count) - require.Equal(t, 1, retry.count) - require.GreaterOrEqual(t, retry.at.Sub(first.at), fetchRetryInterval) - require.Empty(t, requests) - resume := pauseBlockTimeoutLoop(t, f) - defer resume() - require.Empty(t, f.fetched) - require.Empty(t, f.announced) - require.Empty(t, f.announces) - require.Zero(t, f.fetchSlotsTotal) - }) -} - -func limitedBodyFetcher(t *testing.T, blocks map[common.Hash]*types.Block, requests chan<- partialBodyRequest) bodyRequesterFn { - t.Helper() - return func(hashes []common.Hash, sink chan *ethproto.Response) (*ethproto.Request, error) { - requests <- partialBodyRequest{count: len(hashes), at: time.Now()} - var bodies ethproto.BlockBodiesResponse - size := 0 - for _, hash := range hashes { - // Match ServiceGetBlockBodiesQuery's soft response limit. - if size >= 2*1024*1024 { - break - } - block := blocks[hash] - body := ðproto.BlockBody{Transactions: block.Transactions(), Uncles: block.Uncles()} - encoded, err := rlp.EncodeToBytes(body) - require.NoError(t, err) - size += len(encoded) - bodies = append(bodies, body) - } - req := ðproto.Request{Cancel: make(chan struct{})} - go func() { sink <- ðproto.Response{Req: req, Res: &bodies, Done: make(chan error, 1)} }() - return req, nil - } -} - -func TestBlockFetchPartialReleaseOwnership(t *testing.T) { - for _, mode := range []string{"retry", "empty", "headers", "new owner", "queued"} { - t.Run(mode, func(t *testing.T) { - f := newBlockReleaseFetcher(t) - a := addBlockReleaseRequest(f, mode == "headers", 1, "peer") - release := blockFetchRelease{announces: []*blockAnnounce{a}, retryBodies: mode != "empty", headers: mode == "headers"} - if mode == "new owner" { - copy := *a - f.completing[a.hash] = © - } - if mode == "queued" { - f.queued[a.hash] = &blockOrHeaderInject{} - } - require.Equal(t, mode == "retry", f.releaseFetchOwnership(release)) - if mode != "retry" { - require.Empty(t, f.fetched) - return - } - retry := f.fetched[a.hash][0] - require.NotSame(t, a, retry) - require.True(t, retry.bodyDeferred) - require.Equal(t, a.origin, retry.origin) - delete(f.fetched, a.hash) - f.completing[a.hash] = retry - require.False(t, f.releaseFetchOwnership(release)) - require.Same(t, retry, f.completing[a.hash]) - }) - } -} diff --git a/eth/fetcher/block_fetcher_pending.go b/eth/fetcher/block_fetcher_pending.go deleted file mode 100644 index 61459f3538..0000000000 --- a/eth/fetcher/block_fetcher_pending.go +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright 2026 The go-ethereum Authors -// This file is part of the go-ethereum library. -// -// The go-ethereum library is free software: you can redistribute it and/or modify -// it under the terms of the GNU Lesser General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// The go-ethereum library is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Lesser General Public License for more details. -// -// You should have received a copy of the GNU Lesser General Public License -// along with the go-ethereum library. If not, see . - -package fetcher - -// maxPendingAnnounceOrigins caps how many origins may hold pending -// announcements at once. hashLimit bounds each origin on its own, but nothing -// bounded the number of origins, and peers reconnecting under fresh identities -// keep acquiring new quota. Reusing maxTotalFetchRequests pairs the two -// node-wide bounds; together with hashLimit it caps retained announcement -// records, duplicates of one hash across origins included. -// -// This bounds metadata. It is not a fairness guarantee, and refusing a new -// origin at the cap says nothing about whether that peer is misbehaving. -const maxPendingAnnounceOrigins = maxTotalFetchRequests - -// admitAnnouncement reports the credit a pending announcement from origin would -// take, and whether there is room for it. An origin that already holds pending -// announcements keeps using its own per-origin quota; a new one is declined -// until an existing origin's records are actually removed. -// -// Runs on the fetcher loop. -func (f *BlockFetcher) admitAnnouncement(origin string) (int, bool) { - count, known := f.announces[origin] - if !known && len(f.announces) >= maxPendingAnnounceOrigins { - return 0, false - } - count++ - - return count, count <= hashLimit -} diff --git a/eth/fetcher/block_fetcher_pending_test.go b/eth/fetcher/block_fetcher_pending_test.go deleted file mode 100644 index f41842ba25..0000000000 --- a/eth/fetcher/block_fetcher_pending_test.go +++ /dev/null @@ -1,319 +0,0 @@ -// Copyright 2026 The go-ethereum Authors -// This file is part of the go-ethereum library. -// -// The go-ethereum library is free software: you can redistribute it and/or modify -// it under the terms of the GNU Lesser General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// The go-ethereum library is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Lesser General Public License for more details. -// -// You should have received a copy of the GNU Lesser General Public License -// along with the go-ethereum library. If not, see . - -package fetcher - -import ( - "encoding/binary" - "fmt" - "testing" - "time" - - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/eth/protocols/eth" -) - -// pendingCredits counts the announcement records each origin holds in -// f.announced, which is exactly what f.announces must mirror. -func pendingCredits(f *BlockFetcher) map[string]int { - credits := make(map[string]int) - for _, announces := range f.announced { - for _, announce := range announces { - credits[announce.origin]++ - } - } - - return credits -} - -func assertPendingAccounting(t *testing.T, f *BlockFetcher) { - t.Helper() - want := pendingCredits(f) - if len(f.announces) != len(want) { - t.Fatalf("announces tracks %d origins, pending records span %d", len(f.announces), len(want)) - } - for origin, count := range want { - if f.announces[origin] != count { - t.Fatalf("origin %q credited %d, holds %d pending records", origin, f.announces[origin], count) - } - } -} - -func pendingAnnounce(origin string, hash common.Hash) *blockAnnounce { - return &blockAnnounce{hash: hash, number: 1, origin: origin, time: time.Now()} -} - -// seedPending registers count announcements for origin the way Notify does. -func seedPending(f *BlockFetcher, origin string, count int, first uint64) []common.Hash { - hashes := make([]common.Hash, count) - for i := range hashes { - binary.BigEndian.PutUint64(hashes[i][:8], first+uint64(i)) - f.announced[hashes[i]] = append(f.announced[hashes[i]], pendingAnnounce(origin, hashes[i])) - f.announces[origin]++ - } - - return hashes -} - -func TestForgetHashDebitsOnlyPendingAnnouncements(t *testing.T) { - for _, stage := range []string{"fetching", "fetched", "completing"} { - t.Run(stage, func(t *testing.T) { - f := newBlockReleaseFetcher(t) - kept := seedPending(f, "peer", 4, 100) - - var hash common.Hash - hash[0] = 0xaa - announce := pendingAnnounce("peer", hash) - switch stage { - case "fetching": - f.fetching[hash] = announce - case "fetched": - f.fetched[hash] = []*blockAnnounce{announce} - case "completing": - f.completing[hash] = announce - } - - f.forgetHash(hash) - - if f.announces["peer"] != len(kept) { - t.Fatalf("clearing a %s entry debited pending credits: %d, want %d", stage, f.announces["peer"], len(kept)) - } - assertPendingAccounting(t, f) - if f.fetching[hash] != nil || f.fetched[hash] != nil || f.completing[hash] != nil { - t.Fatalf("%s metadata survived forgetHash", stage) - } - }) - } -} - -func TestForgetHashDebitsTheAnnouncedList(t *testing.T) { - f := newBlockReleaseFetcher(t) - hashes := seedPending(f, "peer", 3, 200) - f.announced[hashes[0]] = append(f.announced[hashes[0]], pendingAnnounce("other", hashes[0])) - f.announces["other"]++ - - f.forgetHash(hashes[0]) - - if f.announces["peer"] != 2 { - t.Fatalf("peer credited %d after forgetting one of its three announcements, want 2", f.announces["peer"]) - } - if _, ok := f.announces["other"]; ok { - t.Fatal("an origin with no remaining announcements kept a counter") - } - assertPendingAccounting(t, f) -} - -func TestExpirySweepPreservesPendingAccounting(t *testing.T) { - f := newBlockReleaseFetcher(t) - kept := seedPending(f, "peer", 6, 300) - - // Headers already admitted carry no pending credit; admitHeaderRequest - // released it, so the sweep must not take a second one. - admitted := f.admitHeaderRequest(kept[0], f.announced[kept[0]][0]) - admitted.announce.time = time.Now().Add(-fetchTimeout - time.Second) - - expired := f.expiredFetches() - if len(expired) != 1 { - t.Fatalf("expiry sweep found %d requests, want 1", len(expired)) - } - for _, hash := range expired { - f.forgetHash(hash) - } - if f.announces["peer"] != len(kept)-1 { - t.Fatalf("peer credited %d after expiry, want %d", f.announces["peer"], len(kept)-1) - } - assertPendingAccounting(t, f) -} - -func TestAdmitAnnouncementOriginCap(t *testing.T) { - for _, tc := range []struct { - name string - origin string - want bool - }{ - {"existing origin continues at the cap", "origin-0", true}, - {"fresh origin declined at the cap", "newcomer", false}, - } { - t.Run(tc.name, func(t *testing.T) { - f := newBlockReleaseFetcher(t) - for i := 0; i < maxPendingAnnounceOrigins; i++ { - seedPending(f, fmt.Sprintf("origin-%d", i), 1, uint64(i)*1000) - } - if _, ok := f.admitAnnouncement(tc.origin); ok != tc.want { - t.Fatalf("admitAnnouncement(%q) = %t, want %t", tc.origin, ok, tc.want) - } - }) - } -} - -func TestAdmitAnnouncementRecoversSpaceAfterRemoval(t *testing.T) { - f := newBlockReleaseFetcher(t) - var hashes []common.Hash - for i := 0; i < maxPendingAnnounceOrigins; i++ { - hashes = append(hashes, seedPending(f, fmt.Sprintf("origin-%d", i), 1, uint64(i)*1000)...) - } - if _, ok := f.admitAnnouncement("newcomer"); ok { - t.Fatal("a fresh origin was admitted at the origin cap") - } - - // Only an actual removal of pending records frees an origin slot. - f.forgetHash(hashes[0]) - - if _, ok := f.announces["origin-0"]; ok { - t.Fatal("a drained origin kept its counter") - } - if _, ok := f.admitAnnouncement("newcomer"); !ok { - t.Fatal("space was not recovered after pending records were removed") - } - assertPendingAccounting(t, f) -} - -func TestAdmitAnnouncementBoundsDuplicateHashOrigins(t *testing.T) { - f := newBlockReleaseFetcher(t) - var shared common.Hash - shared[0] = 0x7f - - // Every origin announces the same hash, so len(f.announced) stays at one - // while the retained record count tracks the number of origins. - admitted := 0 - for i := 0; i < maxPendingAnnounceOrigins*2; i++ { - origin := fmt.Sprintf("origin-%d", i) - count, ok := f.admitAnnouncement(origin) - if !ok { - continue - } - f.announces[origin] = count - f.announced[shared] = append(f.announced[shared], pendingAnnounce(origin, shared)) - admitted++ - } - if admitted != maxPendingAnnounceOrigins { - t.Fatalf("admitted %d origins for one hash, want the origin cap %d", admitted, maxPendingAnnounceOrigins) - } - if len(f.announced) != 1 { - t.Fatalf("expected a single announced hash, got %d", len(f.announced)) - } - assertPendingAccounting(t, f) -} - -func TestAdmitAnnouncementKeepsPerOriginLimit(t *testing.T) { - f := newBlockReleaseFetcher(t) - seedPending(f, "peer", hashLimit, 500) - - if _, ok := f.admitAnnouncement("peer"); ok { - t.Fatalf("origin admitted past the per-origin limit of %d", hashLimit) - } - if _, ok := f.admitAnnouncement("fresh"); !ok { - t.Fatal("a full origin blocked an unrelated origin below the origin cap") - } -} - -func TestPendingAnnouncementsStayBoundedAcrossExpiryCycles(t *testing.T) { - tester := newTester(false) - defer tester.fetcher.Stop() - f := tester.fetcher - - sinks := make(chan chan *eth.Response, 4*maxPeerFetchRequests) - fetch := func(_ common.Hash, sink chan *eth.Response) (*eth.Request, error) { - sinks <- sink - - return ð.Request{Cancel: make(chan struct{})}, nil - } - next := uint64(0) - announce := func(count int) { - for i := 0; i < count; i++ { - next++ - var hash common.Hash - binary.BigEndian.PutUint64(hash[:8], next) - if err := f.Notify("slow", hash, 1, time.Now().Add(-arriveTimeout), fetch, nil, nil); err != nil { - t.Fatal(err) - } - } - } - announce(maxPeerFetchRequests) - active := awaitSinks(t, sinks, maxPeerFetchRequests) - announce(hashLimit) - - for cycle := 0; cycle < 3; cycle++ { - expirePendingFetches(t, f) - announce(maxPeerFetchRequests) - active = replyEmptyHeaders(t, sinks, active) - announce(maxPeerFetchRequests) - } - - resume := pauseBlockTimeoutLoop(t, f) - defer resume() - f.mu.RLock() - defer f.mu.RUnlock() - - if queued := len(f.announced); queued > hashLimit { - t.Fatalf("pending metadata grew to %d hashes, limit %d (credited %d)", queued, hashLimit, f.announces["slow"]) - } - assertPendingAccounting(t, f) -} - -func awaitSinks(t *testing.T, sinks <-chan chan *eth.Response, count int) []chan *eth.Response { - t.Helper() - active := make([]chan *eth.Response, count) - for i := range active { - select { - case active[i] = <-sinks: - case <-time.After(5 * time.Second): - t.Fatalf("only %d of %d header requests started", i, count) - } - } - - return active -} - -// expirePendingFetches ages the outstanding requests past the fetch deadline so -// the loop's own sweep runs, without waiting out fetchTimeout. -func expirePendingFetches(t *testing.T, f *BlockFetcher) { - t.Helper() - resume := pauseBlockTimeoutLoop(t, f) - f.mu.Lock() - if len(f.fetching) != maxPeerFetchRequests { - f.mu.Unlock() - resume() - t.Fatalf("outstanding requests = %d, want %d", len(f.fetching), maxPeerFetchRequests) - } - for _, owned := range f.fetching { - owned.time = time.Now().Add(-fetchTimeout - time.Second) - } - f.mu.Unlock() - resume() - - resume = pauseBlockTimeoutLoop(t, f) - outstanding := len(f.fetching) - resume() - - if outstanding != 0 { - t.Fatalf("expiry sweep left %d outstanding requests", outstanding) - } -} - -func replyEmptyHeaders(t *testing.T, sinks <-chan chan *eth.Response, active []chan *eth.Response) []chan *eth.Response { - t.Helper() - empty := eth.BlockHeadersRequest{} - for _, sink := range active { - select { - case sink <- ð.Response{Res: &empty, Done: make(chan error, 1)}: - case <-time.After(5 * time.Second): - t.Fatal("worker did not accept a late empty response") - } - } - - return awaitSinks(t, sinks, len(active)) -} diff --git a/eth/fetcher/block_fetcher_release.go b/eth/fetcher/block_fetcher_release.go deleted file mode 100644 index a14c1f972c..0000000000 --- a/eth/fetcher/block_fetcher_release.go +++ /dev/null @@ -1,88 +0,0 @@ -// Copyright 2026 The go-ethereum Authors -// This file is part of the go-ethereum library. -// -// The go-ethereum library is free software: you can redistribute it and/or modify -// it under the terms of the GNU Lesser General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// The go-ethereum library is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Lesser General Public License for more details. -// -// You should have received a copy of the GNU Lesser General Public License -// along with the go-ethereum library. If not, see . - -package fetcher - -import "github.com/ethereum/go-ethereum/common" - -// blockFetchRelease hands back announcements whose request ended without the -// block reaching a later stage, covering timeouts as well as dispatch failures -// and responses that did not carry the requested data. -type blockFetchRelease struct { - retryBodies bool - headers bool - announces []*blockAnnounce -} - -// fetchAnnouncements snapshots the announcements a scheduled request owns, so -// the worker can identify them later even though the maps keep changing. -func fetchAnnouncements(pending map[common.Hash]*blockAnnounce, hashes []common.Hash) []*blockAnnounce { - announces := make([]*blockAnnounce, 0, len(hashes)) - for _, hash := range hashes { - if announce := pending[hash]; announce != nil { - announces = append(announces, announce) - } - } - - return announces -} - -func (f *BlockFetcher) notifyFetchRelease(headers bool, announces []*blockAnnounce) { - if len(announces) == 0 { - return - } - f.notifyRequestRelease(blockFetchRelease{headers: headers, announces: announces}) -} - -func (f *BlockFetcher) notifyRequestRelease(release blockFetchRelease) { - select { - case f.fetchRelease <- release: - case <-f.quit: - } -} - -// releaseFetchOwnership runs on the fetcher loop so a finished request cannot -// erase a newer attempt for the same hash, nor a block that has already -// advanced to the import queue or to witness retrieval. -func (f *BlockFetcher) releaseFetchOwnership(release blockFetchRelease) bool { - f.mu.Lock() - defer f.mu.Unlock() - - requeued := false - pending := f.completing - if release.headers { - pending = f.fetching - } - for _, announce := range release.announces { - if announce == nil || pending[announce.hash] != announce { - continue - } - if f.queued[announce.hash] != nil || f.wm.isPending(announce.hash) { - continue - } - // Scheduling already released this announcement's count; the counts - // still held belong to other announcements from the same peer. - delete(pending, announce.hash) - if release.retryBodies && !release.headers { - // A fresh token prevents an old release from clearing the retry. - retry := *announce - retry.bodyDeferred = true - f.fetched[announce.hash] = []*blockAnnounce{&retry} - requeued = true - } - } - return requeued -} diff --git a/eth/fetcher/block_fetcher_release_test.go b/eth/fetcher/block_fetcher_release_test.go deleted file mode 100644 index 21cb35d4fe..0000000000 --- a/eth/fetcher/block_fetcher_release_test.go +++ /dev/null @@ -1,321 +0,0 @@ -// Copyright 2026 The go-ethereum Authors -// This file is part of the go-ethereum library. -// -// The go-ethereum library is free software: you can redistribute it and/or modify -// it under the terms of the GNU Lesser General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// The go-ethereum library is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Lesser General Public License for more details. -// -// You should have received a copy of the GNU Lesser General Public License -// along with the go-ethereum library. If not, see . - -package fetcher - -import ( - "testing" - "time" - - "github.com/ethereum/go-ethereum/common" -) - -func newBlockReleaseFetcher(t *testing.T) *BlockFetcher { - t.Helper() - f := NewBlockFetcher(false, nil, nil, nil, nil, nil, nil, nil, nil, func(peer string) { - t.Errorf("release dropped peer %q", peer) - }, false, false, 0, nil, nil) - t.Cleanup(func() { - select { - case <-f.quit: - default: - f.Stop() - } - f.wm.stopAndDrainTimer() - }) - - return f -} - -func pendingBlockRelease(f *BlockFetcher, headers bool) map[common.Hash]*blockAnnounce { - if headers { - return f.fetching - } - - return f.completing -} - -func addBlockReleaseRequest(f *BlockFetcher, headers bool, hash byte, peer string) *blockAnnounce { - announce := &blockAnnounce{hash: common.Hash{hash}, origin: peer} - pendingBlockRelease(f, headers)[announce.hash] = announce - - return announce -} - -func TestBlockFetchReleaseBatchCleanup(t *testing.T) { - for _, tc := range []struct { - name string - headers bool - }{{"headers", true}, {"bodies", false}} { - t.Run(tc.name, func(t *testing.T) { - testBlockFetchReleaseBatchCleanup(t, tc.headers) - }) - } -} - -func testBlockFetchReleaseBatchCleanup(t *testing.T, headers bool) { - f := newBlockReleaseFetcher(t) - first := addBlockReleaseRequest(f, headers, 1, "shared") - second := addBlockReleaseRequest(f, headers, 2, "expired") - samePeer := addBlockReleaseRequest(f, headers, 3, "shared") - otherPeer := addBlockReleaseRequest(f, headers, 4, "other") - release := blockFetchRelease{headers: headers, announces: []*blockAnnounce{first, second}} - f.releaseFetchOwnership(release) - f.releaseFetchOwnership(release) - - pending := pendingBlockRelease(f, headers) - if len(pending) != 2 || pending[first.hash] != nil || pending[second.hash] != nil { - t.Fatalf("released requests remain: %v", pending) - } - for _, announce := range []*blockAnnounce{samePeer, otherPeer} { - if pending[announce.hash] != announce || f.announces[announce.origin] != 0 { - t.Errorf("unrelated request or accounting changed for %q", announce.origin) - } - } - if len(f.announces) != 0 { - t.Errorf("release created announcement counters: %v", f.announces) - } -} - -func TestBlockFetchReleasePreservesReplacement(t *testing.T) { - for _, tc := range []struct { - name string - headers bool - }{{"headers", true}, {"bodies", false}} { - t.Run(tc.name, func(t *testing.T) { - f := newBlockReleaseFetcher(t) - original := addBlockReleaseRequest(f, tc.headers, 1, "peer") - replacement := &blockAnnounce{hash: original.hash, origin: original.origin} - pending := pendingBlockRelease(f, tc.headers) - pending[original.hash] = replacement - - f.releaseFetchOwnership(blockFetchRelease{headers: tc.headers, announces: []*blockAnnounce{original}}) - if pending[original.hash] != replacement || f.announces[original.origin] != 0 { - t.Fatal("late release removed the replacement request or its accounting") - } - }) - } -} - -func TestBlockFetchReleasePreservesLaterStage(t *testing.T) { - for _, stage := range []string{"fetched", "completing"} { - t.Run(stage, func(t *testing.T) { - testBlockFetchReleasePreservesLaterStage(t, stage) - }) - } -} - -func testBlockFetchReleasePreservesLaterStage(t *testing.T, stage string) { - f := newBlockReleaseFetcher(t) - announce := addBlockReleaseRequest(f, true, 1, "peer") - delete(f.fetching, announce.hash) - if stage == "fetched" { - f.fetched[announce.hash] = []*blockAnnounce{announce} - } else { - f.completing[announce.hash] = announce - } - - f.releaseFetchOwnership(blockFetchRelease{headers: true, announces: []*blockAnnounce{announce}}) - current := f.completing[announce.hash] - if stage == "fetched" { - if len(f.fetched[announce.hash]) != 1 { - t.Fatal("late release removed the fetched header") - } - current = f.fetched[announce.hash][0] - } - if current != announce || f.announces[announce.origin] != 0 { - t.Fatal("late release changed the later stage or its accounting") - } -} - -func TestBlockFetchReleasePreservesCompletedWork(t *testing.T) { - for _, tc := range []struct { - name string - headers bool - witness bool - }{ - {"header queued", true, false}, - {"header witness", true, true}, - {"body queued", false, false}, - {"body witness", false, true}, - } { - t.Run(tc.name, func(t *testing.T) { - testBlockFetchReleasePreservesCompletedWork(t, tc.headers, tc.witness) - }) - } -} - -func testBlockFetchReleasePreservesCompletedWork(t *testing.T, headers, pendingWitness bool) { - f := newBlockReleaseFetcher(t) - announce := addBlockReleaseRequest(f, headers, 1, "peer") - op := &blockOrHeaderInject{origin: announce.origin} - witness := &witnessRequestState{op: op, announce: announce} - if pendingWitness { - f.wm.pending[announce.hash] = witness - } else { - f.queued[announce.hash] = op - } - - f.releaseFetchOwnership(blockFetchRelease{headers: headers, announces: []*blockAnnounce{announce}}) - if pendingBlockRelease(f, headers)[announce.hash] != announce || f.announces[announce.origin] != 0 { - t.Fatal("late release changed a completed request or its accounting") - } - if pendingWitness && f.wm.pending[announce.hash] != witness { - t.Error("late release removed pending witness work") - } - if !pendingWitness && f.queued[announce.hash] != op { - t.Error("late release removed the queued block") - } -} - -func TestBlockFetchReleaseOnlyClearsMatchingStage(t *testing.T) { - for _, tc := range []struct { - name string - headers bool - }{{"headers", true}, {"bodies", false}} { - t.Run(tc.name, func(t *testing.T) { - f := newBlockReleaseFetcher(t) - original := addBlockReleaseRequest(f, tc.headers, 1, "peer") - otherStage := addBlockReleaseRequest(f, !tc.headers, 1, "peer") - - f.releaseFetchOwnership(blockFetchRelease{headers: tc.headers, announces: []*blockAnnounce{original}}) - if pendingBlockRelease(f, tc.headers)[original.hash] != nil { - t.Error("released request remains") - } - if pendingBlockRelease(f, !tc.headers)[original.hash] != otherStage || f.announces[original.origin] != 0 { - t.Fatal("release changed another stage or its accounting") - } - }) - } -} - -func TestFetchAnnouncementsSnapshot(t *testing.T) { - first := &blockAnnounce{hash: common.Hash{1}, origin: "peer"} - second := &blockAnnounce{hash: common.Hash{2}, origin: "peer"} - pending := map[common.Hash]*blockAnnounce{first.hash: first, second.hash: second} - snapshot := fetchAnnouncements(pending, []common.Hash{second.hash, first.hash}) - pending[first.hash] = &blockAnnounce{hash: first.hash, origin: first.origin} - delete(pending, second.hash) - - if len(snapshot) != 2 || snapshot[0] != second || snapshot[1] != first { - t.Fatalf("snapshot = %v, want the original requests in hash order", snapshot) - } - if empty := fetchAnnouncements(pending, nil); len(empty) != 0 { - t.Errorf("empty batch snapshot = %v", empty) - } - if missing := fetchAnnouncements(pending, []common.Hash{{9}}); len(missing) != 0 { - t.Errorf("unknown hash snapshot = %v", missing) - } -} - -func TestBlockFetchReleaseNotification(t *testing.T) { - for _, tc := range []struct { - name string - headers bool - }{{"headers", true}, {"bodies", false}} { - t.Run(tc.name, func(t *testing.T) { - f := newBlockReleaseFetcher(t) - announce := &blockAnnounce{hash: common.Hash{1}, origin: "peer"} - done := make(chan struct{}) - go func() { - f.notifyFetchRelease(tc.headers, []*blockAnnounce{announce}) - close(done) - }() - - select { - case release := <-f.fetchRelease: - if release.headers != tc.headers || len(release.announces) != 1 || release.announces[0] != announce { - t.Errorf("release notification = %+v", release) - } - case <-time.After(time.Second): - t.Fatal("release notification was not delivered") - } - waitBlockReleaseNotification(t, done) - }) - } -} - -func TestBlockFetchReleaseNotificationSkipsEmpty(t *testing.T) { - f := newBlockReleaseFetcher(t) - done := make(chan struct{}) - go func() { - f.notifyFetchRelease(true, nil) - close(done) - }() - - select { - case release := <-f.fetchRelease: - t.Fatalf("empty release was delivered: %+v", release) - case <-done: - case <-time.After(time.Second): - t.Fatal("empty release notification did not return") - } -} - -func TestBlockFetchReleaseNotificationStops(t *testing.T) { - for _, stopped := range []bool{false, true} { - name := "while notifying" - if stopped { - name = "before notifying" - } - t.Run(name, func(t *testing.T) { - f := newBlockReleaseFetcher(t) - if stopped { - f.Stop() - } - announce := &blockAnnounce{hash: common.Hash{1}, origin: "peer"} - done, started := make(chan struct{}), make(chan struct{}) - go func() { - close(started) - f.notifyFetchRelease(true, []*blockAnnounce{announce}) - close(done) - }() - <-started - if !stopped { - f.Stop() - } - waitBlockReleaseNotification(t, done) - }) - } -} - -func waitBlockReleaseNotification(t *testing.T, done <-chan struct{}) { - t.Helper() - select { - case <-done: - case <-time.After(time.Second): - t.Fatal("release notification did not finish") - } -} - -func TestBlockFetchReleaseMixedBatch(t *testing.T) { - f := newBlockReleaseFetcher(t) - served := addBlockReleaseRequest(f, false, 1, "peer") - skipped := addBlockReleaseRequest(f, false, 2, "peer") - // A partial response advances the hashes it covered to the import queue and - // leaves the rest owned by the finished request. - op := &blockOrHeaderInject{origin: "peer"} - f.queued[served.hash] = op - - f.releaseFetchOwnership(blockFetchRelease{announces: []*blockAnnounce{served, skipped}}) - - if f.completing[served.hash] != served || f.queued[served.hash] != op { - t.Fatal("partial release discarded the body that was served") - } - if f.completing[skipped.hash] != nil { - t.Fatal("partial release retained the hash the response skipped") - } -} diff --git a/eth/fetcher/block_fetcher_request.go b/eth/fetcher/block_fetcher_request.go deleted file mode 100644 index 66b785cd70..0000000000 --- a/eth/fetcher/block_fetcher_request.go +++ /dev/null @@ -1,160 +0,0 @@ -// Copyright 2026 The go-ethereum Authors -// This file is part of the go-ethereum library. -// -// The go-ethereum library is free software: you can redistribute it and/or modify -// it under the terms of the GNU Lesser General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// The go-ethereum library is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Lesser General Public License for more details. -// -// You should have received a copy of the GNU Lesser General Public License -// along with the go-ethereum library. If not, see . - -package fetcher - -import ( - "time" - - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/eth/protocols/eth" - "github.com/ethereum/go-ethereum/log" - "github.com/ethereum/go-ethereum/metrics" -) - -// headerRequest carries what a header worker needs by value. The announce -// pointer is an ownership token only; its mutable fields belong to the loop. -type headerRequest struct { - hash common.Hash - announce *blockAnnounce - fetch headerRequesterFn - announcedAt time.Time -} - -type bodyRequest struct { - hashes []common.Hash - announces []*blockAnnounce - fetch bodyRequesterFn - announcedAt time.Time -} - -// runHeaderRequest performs one header retrieval using a slot the loop already -// reserved, releasing it on every exit. A blocked dispatch keeps that slot -// until the call below returns and the request is closed; the response deadline -// does not start until then, which is why admission and not the deadline is -// what bounds outstanding requests. -func (f *BlockFetcher) runHeaderRequest(peer string, req headerRequest) { - defer f.releaseFetchSlot(peer) - - owned := []*blockAnnounce{req.announce} - if req.fetch == nil { - f.notifyFetchRelease(true, owned) - return - } - resCh := make(chan *eth.Response) - - request, err := req.fetch(req.hash, resCh) - if err != nil { - log.Debug("Failed to request header", "peer", peer, "hash", req.hash, "err", err) - f.notifyFetchRelease(true, owned) - - return - } - defer request.Close() - - timeout := time.NewTimer(2 * fetchTimeout) - defer timeout.Stop() - - select { - case res := <-resCh: - f.deliverHeaders(peer, req, res) - case <-timeout.C: - log.Debug("BlockFetcher: header fetch timed out", "peer", peer, "hash", req.hash, "timeout", 2*fetchTimeout, "backoff", fetchPeerBackoff) - f.backoffFetchPeer(peer) - f.notifyFetchRelease(true, owned) - case <-f.quit: - } -} - -// deliverHeaders filters a header response, then returns ownership. A response -// that did not carry the requested header leaves the announcement owned here, -// so releasing unconditionally is what lets a later announcement retry. -func (f *BlockFetcher) deliverHeaders(peer string, req headerRequest, res *eth.Response) { - owned := []*blockAnnounce{req.announce} - if res == nil { - f.notifyFetchRelease(true, owned) - return - } - res.Done <- nil - - headers, ok := res.Res.(*eth.BlockHeadersRequest) - if !ok || headers == nil { - f.notifyFetchRelease(true, owned) - return - } - metrics.RecordPerItemDuration(blockHeaderItemDownloadTimer, res.Time, len(*headers)) - f.FilterHeaders(peer, *headers, time.Now(), req.announcedAt) - f.notifyFetchRelease(true, owned) -} - -// runBodyRequest performs one body retrieval for a batch of hashes. -func (f *BlockFetcher) runBodyRequest(peer string, req bodyRequest) { - defer f.releaseFetchSlot(peer) - - bodyFetchMeter.Mark(int64(len(req.hashes))) - - if req.fetch == nil { - f.notifyFetchRelease(false, req.announces) - return - } - resCh := make(chan *eth.Response) - - request, err := req.fetch(req.hashes, resCh) - if err != nil { - log.Debug("Failed to request bodies", "peer", peer, "hashes", req.hashes, "err", err) - f.notifyFetchRelease(false, req.announces) - - return - } - defer request.Close() - - timeout := time.NewTimer(2 * fetchTimeout) - defer timeout.Stop() - - select { - case res := <-resCh: - f.deliverBodies(peer, req, res) - case <-timeout.C: - log.Debug("BlockFetcher: body fetch timed out", "peer", peer, "hashes", req.hashes, "timeout", 2*fetchTimeout, "backoff", fetchPeerBackoff) - f.backoffFetchPeer(peer) - f.notifyFetchRelease(false, req.announces) - case <-f.quit: - } -} - -// deliverBodies filters a body response, then returns ownership of the batch. -// Hashes an empty or partial reply did not cover are still owned here; the loop -// keeps the ones that reached the import queue or witness retrieval. -func (f *BlockFetcher) deliverBodies(peer string, req bodyRequest, res *eth.Response) { - if res == nil { - f.notifyFetchRelease(false, req.announces) - return - } - res.Done <- nil - - bodies, ok := res.Res.(*eth.BlockBodiesResponse) - if !ok || bodies == nil { - f.notifyFetchRelease(false, req.announces) - return - } - // Withdrawals are ignored here, since the block fetcher is not used post-merge. - txs, uncles, _ := bodies.Unpack() - metrics.RecordPerItemDuration(blockBodyItemDownloadTimer, res.Time, len(txs)) - remaining, _ := f.FilterBodies(peer, txs, uncles, time.Now(), req.announcedAt) - // Retry omissions only when the response made progress; empty or unmatched - // responses must not create a perpetual retry loop. - f.notifyRequestRelease(blockFetchRelease{announces: req.announces, retryBodies: len(remaining) < len(txs)}) -} diff --git a/eth/fetcher/block_fetcher_schedule.go b/eth/fetcher/block_fetcher_schedule.go deleted file mode 100644 index 5f513ee3f6..0000000000 --- a/eth/fetcher/block_fetcher_schedule.go +++ /dev/null @@ -1,252 +0,0 @@ -// Copyright 2026 The go-ethereum Authors -// This file is part of the go-ethereum library. -// -// The go-ethereum library is free software: you can redistribute it and/or modify -// it under the terms of the GNU Lesser General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// The go-ethereum library is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Lesser General Public License for more details. -// -// You should have received a copy of the GNU Lesser General Public License -// along with the go-ethereum library. If not, see . - -package fetcher - -import ( - "time" - - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/log" -) - -// scheduleHeaderFetches turns expired announcements into header requests, one -// request per hash. It reports whether admission left any announcement behind. -// Runs on the fetcher loop. -func (f *BlockFetcher) scheduleHeaderFetches() bool { - requests := make(map[string][]headerRequest) - awaitingBodies := f.peersAwaitingBodies() - deferred := false - - for hash, announces := range f.announced { - announce, retry := f.prepareHeaderFetch(hash, announces, awaitingBodies) - deferred = deferred || retry - if announce == nil { - continue - } - req := f.admitHeaderRequest(hash, announce) - deferred = deferred || len(f.announced[hash]) > 0 - requests[req.announce.origin] = append(requests[req.announce.origin], req) - } - - if len(requests) > 0 { - f.fetchRetry = 0 - } - for peer, batch := range requests { - f.startHeaderRequests(peer, batch) - } - - return deferred -} - -func (f *BlockFetcher) prepareHeaderFetch(hash common.Hash, announces []*blockAnnounce, awaitingBodies map[string]bool) (*blockAnnounce, bool) { - wait := arriveTimeout - gatherSlack - if f.light { - wait = 0 - } - if time.Since(announces[0].time) <= wait { - return nil, false - } - announces[0].deferred = true - if f.fetchInProgress(hash) { - return nil, true - } - announce := f.acquireAnnouncer(announces, awaitingBodies) - if announce == nil { - return nil, true - } - // Unknown block lookups may hit disk, so reserve capacity before reading. - if !f.readyToFetch(hash) { - f.releaseFetchSlot(announce.origin) - f.dropAnnouncements(hash, announces) - return nil, false - } - return announce, false -} - -func (f *BlockFetcher) fetchInProgress(hash common.Hash) bool { - return f.fetching[hash] != nil || len(f.fetched[hash]) > 0 || f.completing[hash] != nil -} - -// peersAwaitingBodies lists the peers holding headers that still need a body -// request, computed once per pass so header admission can leave their slots -// alone. Scheduling bodies first is not enough on its own: a worker can free a -// slot between the two passes. -func (f *BlockFetcher) peersAwaitingBodies() map[string]bool { - if len(f.fetched) == 0 { - return nil - } - waiting := make(map[string]bool) - for _, announces := range f.fetched { - for _, announce := range announces { - waiting[announce.origin] = true - } - } - - return waiting -} - -// expiredFetches lists outstanding header requests whose fetch phase ran out. -func (f *BlockFetcher) expiredFetches() []common.Hash { - f.mu.RLock() - defer f.mu.RUnlock() - - expired := make([]common.Hash, 0) - for hash, announce := range f.fetching { - if time.Since(announce.time) > fetchTimeout { - expired = append(expired, hash) - } - } - - return expired -} - -// admitHeaderRequest moves an announcement into the fetching stage. The fetch -// phase clock restarts here because an announcement may have waited for -// capacity; carrying its original age over would make expiredFetches drop the -// request before any response could arrive. The announcement time travels on -// the request so latency metrics still measure from the announcement. -func (f *BlockFetcher) admitHeaderRequest(hash common.Hash, announce *blockAnnounce) headerRequest { - announcedAt := announce.time - f.consumeAnnouncement(hash, announce.origin) - announce.time = time.Now() - f.fetching[hash] = announce - - return headerRequest{hash: hash, announce: announce, fetch: announce.fetchHeader, announcedAt: announcedAt} -} - -// readyToFetch reports whether a hash still needs a header request, rather than -// already being held by the witness manager, the import queue, or the local -// chain. The caller checks active fetch stages before reserving capacity. -func (f *BlockFetcher) readyToFetch(hash common.Hash) bool { - if f.wm.isPending(hash) || f.queued[hash] != nil { - return false - } - if f.light { - return f.getHeader(hash) == nil - } - - return f.getBlock(hash) == nil -} - -// dropAnnouncements forgets a hash that no longer needs fetching, returning the -// announcement credits it holds. -func (f *BlockFetcher) dropAnnouncements(hash common.Hash, announces []*blockAnnounce) { - for _, announce := range announces { - f.announces[announce.origin]-- - if f.announces[announce.origin] <= 0 { - delete(f.announces, announce.origin) - } - } - delete(f.announced, hash) - - if f.announceChangeHook != nil { - f.announceChangeHook(hash, false) - } -} - -func (f *BlockFetcher) startHeaderRequests(peer string, batch []headerRequest) { - hashes := make([]common.Hash, len(batch)) - for i, req := range batch { - hashes[i] = req.hash - } - log.Debug("Fetching scheduled headers", "peer", peer, "list", hashes) - - go func() { - if f.fetchingHook != nil { - f.fetchingHook(hashes) - } - for _, req := range batch { - headerFetchMeter.Mark(1) - - go f.runHeaderRequest(peer, req) - } - }() -} - -// scheduleBodyFetches turns fetched headers into body requests, grouped so that -// one request per peer carries that peer's whole batch. It reports whether -// admission left any header behind. Runs on the fetcher loop. -func (f *BlockFetcher) scheduleBodyFetches() bool { - requests := make(map[string]*bodyRequest) - admitted := make(map[string]bool) - deferred := false - - for hash, announces := range f.fetched { - if f.rerouteCoolingBody(hash, announces) { - continue - } - announces[0].bodyDeferred = true - announce := f.acquireBodyAnnouncer(announces, admitted) - if announce == nil { - deferred = true - - continue - } - f.groupBodyRequest(requests, hash, announce) - } - if len(requests) > 0 { - f.completeRetry = 0 - } - f.startBodyRequests(requests, admitted) - - return deferred -} - -// groupBodyRequest moves one fetched header into the completion stage and adds -// it to its peer's batch. Keep alternative announcements until the body arrives -// so an unanswered request can restart from another origin. -func (f *BlockFetcher) groupBodyRequest(requests map[string]*bodyRequest, hash common.Hash, announce *blockAnnounce) { - announcedAt := announce.time - delete(f.fetched, hash) - - if f.wm.isPending(hash) || f.getBlock(hash) != nil { - return - } - f.completing[hash] = announce - - req := requests[announce.origin] - if req == nil { - req = &bodyRequest{fetch: announce.fetchBodies, announcedAt: announcedAt} - requests[announce.origin] = req - } - req.hashes = append(req.hashes, hash) - req.announces = append(req.announces, announce) -} - -func (f *BlockFetcher) startBodyRequests(requests map[string]*bodyRequest, admitted map[string]bool) { - // A peer admitted for a hash that turned out not to need a body holds a - // slot with nothing to send. - for peer := range admitted { - if requests[peer] == nil { - f.releaseFetchSlot(peer) - } - } - for peer, req := range requests { - f.startBodyRequest(peer, *req) - } -} - -func (f *BlockFetcher) startBodyRequest(peer string, req bodyRequest) { - log.Debug("Fetching scheduled bodies", "peer", peer, "list", req.hashes) - - go func() { - if f.completingHook != nil { - f.completingHook(req.hashes) - } - f.runBodyRequest(peer, req) - }() -} diff --git a/eth/fetcher/block_fetcher_schedule_edges_test.go b/eth/fetcher/block_fetcher_schedule_edges_test.go deleted file mode 100644 index d1760fac53..0000000000 --- a/eth/fetcher/block_fetcher_schedule_edges_test.go +++ /dev/null @@ -1,94 +0,0 @@ -// Copyright 2026 The go-ethereum Authors -// This file is part of the go-ethereum library. -// -// The go-ethereum library is free software: you can redistribute it and/or modify -// it under the terms of the GNU Lesser General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// The go-ethereum library is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Lesser General Public License for more details. -// -// You should have received a copy of the GNU Lesser General Public License -// along with the go-ethereum library. If not, see . - -package fetcher - -import ( - "testing" - "time" - - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/core/types" -) - -func TestHeaderSchedulingWaitsForAnnouncement(t *testing.T) { - p := newScheduleProbe(t) - hash := p.announce("peer", 0) - announcement := p.fetcher.announced[hash][0] - if p.fetcher.scheduleHeaderFetches() { - t.Fatal("fresh announcement marked capacity-deferred") - } - if p.fetcher.fetching[hash] != nil || p.fetcher.fetchSlotsTotal != 0 || len(p.fetcher.announced[hash]) != 1 || p.fetcher.announced[hash][0] != announcement { - t.Fatal("fresh announcement was fetched before its gather window") - } -} - -func TestHeaderSchedulingKeepsLaterStages(t *testing.T) { - for _, stage := range []string{"fetching", "fetched", "completing"} { - t.Run(stage, func(t *testing.T) { - p := newScheduleProbe(t) - f := p.fetcher - hash := p.announce("new", time.Second) - owned := &blockAnnounce{hash: hash, origin: "existing", time: time.Now()} - if stage == "fetching" { - f.fetching[hash] = owned - } else if stage == "fetched" { - f.fetched[hash] = []*blockAnnounce{owned} - } else { - f.completing[hash] = owned - } - if !f.scheduleHeaderFetches() { - t.Fatal("alternative announcement was not deferred") - } - if f.fetchSlotsTotal != 0 || len(f.announced[hash]) != 1 { - t.Fatal("duplicate announcement started another header request") - } - if stage == "fetched" && (len(f.fetched[hash]) != 1 || f.fetched[hash][0] != owned) { - t.Fatal("lost fetched ownership") - } - if stage == "completing" && f.completing[hash] != owned { - t.Fatal("lost completing ownership") - } - }) - } -} - -func TestHeaderSchedulingDropsKnownAnnouncements(t *testing.T) { - p := newScheduleProbe(t) - f := p.fetcher - hash := p.announce("peer", time.Second) - f.getBlock = func(common.Hash) *types.Block { return genesis } - f.scheduleHeaderFetches() - if _, exists := f.announces["peer"]; exists { - t.Fatal("empty per-peer announcement entry retained") - } - if len(f.announced[hash]) != 0 || f.fetchSlotsTotal != 0 { - t.Fatal("known block announcement retained or fetched") - } -} - -func TestAcquireBodyAnnouncerRefusesEmpty(t *testing.T) { - for _, announces := range [][]*blockAnnounce{nil, {}} { - f := newBlockReleaseFetcher(t) - admitted := make(map[string]bool) - if f.acquireBodyAnnouncer(announces, admitted) != nil { - t.Fatal("admitted empty body announcer set") - } - if f.fetchSlotsTotal != 0 || len(admitted) != 0 { - t.Fatal("empty announcer set reserved capacity") - } - } -} diff --git a/eth/fetcher/block_fetcher_schedule_test.go b/eth/fetcher/block_fetcher_schedule_test.go deleted file mode 100644 index edd0b59394..0000000000 --- a/eth/fetcher/block_fetcher_schedule_test.go +++ /dev/null @@ -1,248 +0,0 @@ -// Copyright 2026 The go-ethereum Authors -// This file is part of the go-ethereum library. -// -// The go-ethereum library is free software: you can redistribute it and/or modify -// it under the terms of the GNU Lesser General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// The go-ethereum library is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Lesser General Public License for more details. -// -// You should have received a copy of the GNU Lesser General Public License -// along with the go-ethereum library. If not, see . - -package fetcher - -import ( - "encoding/binary" - "fmt" - "testing" - "time" - - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/eth/protocols/eth" -) - -// scheduleProbe drives the production scheduling passes directly, so a legal -// interleaving of header completion and header refill is reproducible without -// timers. -type scheduleProbe struct { - fetcher *BlockFetcher - started chan common.Hash - next byte -} - -func newScheduleProbe(t *testing.T) *scheduleProbe { - t.Helper() - f := NewBlockFetcher(false, nil, func(common.Hash) *types.Block { return nil }, nil, nil, - func() uint64 { return 0 }, nil, nil, nil, - func(peer string) { t.Errorf("scheduling dropped peer %q", peer) }, false, false, 0, nil, nil) - t.Cleanup(func() { - select { - case <-f.quit: - default: - f.Stop() - } - f.wm.stopAndDrainTimer() - }) - - return &scheduleProbe{fetcher: f, started: make(chan common.Hash, 4*maxPeerFetchRequests)} -} - -func (p *scheduleProbe) announce(peer string, age time.Duration) common.Hash { - p.next++ - hash := common.Hash{p.next, byte(len(peer))} - announce := &blockAnnounce{ - hash: hash, - number: 1, - origin: peer, - time: time.Now().Add(-age), - fetchHeader: func(h common.Hash, _ chan *eth.Response) (*eth.Request, error) { - p.started <- h - - return ð.Request{Cancel: make(chan struct{})}, nil - }, - fetchBodies: func([]common.Hash, chan *eth.Response) (*eth.Request, error) { - return ð.Request{Cancel: make(chan struct{})}, nil - }, - } - p.fetcher.announced[hash] = []*blockAnnounce{announce} - p.fetcher.announces[peer]++ - - return hash -} - -// completeHeader mirrors the loop's fetching to fetched transition and the -// worker's slot release for one outstanding request. -func (p *scheduleProbe) completeHeader(t *testing.T, hash common.Hash) { - t.Helper() - f := p.fetcher - announce := f.fetching[hash] - if announce == nil { - t.Fatalf("no outstanding header request for %x", hash[:4]) - } - delete(f.fetching, hash) - f.fetched[hash] = []*blockAnnounce{announce} - f.releaseFetchSlot(announce.origin) -} - -func TestHeaderSchedulingYieldsToWaitingBodies(t *testing.T) { - probe := newScheduleProbe(t) - f := probe.fetcher - - for i := 0; i < maxPeerFetchRequests; i++ { - probe.announce("peer", time.Second) - } - if f.scheduleHeaderFetches() { - t.Fatal("initial batch was refused") - } - if f.fetchSlotsTotal != maxPeerFetchRequests { - t.Fatalf("slots held = %d, want %d", f.fetchSlotsTotal, maxPeerFetchRequests) - } - probe.completeHeader(t, <-probe.started) - - // A new announcement from the same peer must not reclaim the slot the - // waiting body needs, or fetched metadata grows without bound. - probe.announce("peer", time.Second) - if !f.scheduleHeaderFetches() { - t.Fatal("header scheduling refilled the slot a waiting body needs") - } - if f.fetchSlotsTotal != maxPeerFetchRequests-1 { - t.Fatalf("slots held = %d, want the freed slot left for the body", f.fetchSlotsTotal) - } - if f.scheduleBodyFetches() { - t.Fatal("body scheduling was refused the slot header scheduling yielded") - } - if len(f.completing) != 1 || len(f.fetched) != 0 { - t.Fatalf("completing=%d fetched=%d, want the body admitted and fetched drained", len(f.completing), len(f.fetched)) - } -} - -func TestHeaderSchedulingPrefersPeerWithoutWaitingBodies(t *testing.T) { - probe := newScheduleProbe(t) - f := probe.fetcher - - for i := 0; i < maxPeerFetchRequests; i++ { - probe.announce("busy", time.Second) - } - if f.scheduleHeaderFetches() { - t.Fatal("initial batch was refused") - } - probe.completeHeader(t, <-probe.started) - - // Each hash is announced by both peers. Choosing at random would land on - // the peer with waiting bodies about half the time. - const rounds = 8 - - hashes := make([]common.Hash, rounds) - for i := range hashes { - hashes[i] = probe.announce("busy", time.Second) - f.announced[hashes[i]] = append(f.announced[hashes[i]], &blockAnnounce{ - hash: hashes[i], number: 1, origin: "idle", time: time.Now().Add(-time.Second), - fetchHeader: func(h common.Hash, _ chan *eth.Response) (*eth.Request, error) { - probe.started <- h - - return ð.Request{Cancel: make(chan struct{})}, nil - }, - }) - f.announces["idle"]++ - } - if !f.scheduleHeaderFetches() { - t.Fatal("unused alternative announcers were not retained") - } - for _, hash := range hashes { - if f.fetching[hash] == nil || f.fetching[hash].origin != "idle" { - t.Fatalf("header request for %x went to %v, want the peer with no waiting bodies", hash[:4], f.fetching[hash]) - } - } -} - -func TestAdmittedHeaderRequestRefreshesFetchClock(t *testing.T) { - probe := newScheduleProbe(t) - f := probe.fetcher - - stale := fetchTimeout + time.Second - hash := probe.announce("peer", stale) - announcedAt := f.announced[hash][0].time - - req := f.admitHeaderRequest(hash, f.announced[hash][0]) - - // The loop sweeps fetching entries older than fetchTimeout, so an - // announcement that waited for capacity must not arrive already expired. - if elapsed := time.Since(f.fetching[hash].time); elapsed > fetchTimeout { - t.Fatalf("admitted request is already %v old, the expiry sweep will drop it before a response arrives", elapsed) - } - if !req.announcedAt.Equal(announcedAt) { - t.Fatalf("announcedAt = %v, want the original announcement time %v", req.announcedAt, announcedAt) - } - if req.hash != hash || req.announce != f.fetching[hash] { - t.Fatal("admitted request does not own the fetching entry") - } -} - -func TestDeferredAnnouncementSurvivesExpirySweep(t *testing.T) { - probe := newScheduleProbe(t) - f := probe.fetcher - - for i := 0; i < maxPeerFetchRequests; i++ { - if !f.acquireFetchSlot("peer") { - t.Fatal("could not fill peer slots") - } - } - probe.announce("peer", fetchTimeout+time.Second) - if !f.scheduleHeaderFetches() { - t.Fatal("full peer did not defer the announcement") - } - for i := 0; i < maxPeerFetchRequests; i++ { - f.releaseFetchSlot("peer") - } - if f.scheduleHeaderFetches() { - t.Fatal("freed peer did not admit the deferred announcement") - } - if expired := f.expiredFetches(); len(expired) != 0 { - t.Fatalf("expiry sweep drops %d freshly dispatched request(s)", len(expired)) - } -} - -func TestHeaderSchedulingStopsAtAggregateBacklog(t *testing.T) { - for _, backlog := range []int{maxTotalFetchRequests - 1, maxTotalFetchRequests, maxTotalFetchRequests + 1} { - t.Run(fmt.Sprint(backlog), func(t *testing.T) { - probe := newScheduleProbe(t) - f := probe.fetcher - - // Headers left by earlier identities; the per-peer body preference - // does not apply to the fresh announcer used below. - for i := 0; i < backlog; i++ { - var hash common.Hash - binary.BigEndian.PutUint64(hash[:8], uint64(i+1)) - f.fetched[hash] = []*blockAnnounce{{hash: hash, origin: fmt.Sprintf("prior-%d", i), time: time.Now()}} - } - hash := probe.announce("fresh", time.Second) - original := f.announced[hash][0] - - deferred := f.scheduleHeaderFetches() - if backlog < maxTotalFetchRequests { - if deferred || f.fetching[hash] != original { - t.Fatal("a fresh peer was refused below the aggregate backlog ceiling") - } - return - } - if !deferred { - t.Fatalf("admitted a header with %d hashes already waiting for bodies", backlog) - } - if f.fetching[hash] != nil || len(f.announced[hash]) != 1 || f.announced[hash][0] != original { - t.Fatal("aggregate refusal did not retain the pending announcement") - } - if f.fetchSlotsTotal != 0 { - t.Fatalf("aggregate refusal reserved %d worker slots", f.fetchSlotsTotal) - } - if len(f.fetched) != backlog { - t.Fatalf("aggregate refusal altered waiting bodies: %d, want %d", len(f.fetched), backlog) - } - }) - } -} diff --git a/eth/fetcher/block_fetcher_timeout_recovery_test.go b/eth/fetcher/block_fetcher_timeout_recovery_test.go deleted file mode 100644 index 64cea9dab1..0000000000 --- a/eth/fetcher/block_fetcher_timeout_recovery_test.go +++ /dev/null @@ -1,276 +0,0 @@ -package fetcher - -import ( - "testing" - "testing/synctest" - "time" - - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/eth/protocols/eth" -) - -func TestBlockFetchTimeoutKeepsPeerAndAllowsRetry(t *testing.T) { - hashes, blocks := makeChain(1, 42, genesis) - block := blocks[hashes[0]] - for _, headers := range []bool{true, false} { - name := "body" - if headers { - name = "header" - } - t.Run(name, func(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - tester := newTester(false) - defer tester.fetcher.Stop() - defer tester.fetcher.wm.witnessCache.Stop() - imported := make(chan struct{}, 1) - tester.fetcher.importedHook = func(*types.Header, *types.Block) { imported <- struct{}{} } - request, started := requestDelayedBlock(t, tester, blocks, block, headers) - prepareBlockTimeoutState(t, tester.fetcher, block.Hash(), headers) - awaitBlockTimeout(t, request, started) - assertBlockTimeoutReleased(t, tester.fetcher, block.Hash(), headers) - assertBlockPeerRetained(t, tester) - err := tester.fetcher.Notify("slow", block.Hash(), block.NumberU64(), time.Now(), - tester.makeHeaderFetcher("slow", blocks, 0), tester.makeBodyFetcher("slow", blocks, 0), nil) - if err != nil { - t.Fatal(err) - } - assertBlockFetchCooldown(t, tester.fetcher, imported) - select { - case <-imported: - case <-time.After(3 * time.Second): - t.Fatal("timed-out block could not be fetched again") - } - assertBlockPeerRetained(t, tester) - }) - }) - } -} - -func TestBlockBodyTimeoutPreservesOtherAnnouncements(t *testing.T) { - hashes, blocks := makeChain(1, 43, genesis) - block := blocks[hashes[0]] - tester := newTester(false) - defer tester.fetcher.Stop() - f := tester.fetcher - other := common.Hash{0xff} - announced, resume := make(chan struct{}), make(chan struct{}) - defer close(resume) - f.announceChangeHook = func(hash common.Hash, added bool) { - if hash == other && added { - close(announced) - <-resume - } - } - request, started := requestDelayedBlock(t, tester, blocks, block, false) - if err := f.Notify("slow", other, block.NumberU64(), time.Now(), nil, nil, nil); err != nil { - t.Fatal(err) - } - select { - case <-announced: - case <-time.After(time.Second): - t.Fatal("second announcement was not registered") - } - if f.completing[block.Hash()] == nil || len(f.announced[other]) != 1 || f.announces["slow"] != 1 { - t.Fatal("expected a body request and one counted announcement from the same peer") - } - // The hook pauses scheduling B while the real worker deadline for A expires. - select { - case release := <-f.fetchRelease: - if release.headers || len(release.announces) != 1 || release.announces[0] != f.completing[block.Hash()] { - t.Fatal("received a release for a different request") - } - f.releaseFetchOwnership(release) - case <-time.After(12 * time.Second): - t.Fatal("body request did not time out") - } - awaitBlockTimeout(t, request, started) - if f.completing[block.Hash()] != nil { - t.Error("timeout did not release the body request") - } - if len(f.announced[other]) != 1 || f.announces["slow"] != 1 { - t.Errorf("body timeout changed unrelated announcement accounting: count=%d, want 1", f.announces["slow"]) - } - assertBlockPeerRetained(t, tester) -} - -type delayedBlockRequest struct { - request *eth.Request - started time.Time -} - -func requestDelayedBlock(t *testing.T, tester *fetcherTester, blocks map[common.Hash]*types.Block, block *types.Block, headers bool) (*eth.Request, time.Time) { - t.Helper() - requests := make(chan delayedBlockRequest, 1) - header := tester.makeHeaderFetcher("slow", blocks, 0) - body := tester.makeBodyFetcher("slow", blocks, 0) - if headers { - header = func(common.Hash, chan *eth.Response) (*eth.Request, error) { - req := ð.Request{Cancel: make(chan struct{})} - requests <- delayedBlockRequest{request: req, started: time.Now()} - return req, nil - } - } else { - body = func([]common.Hash, chan *eth.Response) (*eth.Request, error) { - req := ð.Request{Cancel: make(chan struct{})} - requests <- delayedBlockRequest{request: req, started: time.Now()} - return req, nil - } - } - if err := tester.fetcher.Notify("slow", block.Hash(), block.NumberU64(), time.Now(), header, body, nil); err != nil { - t.Fatal(err) - } - select { - case request := <-requests: - return request.request, request.started - case <-time.After(3 * time.Second): - t.Fatal("block request was not started") - return nil, time.Time{} - } -} - -func prepareBlockTimeoutState(t *testing.T, f *BlockFetcher, hash common.Hash, headers bool) { - t.Helper() - resume := pauseBlockTimeoutLoop(t, f) - defer resume() - f.mu.Lock() - defer f.mu.Unlock() - announce := pendingBlockRelease(f, headers)[hash] - if announce == nil { - t.Fatal("request was not registered in the expected fetch stage") - } - // Isolate the worker's real deadline from the loop's legacy announcement-age sweep. - announce.time = time.Now().Add(time.Hour) -} - -func awaitBlockTimeout(t *testing.T, request *eth.Request, started time.Time) { - t.Helper() - select { - case <-request.Cancel: - case <-time.After(12 * time.Second): - t.Fatal("timed-out request was not cancelled") - } - if elapsed := time.Since(started); elapsed < 2*fetchTimeout || elapsed > 12*time.Second { - t.Fatalf("request timed out after %v, want between %v and 12s", elapsed, 2*fetchTimeout) - } -} - -func assertBlockTimeoutReleased(t *testing.T, f *BlockFetcher, hash common.Hash, headers bool) { - t.Helper() - resume := pauseBlockTimeoutLoop(t, f) - defer resume() - f.mu.RLock() - defer f.mu.RUnlock() - if pendingBlockRelease(f, headers)[hash] != nil { - t.Error("timeout did not release the matching fetch request") - } - if count := f.announces["slow"]; count != 0 { - t.Errorf("timeout left %d outstanding announcements, want 0", count) - } -} - -func pauseBlockTimeoutLoop(t *testing.T, f *BlockFetcher) func() { - t.Helper() - filter := make(chan *headerFilterTask) - select { - case f.headerFilter <- filter: - case <-time.After(time.Second): - t.Fatal("fetcher did not reach the state inspection barrier") - } - return func() { - select { - case filter <- &headerFilterTask{}: - case <-time.After(time.Second): - t.Fatal("fetcher did not resume from the state inspection barrier") - } - select { - case <-filter: - case <-time.After(time.Second): - t.Fatal("fetcher did not complete the state inspection barrier") - } - } -} - -func assertBlockPeerRetained(t *testing.T, tester *fetcherTester) { - t.Helper() - tester.lock.RLock() - defer tester.lock.RUnlock() - if tester.drops["slow"] { - t.Fatal("slow peer was disconnected") - } -} - -func TestEmptyBodyResponseAllowsReannouncement(t *testing.T) { - hashes, blocks := makeChain(1, 63, genesis) - block := blocks[hashes[0]] - if len(block.Transactions()) == 0 && len(block.Uncles()) == 0 { - t.Fatal("fixture block needs a body to reach the body fetch stage") - } - tester := newTester(false) - defer tester.fetcher.Stop() - f := tester.fetcher - - imported := make(chan struct{}, 1) - f.importedHook = func(*types.Header, *types.Block) { imported <- struct{}{} } - completing := make(chan []common.Hash, 4) - f.completingHook = func(hs []common.Hash) { completing <- hs } - - err := f.Notify("silent", block.Hash(), block.NumberU64(), time.Now(), - tester.makeHeaderFetcher("silent", blocks, -gatherSlack), emptyBodyFetcher("silent"), nil) - if err != nil { - t.Fatal(err) - } - select { - case <-completing: - case <-time.After(5 * time.Second): - t.Fatal("body request was never issued") - } - // An announcement arriving while the request is still outstanding is - // deduplicated against it, so recovery depends on the record being released - // once the empty response ends the request. - awaitCompletionReleased(t, f, block.Hash()) - err = f.Notify("healthy", block.Hash(), block.NumberU64(), time.Now(), - tester.makeHeaderFetcher("healthy", blocks, -gatherSlack), - tester.makeBodyFetcher("healthy", blocks, 0), nil) - if err != nil { - t.Fatal(err) - } - select { - case <-imported: - case <-time.After(5 * time.Second): - t.Fatal("block was not recovered after an empty body response") - } - - tester.lock.RLock() - defer tester.lock.RUnlock() - if tester.drops["silent"] { - t.Fatal("empty body response dropped the peer") - } -} - -func awaitCompletionReleased(t *testing.T, f *BlockFetcher, hash common.Hash) { - t.Helper() - deadline := time.Now().Add(5 * time.Second) - for time.Now().Before(deadline) { - f.mu.RLock() - held := f.completing[hash] != nil - f.mu.RUnlock() - if !held { - return - } - time.Sleep(5 * time.Millisecond) - } - t.Fatal("completion record was retained after the response ended the request") -} - -func emptyBodyFetcher(peer string) bodyRequesterFn { - return func(_ []common.Hash, sink chan *eth.Response) (*eth.Request, error) { - req := ð.Request{Peer: peer} - bodies := make([]*eth.BlockBody, 0) - res := ð.Response{Req: req, Res: (*eth.BlockBodiesResponse)(&bodies), Done: make(chan error, 1)} - - go func() { sink <- res }() - - return req, nil - } -} diff --git a/eth/fetcher/block_fetcher_timer.go b/eth/fetcher/block_fetcher_timer.go deleted file mode 100644 index 3f6531c98f..0000000000 --- a/eth/fetcher/block_fetcher_timer.go +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright 2026 The go-ethereum Authors -// This file is part of the go-ethereum library. -// -// The go-ethereum library is free software: you can redistribute it and/or modify -// it under the terms of the GNU Lesser General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// The go-ethereum library is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Lesser General Public License for more details. -// -// You should have received a copy of the GNU Lesser General Public License -// along with the go-ethereum library. If not, see . - -package fetcher - -import "time" - -// Fresh hashes keep their gather deadlines even while other hashes wait for -// capacity. Incoming announcements never move the existing retry deadline. -func (f *BlockFetcher) resetFetchTimer(timer *time.Timer) { - var earliest time.Time - for _, announces := range f.announced { - announce := announces[0] - deadline := f.gatherDeadline(announce) - if announce.deferred { - deadline = f.fetchRetryAt - } - if earliest.IsZero() || deadline.Before(earliest) { - earliest = deadline - } - } - f.fetchTimerAt = earliest - if !earliest.IsZero() { - timer.Reset(time.Until(earliest)) - } -} - -// New announcements only compare deadlines; scanning all retained alternatives -// on every incoming announcement would make bursts quadratic in queue size. -func (f *BlockFetcher) rearmFreshFetch(timer *time.Timer, announce *blockAnnounce) { - if f.announced[announce.hash][0] != announce { - return - } - deadline := f.gatherDeadline(announce) - if f.fetchTimerAt.IsZero() || deadline.Before(f.fetchTimerAt) { - f.fetchTimerAt = deadline - timer.Reset(time.Until(deadline)) - } -} - -func (f *BlockFetcher) gatherDeadline(announce *blockAnnounce) time.Time { - if f.light { - return announce.time.Add(lightTimeout) - } - return announce.time.Add(arriveTimeout) -} diff --git a/eth/fetcher/block_fetcher_timer_test.go b/eth/fetcher/block_fetcher_timer_test.go deleted file mode 100644 index a30a19fc6f..0000000000 --- a/eth/fetcher/block_fetcher_timer_test.go +++ /dev/null @@ -1,112 +0,0 @@ -// Copyright 2026 The go-ethereum Authors -// This file is part of the go-ethereum library. -// -// The go-ethereum library is free software: you can redistribute it and/or modify -// it under the terms of the GNU Lesser General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// The go-ethereum library is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Lesser General Public License for more details. -// -// You should have received a copy of the GNU Lesser General Public License -// along with the go-ethereum library. If not, see . - -package fetcher - -import ( - "testing" - "testing/synctest" - "time" - - "github.com/stretchr/testify/require" -) - -func TestHeaderSchedulingFreshDeadlineDuringBackoff(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - p := newScheduleProbe(t) - f := p.fetcher - stalled := p.announce("alternate", time.Second) - f.fetching[stalled] = &blockAnnounce{origin: "silent", time: time.Now()} - require.True(t, f.scheduleHeaderFetches()) - f.fetchRetry = time.Second - timer := time.NewTimer(time.Hour) - defer timer.Stop() - f.rescheduleFetch(timer, true) - retryAt := f.fetchRetryAt - time.Sleep(time.Millisecond) - fresh := p.announce("available", 0) - start := time.Now() - f.rearmFreshFetch(timer, f.announced[fresh][0]) - <-timer.C - require.Equal(t, arriveTimeout, time.Since(start)) - require.Equal(t, retryAt, f.fetchRetryAt) - require.Equal(t, time.Second, f.fetchRetry) - require.True(t, f.scheduleHeaderFetches()) - require.Equal(t, fresh, <-p.started) - require.Len(t, f.announced[stalled], 1) - }) -} - -func TestHeaderSchedulingAnnouncementPreservesRetryDeadline(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - p := newScheduleProbe(t) - f := p.fetcher - hash := p.announce("alternate", time.Second) - f.completing[hash] = &blockAnnounce{origin: "silent"} - require.True(t, f.scheduleHeaderFetches()) - timer := time.NewTimer(time.Hour) - defer timer.Stop() - f.fetchRetry = time.Second - f.rescheduleFetch(timer, true) - deadline := f.fetchRetryAt - time.Sleep(800 * time.Millisecond) - fresh := p.announce("fresh", 0) - f.rearmFreshFetch(timer, f.announced[fresh][0]) - <-timer.C - require.Equal(t, deadline, time.Now()) - }) -} - -func TestHeaderSchedulingInitialTimer(t *testing.T) { - for _, light := range []bool{false, true} { - synctest.Test(t, func(t *testing.T) { - p := newScheduleProbe(t) - f := p.fetcher - f.light = light - timer := time.NewTimer(time.Hour) - defer timer.Stop() - fresh := p.announce("fresh", 0) - start := time.Now() - f.rearmFreshFetch(timer, f.announced[fresh][0]) - <-timer.C - expected := arriveTimeout - if light { - expected = lightTimeout - } - require.Equal(t, expected, time.Since(start)) - }) - } -} - -func TestHeaderSchedulingDeferredTimer(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - p := newScheduleProbe(t) - f := p.fetcher - hash := p.announce("alternate", time.Second) - f.completing[hash] = &blockAnnounce{origin: "silent"} - require.True(t, f.scheduleHeaderFetches()) - timer := time.NewTimer(time.Hour) - defer timer.Stop() - start := time.Now() - f.rescheduleFetch(timer, true) - <-timer.C - require.Equal(t, fetchRetryInterval, time.Since(start)) - require.True(t, f.scheduleHeaderFetches()) - f.rescheduleFetch(timer, true) - <-timer.C - require.Equal(t, 3*fetchRetryInterval, time.Since(start)) - }) -} diff --git a/eth/filters/IBackend.go b/eth/filters/IBackend.go index fff06f5847..3197a74530 100644 --- a/eth/filters/IBackend.go +++ b/eth/filters/IBackend.go @@ -114,20 +114,6 @@ func (mr *MockBackendMockRecorder) BaseFee(ctx any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BaseFee", reflect.TypeOf((*MockBackend)(nil).BaseFee), ctx) } -// BeginPrivateTx mocks base method. -func (m *MockBackend) BeginPrivateTx(hash common.Hash) func(bool) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "BeginPrivateTx", hash) - ret0, _ := ret[0].(func(bool)) - return ret0 -} - -// BeginPrivateTx indicates an expected call of BeginPrivateTx. -func (mr *MockBackendMockRecorder) BeginPrivateTx(hash any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BeginPrivateTx", reflect.TypeOf((*MockBackend)(nil).BeginPrivateTx), hash) -} - // BlobBaseFee mocks base method. func (m *MockBackend) BlobBaseFee(ctx context.Context) *big.Int { m.ctrl.T.Helper() diff --git a/eth/handler.go b/eth/handler.go index 9053d9533d..a672c2e1dd 100644 --- a/eth/handler.go +++ b/eth/handler.go @@ -1105,7 +1105,8 @@ func (h *handler) rebroadcastStuckTransactions(txs types.Transactions, onBroadca // enableSyncedFeatures enables the post-sync functionalities when the initial // sync is finished. func (h *handler) enableSyncedFeatures() { - wasSynced := h.synced.Swap(true) + // Mark the local node as synced. + h.synced.Store(true) // If we were running snap sync and it finished, disable doing another // round on next sync cycle @@ -1113,20 +1114,6 @@ func (h *handler) enableSyncedFeatures() { log.Info("Snap sync complete, auto disabling") h.snapSync.Store(false) } - if wasSynced || h.disableTxPropagation { - return - } - peers := h.peers.all() - if len(peers) == 0 { - return - } - // Suppressed retries may have expired while these peers remained connected. - hashes := h.pendingTransactionHashes() - if len(hashes) == 0 { - return - } - h.wg.Add(1) - go h.recoverPendingAnnouncements(peers, hashes) } // PeerStats represents a short summary of the information known about a connected diff --git a/eth/relay/CLAUDE.md b/eth/relay/CLAUDE.md index f4e37edff4..8c7fa14e0d 100644 --- a/eth/relay/CLAUDE.md +++ b/eth/relay/CLAUDE.md @@ -77,23 +77,6 @@ eth/relay/ - **Inclusion Detection**: Uses `TxGetter` to check if transaction was included in a block (stops retry) - **Already Known Handling**: "already known" errors are treated as successful submissions -**Failure recovery:** -- A relay error can occur after local pool admission; the RPC error reports that - distinction. Exhausting producer retries does not immediately publish the - transaction; the private window still applies. -- Committed privacy markers expire 30 minutes after the last successful private - admission, checked every minute, even while the transaction remains in the pool. - Expiry makes the original payload eligible for ordinary public propagation; - private submission does not promise indefinite confidentiality. An in-flight - admission keeps its reservation until completion, and stale sweeps cannot - remove a refreshed marker. See [private transaction lifetime](../../docs/private-transactions.md). -- The sender can sign a replacement at the same nonce with a sufficient fee bump. - Submit it through the private endpoint to retain privacy, or the public endpoint - to explicitly publish that replacement. This also permits a cancellation - transaction; inclusion still depends on a producer accepting and mining it. -- The replaced transaction leaves the local pool. Its marker is cleaned up after - the grace period; later transactions still require that nonce to be mined. - ### 3. Already Known Error Handling **Concept:** diff --git a/eth/relay/private_tx_errors.go b/eth/relay/private_tx_errors.go deleted file mode 100644 index 522d544306..0000000000 --- a/eth/relay/private_tx_errors.go +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright 2026 The go-ethereum Authors -// This file is part of the go-ethereum library. -// -// The go-ethereum library is free software: you can redistribute it and/or modify -// it under the terms of the GNU Lesser General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// The go-ethereum library is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Lesser General Public License for more details. -// -// You should have received a copy of the GNU Lesser General Public License -// along with the go-ethereum library. If not, see . - -package relay - -import "errors" - -var ErrPrivateTxConflict = errors.New("transaction has private submission state; public submission refused") diff --git a/eth/relay/private_tx_store.go b/eth/relay/private_tx_store.go index 6075e298b4..42b01e7688 100644 --- a/eth/relay/private_tx_store.go +++ b/eth/relay/private_tx_store.go @@ -14,7 +14,7 @@ import ( ) const ( - privateTxTTL = 30 * time.Minute // release stalled transactions for public propagation + privateTxTTL = 10 * time.Minute // hard TTL: remove tx from store after this duration regardless privateTxGracePeriod = 2 * time.Minute // min age before txpool presence check applies sweepInterval = 1 * time.Minute // how often the sweep goroutine runs ) @@ -34,9 +34,8 @@ type PrivateTxSetter interface { type TxPoolChecker func(hash common.Hash) bool type PrivateTxStore struct { - txs map[common.Hash]time.Time // committed tx hash to last updated time - pending map[common.Hash]*privateTxReservation - mu sync.RWMutex + txs map[common.Hash]time.Time // tx hash to last updated time + mu sync.RWMutex chainEventSubFn func(ch chan<- core.ChainEvent) event.Subscription txPoolChecker TxPoolChecker @@ -45,7 +44,7 @@ type PrivateTxStore struct { txsAdded atomic.Uint64 txsPurged atomic.Uint64 // deleted by an explicit call txsDeleted atomic.Uint64 // deleted because tx got included - txsExpired atomic.Uint64 // deleted by sweep after expiry or txpool eviction + txsExpired atomic.Uint64 // deleted by sweep (txpool eviction or TTL) closeCh chan struct{} } @@ -53,7 +52,6 @@ type PrivateTxStore struct { func NewPrivateTxStore() *PrivateTxStore { store := &PrivateTxStore{ txs: make(map[common.Hash]time.Time), - pending: make(map[common.Hash]*privateTxReservation), closeCh: make(chan struct{}), } go store.report() @@ -74,7 +72,6 @@ func (s *PrivateTxStore) Purge(hash common.Hash) { defer s.mu.Unlock() delete(s.txs, hash) - delete(s.pending, hash) s.txsPurged.Add(1) } @@ -86,7 +83,7 @@ func (s *PrivateTxStore) IsTxPrivate(hash common.Hash) bool { return true } - return s.pending[hash] != nil + return false } func (s *PrivateTxStore) cleanupLoop() { @@ -118,7 +115,6 @@ func (s *PrivateTxStore) cleanup() error { s.mu.Lock() deleted := uint64(0) for _, tx := range event.Transactions { - delete(s.pending, tx.Hash()) if _, exists := s.txs[tx.Hash()]; exists { deleted++ delete(s.txs, tx.Hash()) @@ -149,7 +145,11 @@ func (s *PrivateTxStore) SetTxPoolChecker(checker TxPoolChecker) { s.txPoolChecker = checker } -// Release stalled private transactions without removing them from the pool. +// sweep periodically removes stale entries from the store. An entry is removed +// either when it exceeds the hard TTL (10 minutes by default), regardless of +// txpool state, or when it is older than privateTxGracePeriod and is no longer +// present in the local txpool, which is treated as the source of truth for +// eviction. func (s *PrivateTxStore) sweep() { ticker := time.NewTicker(sweepInterval) defer ticker.Stop() @@ -175,20 +175,22 @@ func (s *PrivateTxStore) sweepOnce() { // Snapshot under the read lock. s.mu.RLock() - checker := s.txPoolChecker entries := make([]entry, 0, len(s.txs)) for h, t := range s.txs { entries = append(entries, entry{h, t}) } s.mu.RUnlock() - // Check pool presence outside the lock. TTL expiry also releases transactions - // that remain pending after all producer retries have failed. + // Filter transactions without holding lock. An entry is removed only if the + // txpool no longer holds it and the grace period has elapsed. now := time.Now() toDelete := make([]entry, 0) for _, entry := range entries { age := now.Sub(entry.addedAt) - if privateTxExpired(age, checker, entry.hash) { + if age > privateTxTTL { + // Hard TTL: remove regardless of txpool status + toDelete = append(toDelete, entry) + } else if age > privateTxGracePeriod && s.txPoolChecker != nil && !s.txPoolChecker(entry.hash) { toDelete = append(toDelete, entry) } } @@ -196,13 +198,12 @@ func (s *PrivateTxStore) sweepOnce() { return } - // Pending admissions keep ownership; successful admissions refresh addedAt. + // Delete the entries under write lock. Only delete those whose `addedAt` time + // hasn't changed as it's possible that the tx was re-added to the pool after + // snapshot was taken. expired := uint64(0) s.mu.Lock() for _, entry := range toDelete { - if s.pending[entry.hash] != nil { - continue - } if cur, ok := s.txs[entry.hash]; ok && cur.Equal(entry.addedAt) { delete(s.txs, entry.hash) expired++ @@ -237,7 +238,3 @@ func (s *PrivateTxStore) report() { func (s *PrivateTxStore) Close() { close(s.closeCh) } - -func privateTxExpired(age time.Duration, checker TxPoolChecker, hash common.Hash) bool { - return age >= privateTxTTL || (age > privateTxGracePeriod && checker != nil && !checker(hash)) -} diff --git a/eth/relay/private_tx_store_submission.go b/eth/relay/private_tx_store_submission.go deleted file mode 100644 index a48a466298..0000000000 --- a/eth/relay/private_tx_store_submission.go +++ /dev/null @@ -1,49 +0,0 @@ -package relay - -import ( - "sync" - "time" - - "github.com/ethereum/go-ethereum/common" -) - -type privateTxReservation struct { - inflight int -} - -// Begin hides a transaction during admission. Overlapping submissions share a -// reservation so a failed call cannot expose another call's pending admission. -// The returned callback takes the result of local pool admission. -func (s *PrivateTxStore) Begin(hash common.Hash) func(bool) { - s.mu.Lock() - pending := s.pending[hash] - if pending == nil { - pending = new(privateTxReservation) - s.pending[hash] = pending - } - pending.inflight++ - s.mu.Unlock() - - var once sync.Once - return func(accepted bool) { - once.Do(func() { s.finishSubmission(hash, pending, accepted) }) - } -} - -func (s *PrivateTxStore) finishSubmission(hash common.Hash, pending *privateTxReservation, accepted bool) { - s.mu.Lock() - defer s.mu.Unlock() - - // Inclusion or an explicit purge invalidates callbacks from older attempts. - if s.pending[hash] != pending { - return - } - if accepted { - s.txs[hash] = time.Now() - s.txsAdded.Add(1) - } - pending.inflight-- - if pending.inflight == 0 { - delete(s.pending, hash) - } -} diff --git a/eth/relay/private_tx_store_submission_test.go b/eth/relay/private_tx_store_submission_test.go deleted file mode 100644 index b889827686..0000000000 --- a/eth/relay/private_tx_store_submission_test.go +++ /dev/null @@ -1,198 +0,0 @@ -package relay - -import ( - "sync" - "testing" - "time" - - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/core" - "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/event" - "github.com/stretchr/testify/require" -) - -func TestPrivateTxStoreSubmissionOutcomes(t *testing.T) { - for _, tc := range []struct { - name string - committed, first, second bool - }{ - {name: "all rejected"}, - {name: "first accepted", first: true}, - {name: "second accepted", second: true}, - {name: "both accepted", first: true, second: true}, - {name: "existing private", committed: true}, - } { - t.Run(tc.name, func(t *testing.T) { - s := NewPrivateTxStore() - defer s.Close() - hash := common.Hash{1} - if tc.committed { - s.Add(hash) - } - first, second := s.Begin(hash), s.Begin(hash) - require.True(t, s.IsTxPrivate(hash)) - first(tc.first) - require.True(t, s.IsTxPrivate(hash), "remaining admission must keep the hash private") - second(tc.second) - require.Equal(t, tc.committed || tc.first || tc.second, s.IsTxPrivate(hash)) - require.Empty(t, s.pending) - }) - } -} - -func TestPrivateTxStoreSubmissionConcurrentCompletion(t *testing.T) { - for _, accepted := range []bool{false, true} { - t.Run(map[bool]string{false: "all rejected", true: "one accepted"}[accepted], func(t *testing.T) { - s := NewPrivateTxStore() - defer s.Close() - hash := common.Hash{1} - finishes := make([]func(bool), 32) - for i := range finishes { - finishes[i] = s.Begin(hash) - } - var wg sync.WaitGroup - for i, finish := range finishes { - wg.Go(func() { finish(accepted && i == 0) }) - } - wg.Wait() - require.Equal(t, accepted, s.IsTxPrivate(hash)) - require.Empty(t, s.pending) - }) - } -} - -func TestPrivateTxStoreSubmissionFinishesOnce(t *testing.T) { - s := NewPrivateTxStore() - defer s.Close() - hash := common.Hash{1} - first, second := s.Begin(hash), s.Begin(hash) - first(false) - first(true) - require.True(t, s.IsTxPrivate(hash)) - require.Empty(t, s.txs, "repeated completion cannot commit a rejected submission") - second(false) - require.False(t, s.IsTxPrivate(hash)) - require.Empty(t, s.pending) -} - -func TestPrivateTxStoreSubmissionPreservesExplicitCommit(t *testing.T) { - s := NewPrivateTxStore() - defer s.Close() - hash := common.Hash{1} - finish := s.Begin(hash) - s.Add(hash) - finish(false) - require.True(t, s.IsTxPrivate(hash)) - require.Empty(t, s.pending) -} - -func TestPrivateTxStoreSubmissionPurgeInvalidates(t *testing.T) { - s := NewPrivateTxStore() - defer s.Close() - hash := common.Hash{1} - old := s.Begin(hash) - s.Purge(hash) - current := s.Begin(hash) - old(true) - require.Empty(t, s.txs, "completion from before purge cannot commit a later reservation") - current(false) - require.False(t, s.IsTxPrivate(hash)) - require.Empty(t, s.pending) -} - -func TestPrivateTxStoreSubmissionInclusionInvalidates(t *testing.T) { - s := NewPrivateTxStore() - defer s.Close() - tx := types.NewTx(&types.LegacyTx{Nonce: 1}) - finish := s.Begin(tx.Hash()) - var feed event.Feed - subscribed := make(chan struct{}) - s.SetchainEventSubFn(func(ch chan<- core.ChainEvent) event.Subscription { - sub := feed.Subscribe(ch) - close(subscribed) - return sub - }) - select { - case <-subscribed: - case <-time.After(time.Second): - t.Fatal("cleanup did not subscribe") - } - feed.Send(core.ChainEvent{Transactions: types.Transactions{tx}}) - require.Eventually(t, func() bool { return !s.IsTxPrivate(tx.Hash()) }, time.Second, time.Millisecond) - finish(true) - require.False(t, s.IsTxPrivate(tx.Hash()), "included submission was recreated by its callback") -} - -func TestPrivateTxStoreSubmissionProtectsSweep(t *testing.T) { - s := NewPrivateTxStore() - defer s.Close() - hash := common.Hash{1} - s.Add(hash) - s.mu.Lock() - s.txs[hash] = time.Now().Add(-time.Hour) - s.mu.Unlock() - finish := s.Begin(hash) - s.SetTxPoolChecker(func(common.Hash) bool { return false }) - s.sweepOnce() - require.True(t, s.IsTxPrivate(hash)) - require.Contains(t, s.txs, hash, "long-running admission lost its prior commitment") - finish(false) - require.True(t, s.IsTxPrivate(hash)) - s.sweepOnce() - require.False(t, s.IsTxPrivate(hash), "completed failed reservation prevented absent-marker expiry") -} - -func TestPrivateTxStoreSubmissionFailureDoesNotRefresh(t *testing.T) { - s := NewPrivateTxStore() - defer s.Close() - hash := common.Hash{1} - addedAt := time.Now().Add(-time.Hour) - s.mu.Lock() - s.txs[hash] = addedAt - s.mu.Unlock() - s.SetTxPoolChecker(func(common.Hash) bool { return false }) - for range 32 { - s.Begin(hash)(false) - } - s.mu.RLock() - current := s.txs[hash] - s.mu.RUnlock() - require.Equal(t, addedAt, current, "rejected submissions renewed the absent commitment") - s.sweepOnce() - require.False(t, s.IsTxPrivate(hash), "rejected submissions postponed absent-marker cleanup") -} - -func TestPrivateTxStoreSubmissionCommitProtectsStaleSweep(t *testing.T) { - s := NewPrivateTxStore() - defer s.Close() - hash := common.Hash{1} - s.mu.Lock() - s.txs[hash] = time.Now().Add(-11 * time.Minute) - s.mu.Unlock() - checked, release, done := make(chan struct{}), make(chan struct{}), make(chan struct{}) - defer close(release) - s.SetTxPoolChecker(func(common.Hash) bool { - close(checked) - <-release - return false - }) - go func() { - s.sweepOnce() - close(done) - }() - select { - case <-checked: - case <-time.After(time.Second): - t.Fatal("sweep did not check the pool") - } - s.Begin(hash)(true) - release <- struct{}{} - select { - case <-done: - case <-time.After(time.Second): - t.Fatal("sweep did not finish") - } - require.True(t, s.IsTxPrivate(hash), "stale pool absence removed a successful admission") - require.Zero(t, s.txsExpired.Load()) -} diff --git a/eth/relay/private_tx_store_test.go b/eth/relay/private_tx_store_test.go index 500dcd18da..d3dfb04a7a 100644 --- a/eth/relay/private_tx_store_test.go +++ b/eth/relay/private_tx_store_test.go @@ -1,7 +1,6 @@ package relay import ( - "fmt" "math/big" "sync" "testing" @@ -206,139 +205,74 @@ func TestPrivateTxStoreCleanup(t *testing.T) { require.Equal(t, uint64(1), store.txsDeleted.Load(), "expected txsDeleted metric to be 1") } -func TestPrivateTxStoreSweepPoolRetention(t *testing.T) { +// TestPrivateTxStoreSweepTxPoolCheck tests that the sweep removes entries +// that are no longer in the txpool (after the grace period). +func TestPrivateTxStoreSweepTxPoolCheck(t *testing.T) { t.Parallel() - for _, test := range []struct { - name string - age time.Duration - checker bool - inPool bool - wantPrivate bool - }{ - {"fresh absent", time.Minute, true, false, true}, - {"absent past grace", 3 * time.Minute, true, false, false}, - {"absent past former TTL", 11 * time.Minute, true, false, false}, - {"present past former TTL", 11 * time.Minute, true, true, true}, - {"present before TTL", privateTxTTL - time.Minute, true, true, true}, - {"present past TTL", privateTxTTL + time.Minute, true, true, false}, - {"unknown past TTL", privateTxTTL + time.Minute, false, false, false}, - {"unknown past grace", 3 * time.Minute, false, false, true}, - {"unknown past former TTL", 11 * time.Minute, false, false, true}, - } { - t.Run(test.name, func(t *testing.T) { - store := NewPrivateTxStore() - defer store.Close() - hash := common.HexToHash("0x1") - store.Add(hash) - store.mu.Lock() - store.txs[hash] = time.Now().Add(-test.age) - store.mu.Unlock() - if test.checker { - store.SetTxPoolChecker(func(common.Hash) bool { return test.inPool }) - } - store.sweepOnce() - require.Equal(t, test.wantPrivate, store.IsTxPrivate(hash)) - if test.wantPrivate { - require.Zero(t, store.txsExpired.Load()) - } else { - require.Equal(t, uint64(1), store.txsExpired.Load()) - } - }) - } -} -func TestPrivateTxStoreSweepCheckerAvailability(t *testing.T) { - t.Parallel() store := NewPrivateTxStore() defer store.Close() - hash := common.HexToHash("0x1") - store.Add(hash) + + hash1 := common.HexToHash("0x1") // in pool + hash2 := common.HexToHash("0x2") // NOT in pool, past grace period + hash3 := common.HexToHash("0x3") // NOT in pool, but too fresh (within grace period) + + store.Add(hash1) + store.Add(hash2) + store.Add(hash3) + + // Backdate hash1 and hash2 past the grace period store.mu.Lock() - store.txs[hash] = time.Now().Add(-11 * time.Minute) + store.txs[hash1] = time.Now().Add(-(privateTxGracePeriod + time.Minute)) + store.txs[hash2] = time.Now().Add(-(privateTxGracePeriod + time.Minute)) + // hash3 stays fresh (within grace period) store.mu.Unlock() + // Set txpool checker: only hash1 is in the pool + poolSet := map[common.Hash]bool{hash1: true} + store.SetTxPoolChecker(func(hash common.Hash) bool { + return poolSet[hash] + }) + + // Exercise the actual sweep code path used by the background goroutine store.sweepOnce() - require.True(t, store.IsTxPrivate(hash)) - store.SetTxPoolChecker(func(common.Hash) bool { return true }) - store.sweepOnce() - require.True(t, store.IsTxPrivate(hash)) - store.SetTxPoolChecker(nil) - store.sweepOnce() - require.True(t, store.IsTxPrivate(hash)) - store.SetTxPoolChecker(func(common.Hash) bool { return false }) - store.sweepOnce() - require.False(t, store.IsTxPrivate(hash)) - require.Equal(t, uint64(1), store.txsExpired.Load()) -} -func TestPrivateTxStoreSweepPreservesRefresh(t *testing.T) { - t.Parallel() - for name, purge := range map[string]bool{"refresh": false, "purge and re-add": true} { - t.Run(name, func(t *testing.T) { - store := NewPrivateTxStore() - defer store.Close() - hash := common.HexToHash("0x1") - store.Add(hash) - store.mu.Lock() - store.txs[hash] = time.Now().Add(-3 * time.Minute) - store.mu.Unlock() - - checked, release, done := make(chan struct{}), make(chan struct{}), make(chan struct{}) - defer close(release) - store.SetTxPoolChecker(func(common.Hash) bool { - close(checked) - <-release - return false - }) - go func() { - store.sweepOnce() - close(done) - }() - select { - case <-checked: - case <-time.After(time.Second): - t.Fatal("sweep did not check the pool") - } - if purge { - store.Purge(hash) - } - store.Add(hash) - release <- struct{}{} - select { - case <-done: - case <-time.After(time.Second): - t.Fatal("sweep did not finish") - } - require.True(t, store.IsTxPrivate(hash)) - require.Zero(t, store.txsExpired.Load()) - }) - } + require.True(t, store.IsTxPrivate(hash1), "hash1 should remain (still in pool)") + require.False(t, store.IsTxPrivate(hash2), "hash2 should be removed (not in pool, past grace)") + require.True(t, store.IsTxPrivate(hash3), "hash3 should remain (within grace period)") + require.Equal(t, uint64(1), store.txsExpired.Load(), "expected 1 txsExpired") } -func TestPrivateTxStoreSweepConcurrentChecker(t *testing.T) { +// TestPrivateTxStoreSweepTTL tests that the sweep goroutine removes entries +// older than the TTL. +func TestPrivateTxStoreSweepTTL(t *testing.T) { t.Parallel() + store := NewPrivateTxStore() defer store.Close() - hash := common.HexToHash("0x1") - store.Add(hash) + + hash1 := common.HexToHash("0x1") + hash2 := common.HexToHash("0x2") + hash3 := common.HexToHash("0x3") + + store.Add(hash1) + store.Add(hash2) + store.Add(hash3) + + // Manually backdate hash1 and hash2 to be older than TTL store.mu.Lock() - store.txs[hash] = time.Now().Add(-3 * time.Minute) + store.txs[hash1] = time.Now().Add(-(privateTxTTL + time.Minute)) + store.txs[hash2] = time.Now().Add(-(privateTxTTL + 2*time.Minute)) + // hash3 stays fresh store.mu.Unlock() - var wg sync.WaitGroup - wg.Add(1) - go func() { - defer wg.Done() - for range 1000 { - store.SetTxPoolChecker(func(common.Hash) bool { return true }) - store.SetTxPoolChecker(nil) - } - }() - for range 1000 { - store.sweepOnce() - } - wg.Wait() - require.True(t, store.IsTxPrivate(hash)) + // Exercise the actual sweep code path used by the background goroutine + store.sweepOnce() + + require.False(t, store.IsTxPrivate(hash1), "hash1 should be removed after TTL") + require.False(t, store.IsTxPrivate(hash2), "hash2 should be removed after TTL") + require.True(t, store.IsTxPrivate(hash3), "hash3 should still be present (not expired)") + require.Equal(t, uint64(2), store.txsExpired.Load(), "expected 2 txsExpired") } // TestPrivateTxStoreSetTxPoolChecker tests the SetTxPoolChecker method. @@ -354,24 +288,3 @@ func TestPrivateTxStoreSetTxPoolChecker(t *testing.T) { store.SetTxPoolChecker(checker) require.NotNil(t, store.txPoolChecker, "txPoolChecker should be set after calling SetTxPoolChecker") } - -func TestPrivateTxExpiryBoundary(t *testing.T) { - for _, age := range []time.Duration{privateTxTTL - time.Nanosecond, privateTxTTL, privateTxTTL + time.Nanosecond} { - for _, present := range []bool{false, true} { - t.Run(fmt.Sprintf("%s/present=%t", age, present), func(t *testing.T) { - checker := TxPoolChecker(nil) - if present { - checker = func(common.Hash) bool { return true } - } - require.Equal(t, age >= privateTxTTL, privateTxExpired(age, checker, common.Hash{1})) - }) - } - } -} - -func TestPrivateTxGraceBoundary(t *testing.T) { - checker := func(common.Hash) bool { return false } - for _, age := range []time.Duration{privateTxGracePeriod - time.Nanosecond, privateTxGracePeriod, privateTxGracePeriod + time.Nanosecond} { - require.Equal(t, age > privateTxGracePeriod, privateTxExpired(age, checker, common.Hash{1})) - } -} diff --git a/eth/relay/relay.go b/eth/relay/relay.go index 414a885713..ed1ce4dd52 100644 --- a/eth/relay/relay.go +++ b/eth/relay/relay.go @@ -64,13 +64,6 @@ func (s *RelayService) RecordPrivateTx(hash common.Hash) { } } -func (s *RelayService) BeginPrivateTx(hash common.Hash) func(bool) { - if s.privateTxStore != nil { - return s.privateTxStore.Begin(hash) - } - return func(bool) {} -} - func (s *RelayService) PurgePrivateTx(hash common.Hash) { if s.privateTxStore != nil { s.privateTxStore.Purge(hash) @@ -146,7 +139,20 @@ func (s *RelayService) SubmitPreconfTransactionSync(ctx context.Context, tx *typ } // SubmitPrivateTransaction submits a private transaction to block producers. -// Producer rejection cannot release privacy while the local pool retains the transaction. +// Note: We deliberately do NOT purge the private-tx hash from the store on BP +// rejection. Various types of tx rejections are possible. +// - Permanent rejection (i.e. tx which will always be rejected). As the tx has +// already passed the relay's local txpool validation, invalid txs are already +// filtered out. +// - Permanent rejection due to BP config (e.g. tx fee cap set by BP) which will +// lead to relay accepting the tx but BPs rejecting it. Such instances should be +// logged but as they can't be fixed immediately by the relay, they'll be pruned +// after the default private tx store timeout. +// - Transient rejection due to reorg or nonce race. The relay's local pool will +// evict the tx eventually once it catches up with the txpool-aware sweep. +// - Transient rejection due to BPs current mempool state (e.g. pool full). This +// will be handled by local re-submissions in BPs. If the tx still remains invalid +// it will be evicted after the default private tx store timeout. func (s *RelayService) SubmitPrivateTransaction(tx *types.Transaction) error { if s.txRelay == nil { return fmt.Errorf("request dropped: %w", errRelayNotConfigured) diff --git a/eth/relay/relay_private_submission_test.go b/eth/relay/relay_private_submission_test.go deleted file mode 100644 index a47c03e994..0000000000 --- a/eth/relay/relay_private_submission_test.go +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright 2026 The go-ethereum Authors -// This file is part of the go-ethereum library. -// -// The go-ethereum library is free software: you can redistribute it and/or modify -// it under the terms of the GNU Lesser General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// The go-ethereum library is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Lesser General Public License for more details. -// -// You should have received a copy of the GNU Lesser General Public License -// along with the go-ethereum library. If not, see . - -package relay - -import ( - "testing" - "time" - - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/core/types" -) - -func TestPrivateTxRelayReservation(t *testing.T) { - for _, enabled := range []bool{false, true} { - t.Run(map[bool]string{false: "disabled", true: "enabled"}[enabled], func(t *testing.T) { - s := &RelayService{} - if enabled { - s.privateTxStore = NewPrivateTxStore() - defer s.privateTxStore.Close() - } - tx := types.NewTx(&types.LegacyTx{}) - finish := s.BeginPrivateTx(tx.Hash()) - if finish == nil { - t.Fatal("missing completion callback") - } - if enabled && !s.privateTxStore.IsTxPrivate(tx.Hash()) { - t.Fatal("submission was visible before admission completed") - } - finish(true) - finish(false) - if enabled && !s.privateTxStore.IsTxPrivate(tx.Hash()) { - t.Fatal("accepted transaction lost its private marker") - } - }) - } -} - -func TestPrivateTxRelayFailureExpires(t *testing.T) { - store := NewPrivateTxStore() - defer store.Close() - s := &RelayService{privateTxStore: store} - tx := types.NewTx(&types.LegacyTx{}) - s.BeginPrivateTx(tx.Hash())(true) - if err := s.SubmitPrivateTransaction(tx); err == nil { - t.Fatal("unconfigured relay unexpectedly accepted the transaction") - } - store.mu.Lock() - store.txs[tx.Hash()] = time.Now().Add(-11 * time.Minute) - store.mu.Unlock() - store.SetTxPoolChecker(func(hash common.Hash) bool { return hash == tx.Hash() }) - store.sweepOnce() - if !store.IsTxPrivate(tx.Hash()) { - t.Fatal("relay failure released the transaction before the private window ended") - } - - store.mu.Lock() - store.txs[tx.Hash()] = time.Now().Add(-privateTxTTL - time.Minute) - store.mu.Unlock() - store.sweepOnce() - if s.GetPrivateTxGetter().IsTxPrivate(tx.Hash()) { - t.Fatal("expired pool-resident transaction is still hidden from public propagation") - } -} diff --git a/eth/sync.go b/eth/sync.go index 24bdf79365..3db5cc5476 100644 --- a/eth/sync.go +++ b/eth/sync.go @@ -37,24 +37,22 @@ const ( // syncTransactions starts sending all currently pending transactions to the given peer. func (h *handler) syncTransactions(p *eth.Peer) { - if hashes := h.pendingTransactionHashes(); len(hashes) > 0 { - p.AsyncSendPooledTransactionHashes(hashes) - } -} - -func (h *handler) pendingTransactionHashes() []common.Hash { var hashes []common.Hash for _, batch := range h.txpool.Pending(txpool.PendingFilter{BlobTxs: false}, nil) { for _, tx := range batch { - resolved := tx.Resolve() - if resolved == nil || resolved.GetOptions() != nil || - (h.privateTxGetter != nil && h.privateTxGetter.IsTxPrivate(tx.Hash)) { + if h.privateTxGetter != nil && h.privateTxGetter.IsTxPrivate(tx.Hash) { + continue + } + if resolved := tx.Resolve(); resolved == nil || resolved.GetOptions() != nil { continue } hashes = append(hashes, tx.Hash) } } - return hashes + if len(hashes) == 0 { + return + } + p.AsyncSendPooledTransactionHashes(hashes) } // chainSyncer coordinates blockchain sync components. diff --git a/eth/sync_completion.go b/eth/sync_completion.go deleted file mode 100644 index 45e3f7864b..0000000000 --- a/eth/sync_completion.go +++ /dev/null @@ -1,88 +0,0 @@ -// Copyright 2026 The go-ethereum Authors -// This file is part of the go-ethereum library. -// -// The go-ethereum library is free software: you can redistribute it and/or modify -// it under the terms of the GNU Lesser General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// The go-ethereum library is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Lesser General Public License for more details. -// -// You should have received a copy of the GNU Lesser General Public License -// along with the go-ethereum library. If not, see . - -package eth - -import ( - "time" - - "github.com/ethereum/go-ethereum/common" -) - -const ( - syncAnnouncementBatch = 256 - syncAnnouncementRetry = 100 * time.Millisecond -) - -// One worker owns a shared, pool-bounded snapshot and one cursor per peer. -// Queue rejection does not consume the cursor, even after rebroadcast expiry. -// The worker exits on completion, peer departure, or handler shutdown. -func (h *handler) recoverPendingAnnouncements(peers []*ethPeer, hashes []common.Hash) { - defer h.wg.Done() - pending := make(map[*ethPeer]int, len(peers)) - for _, peer := range peers { - pending[peer] = 0 - } - timer := time.NewTimer(0) - defer timer.Stop() - for len(pending) > 0 { - select { - case <-h.quitSync: - return - case <-timer.C: - } - h.retryPendingAnnouncements(pending, hashes) - timer.Reset(syncAnnouncementRetry) - } -} - -func (h *handler) retryPendingAnnouncements(pending map[*ethPeer]int, hashes []common.Hash) { - for peer, offset := range pending { - if h.peers.peer(peer.ID()) != peer { - delete(pending, peer) - continue - } - offset += h.queuePendingAnnouncements(peer, hashes[offset:]) - if offset == len(hashes) { - delete(pending, peer) - } else { - pending[peer] = offset - } - } -} - -func (h *handler) queuePendingAnnouncements(peer *ethPeer, hashes []common.Hash) int { - hashes = hashes[:min(len(hashes), syncAnnouncementBatch)] - batch := make([]common.Hash, 0, len(hashes)) - offsets := make([]int, 0, len(hashes)) - for i, hash := range hashes { - tx := h.txpool.Get(hash) - if tx == nil || tx.GetOptions() != nil || (h.privateTxGetter != nil && h.privateTxGetter.IsTxPrivate(hash)) { - continue - } - batch = append(batch, hash) - offsets = append(offsets, i) - } - if len(batch) == 0 { - return len(hashes) - } - // The propagation queue retains a prefix; retry from its first rejected hash. - accepted := peer.QueuePooledTransactionHashes(batch) - if len(accepted) < len(batch) { - return offsets[len(accepted)] - } - return len(hashes) -} diff --git a/eth/sync_completion_retry_test.go b/eth/sync_completion_retry_test.go deleted file mode 100644 index 9703b9c6b9..0000000000 --- a/eth/sync_completion_retry_test.go +++ /dev/null @@ -1,224 +0,0 @@ -// Copyright 2026 The go-ethereum Authors -// This file is part of the go-ethereum library. -// -// The go-ethereum library is free software: you can redistribute it and/or modify -// it under the terms of the GNU Lesser General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// The go-ethereum library is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Lesser General Public License for more details. -// -// You should have received a copy of the GNU Lesser General Public License -// along with the go-ethereum library. If not, see . - -package eth - -import ( - "math/big" - "testing" - "testing/synctest" - "time" - - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/eth/protocols/eth" - "github.com/ethereum/go-ethereum/eth/relay" - "github.com/ethereum/go-ethereum/p2p" - "github.com/ethereum/go-ethereum/params" -) - -func TestEnableSyncedFeaturesRetriesFullQueue(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - h, target := syncCompletionRecoveryFixture(t) - prepareSyncRetryShutdown(t, h) - filler, err := types.SignTx(types.NewTransaction(1, testAddr, big.NewInt(1), 21000, - big.NewInt(params.BorDefaultTxPoolPriceLimit), nil), types.HomesteadSigner{}, testKey) - if err != nil { - t.Fatal(err) - } - if err := h.txpool.Add(types.Transactions{filler}, true)[0]; err != nil { - t.Fatal(err) - } - time.Sleep(3 * time.Second) - synctest.Wait() - slow, sink := syncCompletionPeer(t, h, eth.ETH69, 1) - fillSyncAnnouncementQueue(t, slow, filler.Hash()) - healthy, _ := syncCompletionPeer(t, h, eth.ETH69, 2) - h.enableSyncedFeatures() - synctest.Wait() - if slow.KnownTransaction(target.Hash()) || !healthy.KnownTransaction(target.Hash()) { - t.Fatal("queue rejection was not isolated to the saturated peer") - } - seen := readSyncRetryTarget(sink, target.Hash()) - time.Sleep(3 * syncAnnouncementRetry) - synctest.Wait() - select { - case err := <-seen: - if err != nil { - t.Fatal(err) - } - default: - t.Fatal("expired pending transaction was lost after the queue recovered") - } - h.wg.Wait() - }) -} - -func TestEnableSyncedFeaturesRetryStops(t *testing.T) { - for _, shutdown := range []bool{false, true} { - t.Run(map[bool]string{false: "peer departure", true: "shutdown"}[shutdown], func(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - h, pool, txs := syncCompletionFixture() - prepareSyncRetryShutdown(t, h) - filler := types.NewTx(&types.LegacyTx{Nonce: 99}) - pool.pool[filler.Hash()] = filler - peer, _ := syncCompletionPeer(t, h, eth.ETH69, 1) - fillSyncAnnouncementQueue(t, peer, filler.Hash()) - h.enableSyncedFeatures() - synctest.Wait() - if shutdown { - close(h.quitSync) - } else if err := h.peers.unregisterPeer(peer.ID()); err != nil { - t.Fatal(err) - } - time.Sleep(syncAnnouncementRetry) - h.wg.Wait() - if peer.KnownTransaction(txs[0].Hash()) { - t.Fatal("rejected transaction was marked accepted") - } - }) - }) - } -} - -func TestEnableSyncedFeaturesRetryRefilters(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - h, pool, txs := syncCompletionFixture() - prepareSyncRetryShutdown(t, h) - private := relay.NewPrivateTxStore() - defer private.Close() - private.Add(txs[2].Hash()) - h.privateTxGetter = private - filler := types.NewTx(&types.LegacyTx{Nonce: 99}) - pool.pool[filler.Hash()] = filler - peer, _ := syncCompletionPeer(t, h, eth.ETH69, 1) - fillSyncAnnouncementQueue(t, peer, filler.Hash()) - h.enableSyncedFeatures() - synctest.Wait() - private.Add(txs[0].Hash()) - pool.lock.Lock() - delete(pool.pool, filler.Hash()) - pool.lock.Unlock() - time.Sleep(syncAnnouncementRetry) - h.wg.Wait() - if peer.KnownTransaction(txs[0].Hash()) { - t.Fatal("retry announced a transaction that became private") - } - }) -} - -func TestEnableSyncedFeaturesRetryPartialBatch(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - h, pool, txs := syncCompletionFixture() - filler := types.NewTx(&types.LegacyTx{Nonce: 99}) - last := types.NewTx(&types.LegacyTx{Nonce: 100}) - pool.pool[filler.Hash()], pool.pool[last.Hash()] = filler, last - peer, sink := syncCompletionPeer(t, h, eth.ETH69, 1) - fillSyncAnnouncementQueue(t, peer, filler.Hash()) - expectSyncCompletionAnnouncement(t, sink, filler) - synctest.Wait() - // The next packet now owns the previous full queue. Leave one slot - // in the replacement queue for the first eligible snapshot entry. - fill := make([]common.Hash, 16383) - for i := range fill { - fill[i] = filler.Hash() - } - if len(peer.QueuePooledTransactionHashes(fill)) != len(fill) { - t.Fatal("could not leave exactly one queue slot") - } - hashes := []common.Hash{txs[1].Hash(), txs[0].Hash(), txs[2].Hash(), last.Hash()} - if offset := h.queuePendingAnnouncements(h.peers.peer(peer.ID()), hashes); offset != 3 { - t.Fatalf("partial acceptance advanced by %d, want first rejected index 3", offset) - } - if !peer.KnownTransaction(txs[0].Hash()) || peer.KnownTransaction(last.Hash()) || peer.KnownTransaction(txs[2].Hash()) { - t.Fatal("partial acceptance marked the wrong transactions known") - } - }) -} - -func TestEnableSyncedFeaturesRetryBatchLimit(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - h, pool, _ := syncCompletionFixture() - peer, _ := syncCompletionPeer(t, h, eth.ETH69, 1) - hashes := make([]common.Hash, syncAnnouncementBatch+1) - for i := range hashes { - tx := types.NewTx(&types.LegacyTx{Nonce: uint64(i + 10)}) - pool.pool[tx.Hash()] = tx - hashes[i] = tx.Hash() - } - offset := h.queuePendingAnnouncements(h.peers.peer(peer.ID()), hashes) - if offset != 256 || peer.KnownTransaction(hashes[256]) { - t.Fatal("one retry exceeded the per-peer batch bound") - } - if next := h.queuePendingAnnouncements(h.peers.peer(peer.ID()), hashes[offset:]); next != 1 || !peer.KnownTransaction(hashes[256]) { - t.Fatal("next batch did not announce the remainder") - } - }) -} - -func prepareSyncRetryShutdown(t *testing.T, h *handler) { - t.Helper() - h.quitSync = make(chan struct{}) - t.Cleanup(func() { - select { - case <-h.quitSync: - default: - close(h.quitSync) - } - h.wg.Wait() - }) -} - -func fillSyncAnnouncementQueue(t *testing.T, peer *eth.Peer, filler common.Hash) { - t.Helper() - if len(peer.QueuePooledTransactionHashes([]common.Hash{filler})) != 1 { - t.Fatal("initial announcement was rejected") - } - synctest.Wait() - hashes := make([]common.Hash, 65536) - for i := range hashes { - hashes[i] = filler - } - accepted := peer.QueuePooledTransactionHashes(hashes) - if len(accepted) == 0 || len(accepted) == len(hashes) || len(peer.QueuePooledTransactionHashes(hashes[:1])) != 0 { - t.Fatal("failed to saturate the announcement queue") - } -} - -func readSyncRetryTarget(sink *p2p.MsgPipeRW, target common.Hash) <-chan error { - seen := make(chan error, 1) - go func() { - for { - msg, err := sink.ReadMsg() - if err != nil { - seen <- err - return - } - var packet eth.NewPooledTransactionHashesPacket - if err := msg.Decode(&packet); err != nil { - seen <- err - return - } - for _, hash := range packet.Hashes { - if hash == target { - seen <- nil - return - } - } - } - }() - return seen -} diff --git a/eth/sync_completion_test.go b/eth/sync_completion_test.go deleted file mode 100644 index ea8b51bb06..0000000000 --- a/eth/sync_completion_test.go +++ /dev/null @@ -1,267 +0,0 @@ -// Copyright 2026 The go-ethereum Authors -// This file is part of the go-ethereum library. -// -// The go-ethereum library is free software: you can redistribute it and/or modify -// it under the terms of the GNU Lesser General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// The go-ethereum library is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Lesser General Public License for more details. -// -// You should have received a copy of the GNU Lesser General Public License -// along with the go-ethereum library. If not, see . - -package eth - -import ( - "math/big" - "sync" - "sync/atomic" - "testing" - "testing/synctest" - "time" - - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/consensus/ethash" - "github.com/ethereum/go-ethereum/core" - "github.com/ethereum/go-ethereum/core/rawdb" - "github.com/ethereum/go-ethereum/core/txpool" - "github.com/ethereum/go-ethereum/core/txpool/legacypool" - "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/eth/protocols/eth" - "github.com/ethereum/go-ethereum/p2p" - "github.com/ethereum/go-ethereum/p2p/enode" - "github.com/ethereum/go-ethereum/params" -) - -func TestEnableSyncedFeaturesAnnouncesExpiredPending(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - h, tx := syncCompletionRecoveryFixture(t) - peer, sink := syncCompletionPeer(t, h, eth.ETH69, 1) - h.syncTransactions(peer) - expectSyncCompletionAnnouncement(t, sink, tx) - pool := h.txpool.(*txpool.TxPool) - events := make(chan core.StuckTxsEvent, 1) - sub := pool.SubscribeRebroadcastTransactionsWithAcknowledgement(events) - defer sub.Unsubscribe() - time.Sleep(time.Second) - synctest.Wait() - select { - case event := <-events: - if len(event.Txs) != 1 || event.Txs[0].Hash() != tx.Hash() { - t.Fatal("unexpected pending transaction selected") - } - if h.rebroadcastStuckTransactions(event.Txs, pool.RebroadcastAcknowledgement(event.Txs)) { - t.Fatal("initial sync must suppress periodic rebroadcast") - } - // Model an actual queue offer before its retry window expires during sync. - h.txAnnouncementOnly = true - h.peers.ForgetTransactions([]common.Hash{tx.Hash()}) - if !h.broadcastTransactions(event.Txs, pool.RebroadcastAcknowledgement(event.Txs), pool.RebroadcastAttempt(event.Txs)) { - t.Fatal("fixture must offer the transaction to a peer queue") - } - expectSyncCompletionAnnouncement(t, sink, tx) - default: - t.Fatal("missing first rebroadcast attempt") - } - time.Sleep(2 * time.Second) - synctest.Wait() - select { - case <-events: - t.Fatal("expired attempt must not remain eligible for periodic rebroadcast") - default: - } - if !pool.Has(tx.Hash()) || !peer.KnownTransaction(tx.Hash()) { - t.Fatal("test requires a retained transaction already announced on this connection") - } - h.enableSyncedFeatures() - expectSyncCompletionAnnouncement(t, sink, tx) - }) -} - -func TestEnableSyncedFeaturesPendingFilters(t *testing.T) { - for _, test := range []struct { - name, remove string - disabled bool - want bool - }{ - {name: "public", want: true}, - {name: "filtered only", remove: "public"}, - {name: "empty", remove: "all"}, - {name: "disabled", disabled: true}, - } { - t.Run(test.name, func(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - h, pool, txs := syncCompletionFixture() - h.disableTxPropagation = test.disabled - if test.remove == "public" { - delete(pool.pool, txs[0].Hash()) - } else if test.remove == "all" { - clear(pool.pool) - } - peer, sink := syncCompletionPeer(t, h, eth.ETH69, 1) - h.snapSync.Store(true) - h.enableSyncedFeatures() - synctest.Wait() - if !h.synced.Load() || h.snapSync.Load() { - t.Fatal("sync completion did not preserve the sync state transition") - } - if peer.KnownTransaction(txs[0].Hash()) != test.want || peer.KnownTransaction(txs[1].Hash()) || peer.KnownTransaction(txs[2].Hash()) { - t.Fatal("sync completion announced an unexpected transaction set") - } - if test.want { - expectSyncCompletionAnnouncement(t, sink, txs[0]) - } else { - expectNoSyncCompletionAnnouncement(t, sink, txs[0]) - } - if test.disabled && pool.snapshots.Load() != 0 { - t.Fatal("disabled propagation read the pending pool") - } - }) - }) - } -} - -func TestEnableSyncedFeaturesSharesSnapshotOnce(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - h, pool, txs := syncCompletionFixture() - var sinks []*p2p.MsgPipeRW - for i, protocol := range eth.ProtocolVersions { - _, sink := syncCompletionPeer(t, h, protocol, byte(i+1)) - sinks = append(sinks, sink) - } - var wg sync.WaitGroup - for range 4 { - wg.Go(h.enableSyncedFeatures) - } - wg.Wait() - for _, sink := range sinks { - expectSyncCompletionAnnouncement(t, sink, txs[0]) - } - h.snapSync.Store(true) - h.enableSyncedFeatures() - if pool.snapshots.Load() != 1 || h.snapSync.Load() { - t.Fatal("repeated sync completion must preserve snap state without another pool snapshot") - } - for _, sink := range sinks { - expectNoSyncCompletionAnnouncement(t, sink, txs[0]) - } - }) -} - -type syncCompletionPool struct { - *testTxPool - snapshots atomic.Int32 -} - -func (p *syncCompletionPool) Pending(filter txpool.PendingFilter, interrupt *atomic.Bool) map[common.Address][]*txpool.LazyTransaction { - p.snapshots.Add(1) - return p.testTxPool.Pending(filter, interrupt) -} - -func syncCompletionFixture() (*handler, *syncCompletionPool, types.Transactions) { - pool := &syncCompletionPool{testTxPool: newTestTxPool()} - var txs types.Transactions - for nonce := range uint64(3) { - tx := types.NewTx(&types.LegacyTx{Nonce: nonce}) - pool.pool[tx.Hash()] = tx - txs = append(txs, tx) - } - txs[1].PutOptions(new(types.OptionsPIP15)) - h := &handler{txpool: pool, peers: newPeerSet(), privateTxGetter: &PrivateTxStore{ - store: map[common.Hash]struct{}{txs[2].Hash(): {}}, - }} - return h, pool, txs -} - -func syncCompletionRecoveryFixture(t *testing.T) (*handler, *types.Transaction) { - t.Helper() - db := rawdb.NewMemoryDatabase() - t.Cleanup(func() { - if err := db.Close(); err != nil { - t.Error(err) - } - }) - genesis := &core.Genesis{Config: params.TestChainConfig, Alloc: types.GenesisAlloc{ - testAddr: {Balance: big.NewInt(1e18)}, - }} - chain, err := core.NewBlockChain(db, genesis, ethash.NewFaker(), nil) - if err != nil { - t.Fatal(err) - } - t.Cleanup(chain.Stop) - config := legacypool.DefaultConfig - config.Journal, config.NoLocals = "", true - config.RebroadcastInterval, config.RebroadcastMaxAge = time.Second, time.Hour - pool, err := txpool.New(1, chain, []txpool.SubPool{legacypool.New(config, chain)}) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { - if err := pool.Close(); err != nil { - t.Error(err) - } - }) - tx, err := types.SignTx(types.NewTransaction(0, testAddr, big.NewInt(1), 21000, big.NewInt(params.BorDefaultTxPoolPriceLimit), nil), types.HomesteadSigner{}, testKey) - if err != nil { - t.Fatal(err) - } - tx.SetTime(time.Now().Add(-2 * time.Hour)) - if err := pool.Add(types.Transactions{tx}, true)[0]; err != nil { - t.Fatal(err) - } - return &handler{txpool: pool, peers: newPeerSet(), chain: chain}, tx -} - -func syncCompletionPeer(t *testing.T, h *handler, protocol uint, id byte) (*eth.Peer, *p2p.MsgPipeRW) { - t.Helper() - source, sink := p2p.MsgPipe() - peer := eth.NewPeer(protocol, p2p.NewPeer(enode.ID{id}, "", nil), source, h.txpool) - t.Cleanup(func() { - peer.Close() - source.Close() - sink.Close() - }) - if err := h.peers.registerPeer(peer, nil, nil); err != nil { - t.Fatal(err) - } - return peer, sink -} - -func syncCompletionAnnouncement(sink *p2p.MsgPipeRW, tx *types.Transaction) <-chan error { - result := make(chan error, 1) - go func() { - result <- p2p.ExpectMsg(sink, eth.NewPooledTransactionHashesMsg, ð.NewPooledTransactionHashesPacket{ - Types: []byte{tx.Type()}, Sizes: []uint32{uint32(tx.Size())}, Hashes: []common.Hash{tx.Hash()}, - }) - }() - return result -} - -func expectSyncCompletionAnnouncement(t *testing.T, sink *p2p.MsgPipeRW, tx *types.Transaction) { - t.Helper() - result := syncCompletionAnnouncement(sink, tx) - synctest.Wait() - select { - case err := <-result: - if err != nil { - t.Fatal(err) - } - default: - t.Fatal("missing pending announcement on the existing connection") - } -} - -func expectNoSyncCompletionAnnouncement(t *testing.T, sink *p2p.MsgPipeRW, tx *types.Transaction) { - t.Helper() - result := syncCompletionAnnouncement(sink, tx) - synctest.Wait() - select { - case err := <-result: - t.Fatalf("unexpected pending announcement: %v", err) - default: - } -} diff --git a/internal/ethapi/api.go b/internal/ethapi/api.go index 1d784ddaec..5c9ec321c6 100644 --- a/internal/ethapi/api.go +++ b/internal/ethapi/api.go @@ -2492,18 +2492,30 @@ func (api *TransactionAPI) SendRawTransactionPrivate(ctx context.Context, input return common.Hash{}, err } - // A private duplicate must not temporarily hide an already-public transaction. - if api.b.GetPoolTransaction(tx.Hash()) != nil { - return common.Hash{}, txpool.ErrAlreadyKnown + // Track the tx hash to ensure it is not gossiped in public + api.b.RecordPrivateTx(tx.Hash()) + + hash, err := SubmitTransaction(ctx, api.b, tx) + if err != nil { + // Purge tx from private tx tracker if submission failed. Don't purge + // if `ErrAlreadyKnown` is being returned. + if !errors.Is(err, txpool.ErrAlreadyKnown) { + api.b.PurgePrivateTx(tx.Hash()) + } + return hash, err } - // Hide the hash before pool admission can emit a transaction event. - finish := api.b.BeginPrivateTx(tx.Hash()) - defer finish(false) + // Submit the private transaction directly to BPs + if api.b.PrivateTxEnabled() { + // Return an error here to inform user that private tx submission failed as it is critical. + // Note that it will be retried in background. + if err := api.b.SubmitPrivateTx(tx); err != nil { + log.Error("Private tx accepted locally but submission to bp failed", "err", err) + return tx.Hash(), fmt.Errorf("private tx accepted locally, submission failed. reason: %w", err) + } + } - _, err := SubmitTransaction(ctx, privateTxBackend{api.b}, tx) - finish(err == nil) - return api.forwardPrivateTransaction(tx, err) + return hash, err } func (api *TransactionAPI) CheckPreconfStatus(ctx context.Context, hash common.Hash) (bool, error) { diff --git a/internal/ethapi/api_private.go b/internal/ethapi/api_private.go deleted file mode 100644 index 5ed836ff7a..0000000000 --- a/internal/ethapi/api_private.go +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright 2026 The go-ethereum Authors -// This file is part of the go-ethereum library. -// -// The go-ethereum library is free software: you can redistribute it and/or modify -// it under the terms of the GNU Lesser General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// The go-ethereum library is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Lesser General Public License for more details. -// -// You should have received a copy of the GNU Lesser General Public License -// along with the go-ethereum library. If not, see . - -package ethapi - -import ( - "context" - "fmt" - - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/core/txpool/locals" - "github.com/ethereum/go-ethereum/core/types" -) - -// A temporary local rejection must not prevent a producer with room from -// accepting the transaction. The caller separately records local admission. -func (api *TransactionAPI) forwardPrivateTransaction(tx *types.Transaction, localErr error) (common.Hash, error) { - if localErr != nil && (!api.b.PrivateTxEnabled() || !locals.IsTemporaryReject(localErr)) { - return common.Hash{}, localErr - } - if api.b.PrivateTxEnabled() { - if err := api.b.SubmitPrivateTx(tx); err != nil { - if localErr != nil { - return common.Hash{}, fmt.Errorf("private tx rejected locally: %w; submission failed: %w", localErr, err) - } - return tx.Hash(), fmt.Errorf("private tx accepted locally, submission failed. reason: %w", err) - } - } - return tx.Hash(), nil -} - -// Keep validation and logging shared while selecting explicit private admission. -type privateTxBackend struct{ Backend } - -func (b privateTxBackend) SendTx(ctx context.Context, tx *types.Transaction) error { - if private, ok := b.Backend.(interface { - SendPrivateTx(context.Context, *types.Transaction) error - }); ok { - return private.SendPrivateTx(ctx, tx) - } - return b.Backend.SendTx(ctx, tx) -} diff --git a/internal/ethapi/api_private_test.go b/internal/ethapi/api_private_test.go deleted file mode 100644 index 8dba695e39..0000000000 --- a/internal/ethapi/api_private_test.go +++ /dev/null @@ -1,242 +0,0 @@ -// Copyright 2026 The go-ethereum Authors -// This file is part of the go-ethereum library. -// -// The go-ethereum library is free software: you can redistribute it and/or modify -// it under the terms of the GNU Lesser General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// The go-ethereum library is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Lesser General Public License for more details. -// -// You should have received a copy of the GNU Lesser General Public License -// along with the go-ethereum library. If not, see . - -package ethapi - -import ( - "context" - "errors" - "sync/atomic" - "testing" - "time" - - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/consensus/ethash" - "github.com/ethereum/go-ethereum/core" - "github.com/ethereum/go-ethereum/core/txpool" - "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/eth/relay" - "github.com/ethereum/go-ethereum/params" - "github.com/stretchr/testify/require" -) - -type privateSubmissionBackend struct { - *testBackend - send func(context.Context, *types.Transaction) error - pooled *types.Transaction -} - -func (b *privateSubmissionBackend) SendTx(ctx context.Context, tx *types.Transaction) error { - return b.send(ctx, tx) -} - -func (b *privateSubmissionBackend) GetPoolTransaction(hash common.Hash) *types.Transaction { - if b.pooled != nil && b.pooled.Hash() == hash { - return b.pooled - } - return nil -} - -func TestSendRawTransactionPrivateConcurrentSubmission(t *testing.T) { - t.Parallel() - for _, tc := range []struct { - name string - first, second bool - }{ - {name: "all rejected"}, - {name: "first accepted", first: true}, - {name: "second accepted", second: true}, - } { - t.Run(tc.name, func(t *testing.T) { testPrivateConcurrentSubmission(t, tc.first, tc.second) }) - } -} - -func testPrivateConcurrentSubmission(t *testing.T, firstAccepted, secondAccepted bool) { - t.Helper() - genesis := &core.Genesis{Config: params.TestChainConfig, Alloc: types.GenesisAlloc{}} - base := newTestBackend(t, 0, genesis, ethash.NewFaker(), nil) - defer base.chain.Stop() - base.acceptPrivateTxs = true - store := relay.NewPrivateTxStore() - defer store.Close() - base.recordPrivateTxFn, base.purgePrivateTxFn = store.Add, store.Purge - base.beginPrivateTxFn = store.Begin - started, release := make(chan struct{}), make(chan struct{}) - defer close(release) - var calls atomic.Int32 - b := &privateSubmissionBackend{testBackend: base} - b.send = func(ctx context.Context, tx *types.Transaction) error { - if calls.Add(1) == 1 { - close(started) - <-release - if firstAccepted { - return base.SendTx(ctx, tx) - } - return txpool.ErrInflightTxLimitReached - } - if secondAccepted { - return base.SendTx(ctx, tx) - } - return txpool.ErrInflightTxLimitReached - } - api := NewTransactionAPI(b, new(AddrLocker)) - raw, tx := makeSelfSignedRaw(t, api, base.acc.Address) - failed := make(chan error, 1) - go func() { - _, err := api.SendRawTransactionPrivate(t.Context(), raw) - failed <- err - }() - select { - case <-started: - case <-time.After(5 * time.Second): - t.Fatal("first submission did not reach admission") - } - hash, err := api.SendRawTransactionPrivate(t.Context(), raw) - if secondAccepted { - require.NoError(t, err) - require.Equal(t, tx.Hash(), hash) - } else { - require.ErrorIs(t, err, txpool.ErrInflightTxLimitReached) - } - require.True(t, store.IsTxPrivate(tx.Hash()), "pending admission must remain private") - release <- struct{}{} - select { - case err := <-failed: - if firstAccepted { - require.NoError(t, err) - } else { - require.ErrorIs(t, err, txpool.ErrInflightTxLimitReached) - } - case <-time.After(5 * time.Second): - t.Fatal("first submission did not finish") - } - require.Equal(t, firstAccepted || secondAccepted, store.IsTxPrivate(tx.Hash()), "final marker must reflect successful admission") -} - -func TestSendRawTransactionPrivateRelayFailurePreservesCommitment(t *testing.T) { - genesis := &core.Genesis{Config: params.TestChainConfig, Alloc: types.GenesisAlloc{}} - base := newTestBackend(t, 0, genesis, ethash.NewFaker(), nil) - defer base.chain.Stop() - base.privateTxEnabled = true - store := relay.NewPrivateTxStore() - defer store.Close() - base.beginPrivateTxFn = store.Begin - base.submitPrivateTxFn = func(tx *types.Transaction) error { - require.True(t, store.IsTxPrivate(tx.Hash()), "local admission must remain private during relay submission") - return errors.New("relay unavailable") - } - api := NewTransactionAPI(base, new(AddrLocker)) - raw, tx := makeSelfSignedRaw(t, api, base.acc.Address) - hash, err := api.SendRawTransactionPrivate(t.Context(), raw) - require.ErrorContains(t, err, "accepted locally, submission failed") - require.Equal(t, tx.Hash(), hash) - require.True(t, store.IsTxPrivate(hash), "relay failure removed an accepted local marker") -} - -func TestSendRawTransactionPrivatePanicReleasesReservation(t *testing.T) { - genesis := &core.Genesis{Config: params.TestChainConfig, Alloc: types.GenesisAlloc{}} - base := newTestBackend(t, 0, genesis, ethash.NewFaker(), nil) - defer base.chain.Stop() - base.acceptPrivateTxs = true - store := relay.NewPrivateTxStore() - defer store.Close() - base.beginPrivateTxFn = store.Begin - backend := &privateSubmissionBackend{testBackend: base} - backend.send = func(context.Context, *types.Transaction) error { panic("pool unavailable") } - api := NewTransactionAPI(backend, new(AddrLocker)) - raw, tx := makeSelfSignedRaw(t, api, base.acc.Address) - require.Panics(t, func() { _, _ = api.SendRawTransactionPrivate(t.Context(), raw) }) - require.False(t, store.IsTxPrivate(tx.Hash()), "recovered admission panic retained a reservation") -} - -func TestSendRawTransactionPrivateKnownPublicRemainsVisible(t *testing.T) { - genesis := &core.Genesis{Config: params.TestChainConfig, Alloc: types.GenesisAlloc{}} - base := newTestBackend(t, 0, genesis, ethash.NewFaker(), nil) - defer base.chain.Stop() - base.acceptPrivateTxs = true - store := relay.NewPrivateTxStore() - defer store.Close() - begins, admissions := 0, 0 - base.beginPrivateTxFn = func(hash common.Hash) func(bool) { - begins++ - return store.Begin(hash) - } - backend := &privateSubmissionBackend{testBackend: base} - backend.send = func(_ context.Context, tx *types.Transaction) error { - admissions++ - require.False(t, store.IsTxPrivate(tx.Hash()), "public transaction hidden during private duplicate admission") - return txpool.ErrAlreadyKnown - } - api := NewTransactionAPI(backend, new(AddrLocker)) - raw, tx := makeSelfSignedRaw(t, api, base.acc.Address) - backend.pooled = tx - require.False(t, store.IsTxPrivate(tx.Hash())) - hash, err := api.SendRawTransactionPrivate(t.Context(), raw) - require.ErrorIs(t, err, txpool.ErrAlreadyKnown) - require.Equal(t, common.Hash{}, hash) - require.Zero(t, begins, "known public duplicate must not reserve a private marker") - require.Zero(t, admissions, "known duplicate must not enter admission") - require.False(t, store.IsTxPrivate(tx.Hash())) -} - -func TestSendRawTransactionPrivateTemporaryForwarding(t *testing.T) { - for _, tc := range []struct { - name string - localErr error - relay, failRelay, forward bool - }{ - {"temporary relay", txpool.ErrUnderpriced, true, false, true}, - {"temporary relay failed", txpool.ErrUnderpriced, true, true, true}, - {"temporary producer", txpool.ErrUnderpriced, false, false, false}, - {"permanent relay", core.ErrNonceTooLow, true, false, false}, - } { - t.Run(tc.name, func(t *testing.T) { - genesis := &core.Genesis{Config: params.TestChainConfig, Alloc: types.GenesisAlloc{}} - base := newTestBackend(t, 0, genesis, ethash.NewFaker(), nil) - defer base.chain.Stop() - base.privateTxEnabled, base.acceptPrivateTxs = tc.relay, true - store := relay.NewPrivateTxStore() - defer store.Close() - base.beginPrivateTxFn = store.Begin - calls := 0 - relayErr := errors.New("relay unavailable") - base.submitPrivateTxFn = func(tx *types.Transaction) error { - calls++ - require.False(t, store.IsTxPrivate(tx.Hash()), "rejected local admission was committed") - if tc.failRelay { - return relayErr - } - return nil - } - backend := &privateSubmissionBackend{testBackend: base, send: func(context.Context, *types.Transaction) error { return tc.localErr }} - api := NewTransactionAPI(backend, new(AddrLocker)) - raw, tx := makeSelfSignedRaw(t, api, base.acc.Address) - hash, err := api.SendRawTransactionPrivate(t.Context(), raw) - require.Equal(t, tc.forward, calls == 1) - if tc.forward && !tc.failRelay { - require.NoError(t, err) - require.Equal(t, tx.Hash(), hash) - } else { - require.ErrorIs(t, err, tc.localErr) - require.Equal(t, common.Hash{}, hash) - } - if tc.failRelay { - require.ErrorIs(t, err, relayErr) - } - require.False(t, store.IsTxPrivate(tx.Hash())) - }) - } -} diff --git a/internal/ethapi/api_test.go b/internal/ethapi/api_test.go index 333ddbac84..b63ce36746 100644 --- a/internal/ethapi/api_test.go +++ b/internal/ethapi/api_test.go @@ -470,7 +470,6 @@ type testBackend struct { checkPreconfStatusFn func(common.Hash) (bool, error) submitPrivateTxFn func(*types.Transaction) error recordPrivateTxFn func(common.Hash) - beginPrivateTxFn func(common.Hash) func(bool) purgePrivateTxFn func(common.Hash) // Error to inject from SendTx (nil = existing logic) @@ -550,14 +549,6 @@ func (b testBackend) RecordPrivateTx(hash common.Hash) { b.recordPrivateTxFn(hash) } } -func (b testBackend) BeginPrivateTx(hash common.Hash) func(bool) { - if b.beginPrivateTxFn != nil { - finish := b.beginPrivateTxFn(hash) - var once sync.Once - return func(accepted bool) { once.Do(func() { finish(accepted) }) } - } - return func(bool) {} -} func (b testBackend) PurgePrivateTx(hash common.Hash) { if b.purgePrivateTxFn != nil { b.purgePrivateTxFn(hash) @@ -4888,7 +4879,7 @@ func TestSendRawTransactionPrivate(t *testing.T) { require.Error(t, err) }) - t.Run("accepted via AcceptPrivateTxs, reservation committed", func(t *testing.T) { + t.Run("accepted via AcceptPrivateTxs, RecordPrivateTx called", func(t *testing.T) { t.Parallel() genesis := &core.Genesis{Config: params.TestChainConfig, Alloc: types.GenesisAlloc{}} b := newTestBackend(t, 0, genesis, ethash.NewFaker(), nil) @@ -4896,10 +4887,7 @@ func TestSendRawTransactionPrivate(t *testing.T) { var recordCount atomic.Int32 var submitPrivateCount atomic.Int32 - b.beginPrivateTxFn = func(hash common.Hash) func(bool) { - recordCount.Add(1) - return func(accepted bool) { require.True(t, accepted) } - } + b.recordPrivateTxFn = func(hash common.Hash) { recordCount.Add(1) } b.submitPrivateTxFn = func(tx *types.Transaction) error { submitPrivateCount.Add(1); return nil } api := NewTransactionAPI(b, new(AddrLocker)) @@ -4908,11 +4896,11 @@ func TestSendRawTransactionPrivate(t *testing.T) { hash, err := api.SendRawTransactionPrivate(context.Background(), raw) require.NoError(t, err) require.Equal(t, tx.Hash(), hash) - require.Equal(t, int32(1), recordCount.Load(), "private reservation should be started once") + require.Equal(t, int32(1), recordCount.Load(), "RecordPrivateTx should be called once") require.Equal(t, int32(0), submitPrivateCount.Load(), "SubmitPrivateTx should NOT be called when PrivateTxEnabled is false") }) - t.Run("SendTx fails, reservation completed without commitment", func(t *testing.T) { + t.Run("SendTx fails, PurgePrivateTx called", func(t *testing.T) { t.Parallel() genesis := &core.Genesis{Config: params.TestChainConfig, Alloc: types.GenesisAlloc{}} b := newTestBackend(t, 0, genesis, ethash.NewFaker(), nil) @@ -4920,11 +4908,9 @@ func TestSendRawTransactionPrivate(t *testing.T) { b.sendTxErr = errors.New("pool full") var recordCount atomic.Int32 - var finishCount atomic.Int32 - b.beginPrivateTxFn = func(hash common.Hash) func(bool) { - recordCount.Add(1) - return func(accepted bool) { require.False(t, accepted); finishCount.Add(1) } - } + var purgeCount atomic.Int32 + b.recordPrivateTxFn = func(hash common.Hash) { recordCount.Add(1) } + b.purgePrivateTxFn = func(hash common.Hash) { purgeCount.Add(1) } api := NewTransactionAPI(b, new(AddrLocker)) raw, _ := makeSelfSignedRaw(t, api, b.acc.Address) @@ -4932,28 +4918,26 @@ func TestSendRawTransactionPrivate(t *testing.T) { _, err := api.SendRawTransactionPrivate(context.Background(), raw) require.Error(t, err) require.Contains(t, err.Error(), "pool full") - require.Equal(t, int32(1), recordCount.Load(), "private reservation should precede SendTx") - require.Equal(t, int32(1), finishCount.Load(), "failed submission should finish its reservation") + require.Equal(t, int32(1), recordCount.Load(), "RecordPrivateTx should be called before SendTx") + require.Equal(t, int32(1), purgeCount.Load(), "PurgePrivateTx should be called on SendTx failure") }) - t.Run("ErrAlreadyKnown completes failed reservation", func(t *testing.T) { + t.Run("ErrAlreadyKnown does not purge", func(t *testing.T) { t.Parallel() genesis := &core.Genesis{Config: params.TestChainConfig, Alloc: types.GenesisAlloc{}} b := newTestBackend(t, 0, genesis, ethash.NewFaker(), nil) b.acceptPrivateTxs = true b.sendTxErr = txpool.ErrAlreadyKnown - var finishCount atomic.Int32 - b.beginPrivateTxFn = func(hash common.Hash) func(bool) { - return func(accepted bool) { require.False(t, accepted); finishCount.Add(1) } - } + var purgeCount atomic.Int32 + b.purgePrivateTxFn = func(hash common.Hash) { purgeCount.Add(1) } api := NewTransactionAPI(b, new(AddrLocker)) raw, _ := makeSelfSignedRaw(t, api, b.acc.Address) _, err := api.SendRawTransactionPrivate(context.Background(), raw) require.ErrorIs(t, err, txpool.ErrAlreadyKnown) - require.Equal(t, int32(1), finishCount.Load(), "already-known submission should finish its reservation") + require.Equal(t, int32(0), purgeCount.Load(), "PurgePrivateTx should NOT be called for ErrAlreadyKnown") }) t.Run("PrivateTxEnabled, SubmitPrivateTx succeeds", func(t *testing.T) { diff --git a/internal/ethapi/backend.go b/internal/ethapi/backend.go index 1a52484f08..1da35e26a9 100644 --- a/internal/ethapi/backend.go +++ b/internal/ethapi/backend.go @@ -76,7 +76,6 @@ type Backend interface { AcceptPreconfTxs() bool AcceptPrivateTxs() bool RecordPrivateTx(hash common.Hash) - BeginPrivateTx(hash common.Hash) func(bool) PurgePrivateTx(hash common.Hash) // Blockchain API diff --git a/internal/ethapi/transaction_args_test.go b/internal/ethapi/transaction_args_test.go index 5cb42ac3f4..62b3380c23 100644 --- a/internal/ethapi/transaction_args_test.go +++ b/internal/ethapi/transaction_args_test.go @@ -327,7 +327,6 @@ func (b *backendMock) SubmitPrivateTx(tx *types.Transaction) error { retur func (b *backendMock) AcceptPreconfTxs() bool { return false } func (b *backendMock) AcceptPrivateTxs() bool { return false } func (b *backendMock) RecordPrivateTx(hash common.Hash) {} -func (b *backendMock) BeginPrivateTx(hash common.Hash) func(bool) { return func(bool) {} } func (b *backendMock) PurgePrivateTx(hash common.Hash) {} func (b *backendMock) SuggestGasTipCap(ctx context.Context) (*big.Int, error) { From e7646d3f8889bfc43daaec073c7ec65cdc26361d Mon Sep 17 00:00:00 2001 From: Vikram Bhattacharjee Date: Mon, 26 Oct 2026 14:01:48 +0530 Subject: [PATCH 14/15] ci: revert pipeline workflow changes --- .github/workflows/kurtosis-pipeline-e2e.yml | 23 --------------------- 1 file changed, 23 deletions(-) diff --git a/.github/workflows/kurtosis-pipeline-e2e.yml b/.github/workflows/kurtosis-pipeline-e2e.yml index 5c20b10723..5324ffe256 100644 --- a/.github/workflows/kurtosis-pipeline-e2e.yml +++ b/.github/workflows/kurtosis-pipeline-e2e.yml @@ -155,29 +155,6 @@ jobs: - name: Copy kurtosis config run: cp ./pos-workflows/configs/kurtosis-pipeline-e2e.yml ./kurtosis-pipeline-e2e.yml - # Keep the baseline on a released build, independent of bor:local. - - name: Use the published release for the baseline node - run: | - python3 - <<'PYTHON' - from pathlib import Path - import re - - config = Path("kurtosis-pipeline-e2e.yml") - content = config.read_text() - released = set(re.findall( - r"(?m)^\s*el_image: (ghcr\.io/0xpolygon/bor:v[0-9]+\.[0-9]+\.[0-9]+)\s*$", - content, - )) - if len(released) != 1: - raise SystemExit("Expected one stable GHCR Bor release for the baseline") - content = re.sub( - r"(?m)^(\s*el_image: )0xpolygon/bor:latest(?=\s*$)", - lambda match: match[1] + next(iter(released)), - content, - ) - config.write_text(content) - PYTHON - # Load images - name: Download bor docker image uses: actions/download-artifact@v4 From 1879e4161ff51d7e4513d29e4dc53d76d2bc1ef0 Mon Sep 17 00:00:00 2001 From: Vikram Bhattacharjee Date: Thu, 29 Oct 2026 13:15:29 +0530 Subject: [PATCH 15/15] core/txpool: align rebroadcast accounting with timer rounds --- .claude/rules/txpool-security.md | 2 + CLAUDE.md | 4 + core/txpool/legacypool/legacypool.go | 4 +- core/txpool/legacypool/rebroadcast.go | 6 +- core/txpool/legacypool/rebroadcast_attempt.go | 4 +- core/txpool/legacypool/rebroadcast_clock.go | 36 ++++++ .../legacypool/rebroadcast_tick_test.go | 106 ++++++++++++++++++ 7 files changed, 154 insertions(+), 8 deletions(-) create mode 100644 core/txpool/legacypool/rebroadcast_clock.go create mode 100644 core/txpool/legacypool/rebroadcast_tick_test.go diff --git a/.claude/rules/txpool-security.md b/.claude/rules/txpool-security.md index 818be57731..abd6e0b8a6 100644 --- a/.claude/rules/txpool-security.md +++ b/.claude/rules/txpool-security.md @@ -29,6 +29,8 @@ The transaction pool receives transactions from both RPC and peers. It is the pr 5. **Replacement fee bump must be enforced** — replacement transactions must pay a minimum percentage increase in gas price to prevent replacement spam. +6. **Keep rebroadcast accounting aligned with its timer** — record only real queue offers or acceptance, but use the current scheduled round for retry timing. Cover delayed callbacks, skipped ticks, and legacy subscribers so callback latency cannot double the configured interval or cause immediate catch-up retries. + ## Patterns to Flag | Pattern | Severity | Why | diff --git a/CLAUDE.md b/CLAUDE.md index 43c994c2d3..b4c1eec6c2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1,5 @@ @AGENTS.md + +## Rebroadcast scheduling + +Queue-offer and acceptance accounting follows the rebroadcast ticker cadence. Test delayed callbacks and skipped ticks through the real pool loop; moving timestamps to callback wall time can silently double the retry interval. diff --git a/core/txpool/legacypool/legacypool.go b/core/txpool/legacypool/legacypool.go index 8c2be7388f..d9c29089b7 100644 --- a/core/txpool/legacypool/legacypool.go +++ b/core/txpool/legacypool/legacypool.go @@ -342,6 +342,7 @@ type LegacyPool struct { rebroadcastAckFeed event.Feed lastRebroadcast map[common.Hash]time.Time // Track last queue acceptance per tx hash lastRebroadcastAttempt map[common.Hash]time.Time + rebroadcastTick time.Time isTxPrivate func(common.Hash) bool stranded strandedState // Pending chains blocked by an unminable head @@ -491,7 +492,8 @@ func (pool *LegacyPool) loop() { } // Handle stuck transaction rebroadcast - case <-rebroadcastC: + case tick := <-rebroadcastC: + pool.setRebroadcastTick(tick) // Use RLock for reading to minimize contention with add()/reorg() identifyStart := time.Now() pool.mu.RLock() diff --git a/core/txpool/legacypool/rebroadcast.go b/core/txpool/legacypool/rebroadcast.go index f7f8b3cac3..745b151cde 100644 --- a/core/txpool/legacypool/rebroadcast.go +++ b/core/txpool/legacypool/rebroadcast.go @@ -17,8 +17,6 @@ package legacypool import ( - "time" - "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core" "github.com/ethereum/go-ethereum/core/types" @@ -71,7 +69,7 @@ func (pool *LegacyPool) rebroadcastAcknowledgement(txs []*types.Transaction, cou pool.mu.Lock() defer pool.mu.Unlock() - now := time.Now() + now := pool.rebroadcastTime() var count int64 for _, hash := range hashes { tx := remaining[hash] @@ -101,7 +99,7 @@ func (pool *LegacyPool) forgetRebroadcast(hash common.Hash) { func (pool *LegacyPool) recordRebroadcastAttempts(txs []*types.Transaction) { pool.mu.Lock() defer pool.mu.Unlock() - now := time.Now() + now := pool.rebroadcastTime() for _, tx := range txs { if pool.all.Get(tx.Hash()) == tx { pool.lastRebroadcastAttempt[tx.Hash()] = now diff --git a/core/txpool/legacypool/rebroadcast_attempt.go b/core/txpool/legacypool/rebroadcast_attempt.go index 603efe4a52..0c37ed2825 100644 --- a/core/txpool/legacypool/rebroadcast_attempt.go +++ b/core/txpool/legacypool/rebroadcast_attempt.go @@ -17,8 +17,6 @@ package legacypool import ( - "time" - "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" ) @@ -33,7 +31,7 @@ func (pool *LegacyPool) RebroadcastAttempt(txs []*types.Transaction) func([]comm return func(hashes []common.Hash) { pool.mu.Lock() defer pool.mu.Unlock() - now := time.Now() + now := pool.rebroadcastTime() for _, hash := range hashes { tx := remaining[hash] if tx == nil { diff --git a/core/txpool/legacypool/rebroadcast_clock.go b/core/txpool/legacypool/rebroadcast_clock.go new file mode 100644 index 0000000000..72a6b8011e --- /dev/null +++ b/core/txpool/legacypool/rebroadcast_clock.go @@ -0,0 +1,36 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package legacypool + +import "time" + +func (pool *LegacyPool) setRebroadcastTick(tick time.Time) { + pool.mu.Lock() + defer pool.mu.Unlock() + pool.rebroadcastTick = tick +} + +// Call with pool.mu held. Queue reports use the ticker's cadence so work after +// selection cannot postpone retries by a whole interval. Advance past missed +// ticks as well, since a slow subscriber can block the pool's publishing loop. +func (pool *LegacyPool) rebroadcastTime() time.Time { + now := time.Now() + if pool.rebroadcastTick.IsZero() { + return now + } + return now.Add(-now.Sub(pool.rebroadcastTick) % pool.config.RebroadcastInterval) +} diff --git a/core/txpool/legacypool/rebroadcast_tick_test.go b/core/txpool/legacypool/rebroadcast_tick_test.go new file mode 100644 index 0000000000..83e3b664f4 --- /dev/null +++ b/core/txpool/legacypool/rebroadcast_tick_test.go @@ -0,0 +1,106 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package legacypool + +import ( + "testing" + "testing/synctest" + "time" + + "github.com/stretchr/testify/require" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core" + "github.com/ethereum/go-ethereum/core/types" +) + +func TestRebroadcastRetriesOnNextTick(t *testing.T) { + for _, mode := range []string{"refused", "accepted", "acknowledgement only", "legacy"} { + t.Run(mode, func(t *testing.T) { + for _, delay := range []time.Duration{5 * time.Millisecond, 35*time.Second + 5*time.Millisecond} { + t.Run(delay.String(), func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { testRebroadcastTick(t, mode, delay) }) + }) + } + }) + } +} + +func testRebroadcastTick(t *testing.T, mode string, delay time.Duration) { + t.Helper() + interval := 30 * time.Second + pool, _, from, tx := setupRebroadcastTest(t, interval, 10*time.Minute, 100) + defer pool.Close() + ch := make(chan core.StuckTxsEvent) + subscribe := pool.SubscribeRebroadcastTransactionsWithAcknowledgement + if mode == "legacy" { + subscribe = pool.SubscribeRebroadcastTransactions + } + sub := subscribe(ch) + defer sub.Unsubscribe() + pool.mu.Lock() + setTxAge(pool, from, time.Minute) + pool.mu.Unlock() + for range 3 { + // Model selection/feed/handler work, including a missed ticker delivery. + time.Sleep(interval + delay) + batch := <-ch + require.Len(t, batch.Txs, 1) + require.Same(t, tx, batch.Txs[0]) + if mode != "legacy" && mode != "acknowledgement only" { + pool.RebroadcastAttempt(batch.Txs)([]common.Hash{tx.Hash()}) + } + if mode == "accepted" || mode == "acknowledgement only" { + pool.RebroadcastAcknowledgement(batch.Txs)([]common.Hash{tx.Hash()}) + } + synctest.Wait() + time.Sleep(interval - delay%interval - time.Nanosecond) + synctest.Wait() + select { + case <-ch: + t.Fatal("delayed reports must not allow an immediate retry of a missed tick") + default: + } + time.Sleep(time.Nanosecond) + synctest.Wait() + select { + case batch = <-ch: + require.Len(t, batch.Txs, 1, "retry must happen on the next tick") + default: + t.Fatal("retry skipped the next scheduled tick after a delayed queue report") + } + synctest.Wait() + } +} + +func TestRebroadcastAccountingBeforeFirstTick(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + pool, _, _, tx := setupRebroadcastTest(t, 30*time.Second, time.Minute, 100) + defer pool.Close() + txs := []*types.Transaction{tx} + attempt, acknowledge := pool.RebroadcastAttempt(txs), pool.RebroadcastAcknowledgement(txs) + time.Sleep(5 * time.Millisecond) + now := time.Now() + attempt([]common.Hash{tx.Hash()}) + acknowledge([]common.Hash{tx.Hash()}) + pool.mu.RLock() + defer pool.mu.RUnlock() + require.True(t, pool.rebroadcastTick.IsZero()) + require.Equal(t, now, pool.lastRebroadcastAttempt[tx.Hash()]) + require.Equal(t, now, pool.lastRebroadcast[tx.Hash()]) + }) +}