Skip to content

Commit c3bbd9e

Browse files
committed
fix(core/txpool/locals): drop the transaction a tracked local tx replaces
TrackAll only ever added to the tracked set, while the per-nonce SortedMap silently overwrote the entry a replacement displaced. The replaced transaction was therefore never returned by Forward again, stayed tracked forever, was rewritten into the journal on every rotation and could win the nonce on the next load, resurrecting a transaction the user had already replaced, because rotation writes the tracked set in map order through a non-stable sort. Drop the replaced transaction from the tracked set when its nonce is taken over, unless the pool still holds it: the pool decides which transaction occupies a nonce, and Add tracks a local transaction only after SubPool.Add has released its lock, so two concurrent submissions can be accepted in one order and reach TrackAll in the other. Keeping the transaction the pool holds then stops a replacement from dropping the live transaction and pinning the superseded one, which recheck could never resubmit successfully and which would leave the live one without local protection. This 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 entry later in the file wins the nonce and the tracked set stays consistent, and the next rotation rewrites the journal from it. Build the test environment from an explicit chain config so tests can pin the gas schedule instead of sharing the package level genesis. Cover both orders in which a replacement can reach the tracker, and the concurrent interleaving where the original is added first and tracked last.
1 parent 6869580 commit c3bbd9e

2 files changed

Lines changed: 330 additions & 6 deletions

File tree

