Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions core/txpool/locals/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ import (
// IsTemporaryReject determines whether the given error indicates a temporary
// reason to reject a transaction from being included in the txpool. The result
// may change if the txpool's state changes later.
//
// ErrUnderMinGasPrice must stay out of this set: the floor only rises as the
// chain advances, so retrying the same transaction cannot help while it stays
// where it is. It must not be used to drop a tracked transaction either -- a
// rollback or a reorg past the fork lowers the floor again, and the local
// tracker is the only thing that can bring those transactions back.
func IsTemporaryReject(err error) bool {
switch {
case errors.Is(err, legacypool.ErrOutOfOrderTxFromDelegated):
Expand Down
41 changes: 41 additions & 0 deletions core/txpool/locals/errors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Copyright 2025 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 <http://www.gnu.org/licenses/>.

package locals

import (
"errors"
"testing"

"github.com/XinFinOrg/XDPoSChain/core/txpool"
)

// TestIsTemporaryRejectExcludesMinGasPrice pins that a transaction priced below
// the gas schedule floor is not a temporary reject: the floor only rises as the
// chain advances, so retrying it cannot help and AddLocal must not track it.
func TestIsTemporaryRejectExcludesMinGasPrice(t *testing.T) {
if IsTemporaryReject(txpool.ErrUnderMinGasPrice) {
t.Error("ErrUnderMinGasPrice must not be a temporary reject")
}
// The pool's own price limit is a node local setting that can change, so it
// stays retryable. The contrast is what the assertion above is about.
if !IsTemporaryReject(txpool.ErrUnderpriced) {
t.Error("ErrUnderpriced must remain a temporary reject")
}
if IsTemporaryReject(errors.New("unrelated failure")) {
t.Error("an unrelated error must not be a temporary reject")
}
}
111 changes: 103 additions & 8 deletions core/txpool/locals/tx_tracker.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,16 @@ import (
var (
recheckInterval = time.Minute
localGauge = metrics.GetOrRegisterGauge("txpool/local", nil)

// Tracked transactions a gas schedule fork priced out of the pool. They stay
// tracked so a rollback past the fork can pick them up again, but recheck
// holds them back from resubmission, so they are broken out here.
//
// Only transactions missing from the pool are counted: a tracked transaction
// the pool still has is counted as ok instead. local minus belowfloor is
// therefore the tracked transactions the pool holds plus the ones recheck
// resubmits this round, not just the latter.
belowFloorGauge = metrics.GetOrRegisterGauge("txpool/local/belowfloor", nil)
)

// TxTracker is a struct used to track priority transactions; it will check from
Expand Down Expand Up @@ -101,11 +111,78 @@ func (tracker *TxTracker) TrackAll(txs []*types.Transaction) {
if err != nil { // Ignore this tx
continue
}
tracker.all[tx.Hash()] = tx
if tracker.byAddr[addr] == nil {
tracker.byAddr[addr] = legacypool.NewSortedMap()
list := tracker.byAddr[addr]
if list == nil {
list = legacypool.NewSortedMap()
tracker.byAddr[addr] = list
}
tracker.byAddr[addr].Put(tx)
// A transaction tracked for a nonce that is already taken supersedes the
// one it replaces. SortedMap.Put overwrites silently, so the replaced
// transaction has to be dropped here: it is never returned by Forward
// again, and leaving it in `all` would keep journaling it forever,
// where it can win the nonce on the next load and resurrect a
// transaction the user already replaced.
//
// Which of the two supersedes the other is decided the way the pool
// decides it:
//
// 1. The pool still holds one of them. It occupies a nonce with at
// most one transaction, so whichever it holds won the nonce. This
// is checked first because the order transactions reach TrackAll
// is not the order they were accepted in: AddLocal tracks a local
// transaction only after Add has released the subpool lock, so two
// concurrent submissions can be accepted in one order and reach
// here in the other.
// 2. The pool holds neither -- two submissions it has since
// discarded, or a journal an older version wrote. Fall back to the
// substitution rules legacypool applies in list.Add: a special
// transaction always claims its nonce, a regular one must not
// evict a pending special one, and otherwise a replacement has to
// beat the transaction it replaces on both fee cap and tip. The
// price bump is deliberately not repeated here: it is a pool
// policy, and a transaction that beats the old one but misses the
// bump behaves exactly as it did before, so no case gets worse.
//
// Dropping the loser here also converges a journal written by an older
// version, which can hold both a transaction and its replacement: load
// feeds the file straight into TrackAll, so the replacement wins the
// nonce instead of whichever entry the older rotation happened to sort
// last, and the next rotation rewrites the journal from the converged
// set.
if replaced := list.Get(tx.Nonce()); replaced != nil {
switch {
case tracker.pool.Has(tx.Hash()):
// The pool accepted this one, so it holds the nonce. A tracked
// transaction that is dearer does not change that: it is one an
// older version journalled after the pool rejected it as
// retryable, and resubmitting it would evict this one.
case tracker.pool.Has(replaced.Hash()):
// The pool still holds the transaction this one would replace:
// it lost the race for the nonce there.
log.Debug("Ignoring tracked local transaction the pool superseded", "nonce", tx.Nonce(),
"kept", replaced.Hash(), "ignored", tx.Hash())
continue
case tx.IsSpecialTransaction():
// A special transaction always claims its nonce.
case replaced.IsSpecialTransaction():
// A regular transaction must not evict a pending special one,
// however much dearer it is.
log.Debug("Ignoring tracked local transaction that would evict a special one", "nonce", tx.Nonce(),
"kept", replaced.Hash(), "ignored", tx.Hash())
continue
case replaced.GasFeeCapCmp(tx) >= 0 || replaced.GasTipCapCmp(tx) >= 0:
// Not a replacement the pool would accept: keep the one that is
// already tracked.
log.Debug("Ignoring tracked local transaction the tracked one outbids", "nonce", tx.Nonce(),
"kept", replaced.Hash(), "ignored", tx.Hash())
continue
}
delete(tracker.all, replaced.Hash())
Comment thread
gzliudan marked this conversation as resolved.
log.Debug("Replaced tracked local transaction", "nonce", tx.Nonce(),
"replaced", replaced.Hash(), "replacement", tx.Hash())
}
list.Put(tx)
tracker.all[tx.Hash()] = tx

if tracker.journal != nil {
_ = tracker.journal.insert(tx)
Expand All @@ -120,10 +197,15 @@ func (tracker *TxTracker) recheck(journalCheck bool) []*types.Transaction {
defer tracker.mu.Unlock()

var (
numStales = 0
numOk = 0
resubmits []*types.Transaction
numStales = 0
numOk = 0
numBelowFloor = 0
resubmits []*types.Transaction
)
// Resolved once: it can only change on a chain head update, and a change
// mid-recheck would make the counters below inconsistent.
floor := tracker.pool.MinGasPrice()

for sender, txs := range tracker.byAddr {
// Wipe the stales
stales := txs.Forward(tracker.pool.Nonce(sender))
Expand All @@ -138,6 +220,17 @@ func (tracker *TxTracker) recheck(journalCheck bool) []*types.Transaction {
numOk++
continue
}
// A gas schedule fork can raise the floor above transactions that
// were admitted under the previous tier. Re-submitting them only
// yields ErrUnderMinGasPrice, so hold them back until the floor
// drops again: a reorg or a set-head rollback past the fork
// reinstates them, and the pool does not bring back what it swept.
// They stay tracked, so recheck picks them up again on its own.
// Special transactions are exempt, exactly as during admission.
if !tx.IsSpecialTransaction() && tx.GasPriceIntCmp(floor) < 0 {
numBelowFloor++
continue
}
resubmits = append(resubmits, tx)
}
}
Expand Down Expand Up @@ -165,7 +258,9 @@ func (tracker *TxTracker) recheck(journalCheck bool) []*types.Transaction {
}
}
localGauge.Update(int64(len(tracker.all)))
log.Debug("Tx tracker status", "need-resubmit", len(resubmits), "stale", numStales, "ok", numOk)
belowFloorGauge.Update(int64(numBelowFloor))
log.Debug("Tx tracker status", "need-resubmit", len(resubmits), "stale", numStales,
"ok", numOk, "below-floor", numBelowFloor)
return resubmits
}

Expand Down
Loading