core/txpool/locals/tx_tracker.go

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,11 +101,43 @@ func (tracker *TxTracker) TrackAll(txs []*types.Transaction) {
101101
if err != nil { // Ignore this tx
102102
continue
103103
}
104-
tracker.all[tx.Hash()] = tx
105-
if tracker.byAddr[addr] == nil {
106-
tracker.byAddr[addr] = legacypool.NewSortedMap()
104+
list := tracker.byAddr[addr]
105+
if list == nil {
106+
list = legacypool.NewSortedMap()
107+
tracker.byAddr[addr] = list
107108
}
108-
tracker.byAddr[addr].Put(tx)
109+
// A transaction tracked for a nonce that is already taken supersedes the
110+
// one it replaces. SortedMap.Put overwrites silently, so the replaced
111+
// transaction has to be dropped here: it is never returned by Forward
112+
// again, and leaving it in `all` would keep journaling it forever,
113+
// where it can win the nonce on the next load and resurrect a
114+
// transaction the user already replaced.
115+
//
116+
// Which of the two supersedes the other is the pool's call: it holds at
117+
// most one transaction per sender and nonce, and Add tracks a local
118+
// transaction only after SubPool.Add has released its lock, so two
119+
// concurrent submissions can be accepted in one order and reach here in
120+
// the other. Keeping the transaction the pool still holds in that case
121+
// stops a replacement from dropping the live transaction and pinning
122+
// the superseded one, which recheck could never resubmit successfully.
123+
//
124+
// Dropping it here also converges a journal written by an older version,
125+
// which can hold both a transaction and its replacement: load feeds the
126+
// file straight into TrackAll, so the entry later in the file wins the
127+
// nonce and the tracked set stays consistent. The next rotation then
128+
// rewrites the journal from the converged set.
129+
if replaced := list.Get(tx.Nonce()); replaced != nil {
130+
if tracker.pool.Has(replaced.Hash()) {
131+
log.Debug("Ignoring tracked local transaction the pool superseded", "nonce", tx.Nonce(),
132+
"kept", replaced.Hash(), "ignored", tx.Hash())
133+
continue
134+
}
135+
delete(tracker.all, replaced.Hash())
136+
log.Debug("Replaced tracked local transaction", "nonce", tx.Nonce(),
137+
"replaced", replaced.Hash(), "replacement", tx.Hash())
138+
}
139+
list.Put(tx)
140+
tracker.all[tx.Hash()] = tx
109141

110142
if tracker.journal != nil {
111143
_ = tracker.journal.insert(tx)

core/txpool/locals/tx_tracker_test.go

Lines changed: 294 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import (
3737
"github.com/XinFinOrg/XDPoSChain/crypto"
3838
"github.com/XinFinOrg/XDPoSChain/ethdb"
3939
"github.com/XinFinOrg/XDPoSChain/params"
40+
"github.com/XinFinOrg/XDPoSChain/rlp"
4041
)
4142

4243
var (
@@ -50,17 +51,30 @@ var (
5051
},
5152
BaseFee: big.NewInt(params.InitialBaseFee),
5253
}
53-
signer = types.LatestSigner(gspec.Config)
5454
)
5555

5656
type testEnv struct {
5757
chain *core.BlockChain
5858
pool *txpool.TxPool
5959
tracker *TxTracker
6060
genDb ethdb.Database
61+
signer types.Signer
6162
}
6263

6364
func newTestEnv(t *testing.T, n int, gasTip uint64, journal string) *testEnv {
65+
return newTestEnvWithConfig(t, n, gasTip, journal, params.TestChainConfig)
66+
}
67+
68+
// newTestEnvWithConfig builds an environment around cfg. It builds its own
69+
// genesis and signer instead of touching the package level ones, which the
70+
// tests in this file share.
71+
func newTestEnvWithConfig(t *testing.T, n int, gasTip uint64, journal string, cfg *params.ChainConfig) *testEnv {
72+
gspec := &core.Genesis{
73+
Config: cfg,
74+
Alloc: types.GenesisAlloc{address: {Balance: new(big.Int).Set(funds)}},
75+
BaseFee: big.NewInt(params.InitialBaseFee),
76+
}
77+
signer := types.LatestSigner(cfg)
6478
genDb, blocks, _ := core.GenerateChainWithGenesis(gspec, ethash.NewFaker(), n, func(i int, gen *core.BlockGen) {
6579
gasPrice := big.NewInt(params.InitialBaseFee)
6680
if baseFee := gen.BaseFee(); baseFee != nil {
@@ -95,7 +109,23 @@ func newTestEnv(t *testing.T, n int, gasTip uint64, journal string) *testEnv {
95109
pool: pool,
96110
tracker: New(journal, time.Minute, gspec.Config, pool),
97111
genDb: genDb,
112+
signer: signer,
113+
}
114+
}
115+
116+
// nonce returns the next nonce the test account can spend at the current head.
117+
func (env *testEnv) nonce() uint64 {
118+
head := env.chain.CurrentHeader()
119+
state, _ := env.chain.StateAt(head.Root)
120+
return state.GetNonce(address)
121+
}
122+
123+
// gasPrice returns a gas price the gas schedule of the current head admits.
124+
func (env *testEnv) gasPrice() *big.Int {
125+
if baseFee := env.chain.CurrentHeader().BaseFee; baseFee != nil {
126+
return new(big.Int).Set(baseFee)
98127
}
128+
return big.NewInt(params.InitialBaseFee)
99129
}
100130

101131
func (env *testEnv) close() {
@@ -116,7 +146,7 @@ func (env *testEnv) makeTxs(n int) []*types.Transaction {
116146

117147
var txs []*types.Transaction
118148
for i := 0; i < n; i++ {
119-
tx, _ := types.SignTx(types.NewTransaction(nonce+uint64(i), common.Address{0x00}, big.NewInt(1000), params.TxGas, gasPrice, nil), signer, key)
149+
tx, _ := types.SignTx(types.NewTransaction(nonce+uint64(i), common.Address{0x00}, big.NewInt(1000), params.TxGas, gasPrice, nil), env.signer, key)
120150
txs = append(txs, tx)
121151
}
122152
return txs
@@ -225,3 +255,265 @@ func TestStartContinuesOnCorruptedJournal(t *testing.T) {
225255
t.Fatal("Journal writer should be initialized even if journal load fails")
226256
}
227257
}
258+
259+
// replacementPair returns two transactions sharing a nonce: the one tracked
260+
// first, and the one that replaces it.
261+
func replacementPair(env *testEnv) (replaced, replacement *types.Transaction) {
262+
nonce := env.nonce()
263+
mk := func(to common.Address) *types.Transaction {
264+
tx, _ := types.SignTx(types.NewTransaction(nonce, to, big.NewInt(1000), params.TxGas, env.gasPrice(), nil), env.signer, key)
265+
return tx
266+
}
267+
return mk(common.Address{0x00}), mk(common.Address{0x01})
268+
}
269+
270+
// writeReplacementJournal writes a journal holding two transactions that share
271+
// a nonce, the way an older version could have left it behind: a transaction
272+
// together with the one that replaced it, in the order selected by reverse.
273+
func writeReplacementJournal(t *testing.T, reverse bool) (path string, replaced, replacement *types.Transaction) {
274+
t.Helper()
275+
276+
env := newTestEnv(t, 10, 0, "")
277+
defer env.close()
278+
279+
replaced, replacement = replacementPair(env)
280+
order := []*types.Transaction{replaced, replacement}
281+
if reverse {
282+
order = []*types.Transaction{replacement, replaced}
283+
}
284+
var journal []byte
285+
for _, tx := range order {
286+
blob, err := rlp.EncodeToBytes(tx)
287+
if err != nil {
288+
t.Fatalf("Failed to encode transaction: %v", err)
289+
}
290+
journal = append(journal, blob...)
291+
}
292+
path = filepath.Join(t.TempDir(), fmt.Sprintf("%d", rand.Int63()))
293+
if err := os.WriteFile(path, journal, 0o644); err != nil {
294+
t.Fatalf("Failed to write journal: %v", err)
295+
}
296+
return path, replaced, replacement
297+
}
298+
299+
// TestTrackAllKeepsTransactionHeldByPool pins which transaction wins a nonce
300+
// when the pool already holds one of them: the pool decides, because the order
301+
// transactions reach TrackAll is not the order they were accepted in. Add
302+
// tracks a local transaction only after SubPool.Add has released its lock, so
303+
// a replacement can arrive here before the transaction it replaced.
304+
func TestTrackAllKeepsTransactionHeldByPool(t *testing.T) {
305+
for _, tc := range []struct {
306+
name string
307+
reversed bool
308+
}{
309+
{name: "pooled first"},
310+
{name: "replacement first", reversed: true},
311+
} {
312+
t.Run(tc.name, func(t *testing.T) {
313+
env := newTestEnv(t, 10, 0, "")
314+
defer env.close()
315+
316+
pooled, replacement := replacementPair(env)
317+
if err := env.pool.Add([]*types.Transaction{pooled}, true)[0]; err != nil {
318+
t.Fatalf("failed to add the transaction the pool must hold: %v", err)
319+
}
320+
pair := []*types.Transaction{pooled, replacement}
321+
if tc.reversed {
322+
pair = []*types.Transaction{replacement, pooled}
323+
}
324+
env.tracker.TrackAll(pair)
325+
326+
env.tracker.mu.Lock()
327+
defer env.tracker.mu.Unlock()
328+
329+
if len(env.tracker.all) != 1 {
330+
t.Fatalf("tracked set must hold a single transaction, got %d", len(env.tracker.all))
331+
}
332+
if _, ok := env.tracker.all[pooled.Hash()]; !ok {
333+
t.Fatalf("the transaction the pool holds must stay tracked: %v", pooled.Hash())
334+
}
335+
if kept := env.tracker.byAddr[address].Get(pooled.Nonce()); kept == nil || kept.Hash() != pooled.Hash() {
336+
t.Fatalf("nonce %d must hold the pooled transaction, got %v", pooled.Nonce(), kept)
337+
}
338+
})
339+
}
340+
}
341+
342+
// TestConcurrentReplacementKeepsPoolTransaction pins the tracker's view of two
343+
// concurrent submissions for the same nonce: the original is added first and
344+
// reaches the tracker last, the window AddLocal leaves between SubPool.Add
345+
// releasing its lock and Track acquiring this one. The transaction the pool
346+
// holds must win the nonce even though it is tracked last, or recheck would
347+
// keep resubmitting the superseded one and the live one would lose the local
348+
// resubmit protection entirely.
349+
func TestConcurrentReplacementKeepsPoolTransaction(t *testing.T) {
350+
env := newTestEnv(t, 10, 0, "")
351+
defer env.close()
352+
353+
nonce := env.nonce()
354+
price := env.gasPrice()
355+
// The replacement has to clear the pool's price bump to take the nonce.
356+
bumped := new(big.Int).Div(
357+
new(big.Int).Mul(price, big.NewInt(int64(100+legacypool.DefaultConfig.PriceBump))),
358+
big.NewInt(100))
359+
bumped.Add(bumped, big.NewInt(1))
360+
mk := func(to common.Address, gasPrice *big.Int) *types.Transaction {
361+
tx, _ := types.SignTx(types.NewTransaction(nonce, to, big.NewInt(1000), params.TxGas, gasPrice, nil), env.signer, key)
362+
return tx
363+
}
364+
var (
365+
original = mk(common.Address{0x00}, price)
366+
replacement = mk(common.Address{0x01}, bumped)
367+
added = make(chan struct{})
368+
tracked = make(chan struct{})
369+
finished = make(chan error, 2)
370+
)
371+
go func() {
372+
err := env.pool.Add([]*types.Transaction{original}, true)[0]
373+
close(added)
374+
<-tracked
375+
env.tracker.Track(original)
376+
finished <- err
377+
}()
378+
go func() {
379+
<-added
380+
err := env.pool.Add([]*types.Transaction{replacement}, true)[0]
381+
env.tracker.Track(replacement)
382+
close(tracked)
383+
finished <- err
384+
}()
385+
for i := 0; i < 2; i++ {
386+
if err := <-finished; err != nil {
387+
t.Fatalf("failed to submit the transaction: %v", err)
388+
}
389+
}
390+
391+
env.tracker.mu.Lock()
392+
defer env.tracker.mu.Unlock()
393+
394+
if len(env.tracker.all) != 1 {
395+
t.Fatalf("tracked set must hold a single transaction, got %d", len(env.tracker.all))
396+
}
397+
if _, ok := env.tracker.all[replacement.Hash()]; !ok {
398+
t.Fatalf("the replacement the pool holds must stay tracked: %v", replacement.Hash())
399+
}
400+
}
401+
402+
func TestTrackAllDropsReplacedTransaction(t *testing.T) {
403+
env := newTestEnv(t, 10, 0, "")
404+
defer env.close()
405+
406+
replaced, replacement := replacementPair(env)
407+
env.tracker.TrackAll([]*types.Transaction{replaced, replacement})
408+
409+
env.tracker.mu.Lock()
410+
defer env.tracker.mu.Unlock()
411+
412+
if len(env.tracker.all) != 1 {
413+
t.Fatalf("replaced transaction must be dropped: tracking %d", len(env.tracker.all))
414+
}
415+
if _, ok := env.tracker.all[replaced.Hash()]; ok {
416+
t.Fatalf("replaced transaction still tracked: %v", replaced.Hash())
417+
}
418+
kept := env.tracker.byAddr[address].Get(replacement.Nonce())
419+
if kept == nil || kept.Hash() != replacement.Hash() {
420+
t.Fatalf("nonce %d must hold the replacement, got %v", replacement.Nonce(), kept)
421+
}
422+
}
423+
424+
func TestRecheckDoesNotResubmitReplacedTransaction(t *testing.T) {
425+
env := newTestEnv(t, 10, 0, "")
426+
defer env.close()
427+
428+
replaced, replacement := replacementPair(env)
429+
env.tracker.TrackAll([]*types.Transaction{replaced, replacement})
430+
431+
resubmits := env.tracker.recheck(false)
432+
if len(resubmits) != 1 || resubmits[0].Hash() != replacement.Hash() {
433+
t.Fatalf("unexpected transactions to resubmit: %v", resubmits)
434+
}
435+
}
436+
437+
func TestJournalRotationDropsReplacedTransaction(t *testing.T) {
438+
journalPath := filepath.Join(t.TempDir(), fmt.Sprintf("%d", rand.Int63()))
439+
env := newTestEnv(t, 10, 0, journalPath)
440+
defer env.close()
441+
442+
if err := env.tracker.Start(); err != nil {
443+
t.Fatalf("Failed to start tracker: %v", err)
444+
}
445+
defer env.tracker.Stop()
446+
447+
replaced, replacement := replacementPair(env)
448+
env.tracker.TrackAll([]*types.Transaction{replaced, replacement})
449+
450+
// Rotate the journal from the tracked set: the replaced transaction has
451+
// been dropped from it and must not come back.
452+
env.tracker.recheck(true)
453+
454+
reloaded := New(journalPath, time.Minute, params.TestChainConfig, env.pool)
455+
if err := reloaded.journal.load(func(transactions []*types.Transaction) []error {
456+
reloaded.TrackAll(transactions)
457+
return nil
458+
}); err != nil {
459+
t.Fatalf("Failed to load journal: %v", err)
460+
}
461+
462+
reloaded.mu.Lock()
463+
defer reloaded.mu.Unlock()
464+
465+
if len(reloaded.all) != 1 {
466+
t.Fatalf("rotated journal must hold a single transaction, got %d", len(reloaded.all))
467+
}
468+
if _, ok := reloaded.all[replacement.Hash()]; !ok {
469+
t.Fatalf("rotated journal must hold the replacement: %v", replacement.Hash())
470+
}
471+
}
472+
473+
func TestJournalLoadDropsReplacedTransaction(t *testing.T) {
474+
journalPath, _, replacement := writeReplacementJournal(t, false)
475+
env := newTestEnv(t, 10, 0, journalPath)
476+
defer env.close()
477+
478+
if err := env.tracker.Start(); err != nil {
479+
t.Fatalf("Failed to start tracker: %v", err)
480+
}
481+
defer env.tracker.Stop()
482+
483+
env.tracker.mu.Lock()
484+
defer env.tracker.mu.Unlock()
485+
486+
if len(env.tracker.all) != 1 {
487+
t.Fatalf("loading must leave a single transaction, got %d", len(env.tracker.all))
488+
}
489+
if _, ok := env.tracker.all[replacement.Hash()]; !ok {
490+
t.Fatalf("the replacement must survive the load: %v", replacement.Hash())
491+
}
492+
}
493+
494+
// TestJournalLoadKeepsLastEntryPerNonce pins the load path for the reverse file
495+
// order. A journal entry carries no timestamp or sequence number, so when a
496+
// journal written by an older version holds both a transaction and the one that
497+
// replaced it, the entry later in the file wins the nonce -- even when that is
498+
// the transaction the user replaced. TrackAll drops the loser from the tracked
499+
// set either way; it cannot tell which one is newer.
500+
func TestJournalLoadKeepsLastEntryPerNonce(t *testing.T) {
501+
journalPath, replaced, _ := writeReplacementJournal(t, true)
502+
env := newTestEnv(t, 10, 0, journalPath)
503+
defer env.close()
504+
505+
if err := env.tracker.Start(); err != nil {
506+
t.Fatalf("Failed to start tracker: %v", err)
507+
}
508+
defer env.tracker.Stop()
509+
510+
env.tracker.mu.Lock()
511+
defer env.tracker.mu.Unlock()
512+
513+
if len(env.tracker.all) != 1 {
514+
t.Fatalf("loading must leave a single transaction, got %d", len(env.tracker.all))
515+
}
516+
if _, ok := env.tracker.all[replaced.Hash()]; !ok {
517+
t.Fatalf("the entry later in the file must survive the load: %v", replaced.Hash())
518+
}
519+
}

0 commit comments

Comments
 (0)