go-ethereum/eth/txtracker/tracker_test.go
Csaba Kiraly 72f8ef6f69 eth/txtracker: test reorg safety of head block lookup
handleChainHead resolves the head block via GetBlock(hash, number) so
that a stale head event after a reorg cannot credit transactions from
the wrong block. The existing mockChain ignored the hash argument, so a
regression to GetBlockByNumber would have gone undetected.

Make mockChain hash-aware: store blocks keyed by hash with a separate
canonical-by-number index for the finalization path, and have sendHead
emit the real block's hash. Add TestReorgSafety with two blocks at the
same height to exercise the hash selector directly.
2026-04-13 16:56:37 +02:00

415 lines
12 KiB
Go

// 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 <http://www.gnu.org/licenses/>.
package txtracker
import (
"math/big"
"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/ethereum/go-ethereum/trie"
)
// mockChain implements the Chain interface for testing.
//
// Blocks are stored by hash to exercise the reorg-safe lookup path in
// tracker.handleChainHead (which calls GetBlock(hash, number)). A separate
// canonicalByNum index maps each height to its canonical block hash, used
// by GetBlockByNumber (the finalization path).
type mockChain struct {
mu sync.Mutex
headFeed event.Feed
blocksByHash map[common.Hash]*types.Block
canonicalByNum map[uint64]common.Hash
finalNum uint64
}
func newMockChain() *mockChain {
return &mockChain{
blocksByHash: make(map[common.Hash]*types.Block),
canonicalByNum: make(map[uint64]common.Hash),
}
}
func (c *mockChain) SubscribeChainHeadEvent(ch chan<- core.ChainHeadEvent) event.Subscription {
return c.headFeed.Subscribe(ch)
}
func (c *mockChain) GetBlockByNumber(number uint64) *types.Block {
c.mu.Lock()
defer c.mu.Unlock()
hash, ok := c.canonicalByNum[number]
if !ok {
return nil
}
return c.blocksByHash[hash]
}
func (c *mockChain) GetBlock(hash common.Hash, number uint64) *types.Block {
c.mu.Lock()
defer c.mu.Unlock()
return c.blocksByHash[hash]
}
func (c *mockChain) CurrentFinalBlock() *types.Header {
c.mu.Lock()
defer c.mu.Unlock()
if c.finalNum == 0 {
return nil
}
return &types.Header{Number: new(big.Int).SetUint64(c.finalNum)}
}
// addBlock adds a canonical block at the given height. Overwrites any
// prior canonical block at that height.
func (c *mockChain) addBlock(num uint64, txs []*types.Transaction) *types.Block {
return c.addBlockAtHeight(num, num, txs, true)
}
// addBlockAtHeight adds a block at the given height. The salt parameter
// ensures distinct block hashes for two blocks at the same height (used
// for reorg tests). If canonical is true, the block becomes the canonical
// block for that height (looked up by GetBlockByNumber).
func (c *mockChain) addBlockAtHeight(num, salt uint64, txs []*types.Transaction, canonical bool) *types.Block {
c.mu.Lock()
defer c.mu.Unlock()
// Mix salt into Extra so siblings at the same height get distinct hashes.
header := &types.Header{
Number: new(big.Int).SetUint64(num),
Extra: big.NewInt(int64(salt)).Bytes(),
}
block := types.NewBlock(header, &types.Body{Transactions: txs}, nil, trie.NewListHasher())
c.blocksByHash[block.Hash()] = block
if canonical {
c.canonicalByNum[num] = block.Hash()
}
return block
}
func (c *mockChain) setFinalBlock(num uint64) {
c.mu.Lock()
defer c.mu.Unlock()
c.finalNum = num
}
// sendHead emits a chain head event for the canonical block at the given
// height. The emitted header carries the real block's hash so the
// tracker's GetBlock(hash, number) lookup resolves correctly.
func (c *mockChain) sendHead(num uint64) {
c.mu.Lock()
hash := c.canonicalByNum[num]
block := c.blocksByHash[hash]
c.mu.Unlock()
if block == nil {
panic("sendHead: no canonical block at height")
}
c.headFeed.Send(core.ChainHeadEvent{Header: block.Header()})
}
// sendHeadBlock emits a chain head event for the given block (may be
// non-canonical). Used for reorg tests.
func (c *mockChain) sendHeadBlock(block *types.Block) {
c.headFeed.Send(core.ChainHeadEvent{Header: block.Header()})
}
func hashTxs(txs []*types.Transaction) []common.Hash {
hashes := make([]common.Hash, len(txs))
for i, tx := range txs {
hashes[i] = tx.Hash()
}
return hashes
}
func makeTx(nonce uint64) *types.Transaction {
return types.NewTx(&types.LegacyTx{Nonce: nonce, GasPrice: big.NewInt(1), Gas: 21000})
}
// waitStep blocks until the tracker has processed one event.
func waitStep(t *testing.T, tr *Tracker) {
t.Helper()
select {
case <-tr.step:
case <-time.After(time.Second):
t.Fatal("timeout waiting for tracker step")
}
}
func TestNotifyReceived(t *testing.T) {
tr := New()
chain := newMockChain()
tr.Start(chain)
defer tr.Stop()
txs := []*types.Transaction{makeTx(1), makeTx(2), makeTx(3)}
hashes := hashTxs(txs)
tr.NotifyAccepted("peerA", hashes)
// Public surface: peer entry was created with zero stats before any
// chain events. Map lookups would return a zero value for a missing
// key, so assert presence explicitly.
stats := tr.GetAllPeerStats()
if len(stats) != 1 {
t.Fatalf("expected 1 peer entry, got %d", len(stats))
}
ps, ok := stats["peerA"]
if !ok {
t.Fatal("expected peerA entry, not found")
}
if ps.Finalized != 0 || ps.RecentIncluded != 0 {
t.Fatalf("expected zero stats before chain events, got %+v", ps)
}
// Internal state: all tx→deliverer mappings recorded, insertion order
// preserved in the FIFO slice.
tr.mu.Lock()
defer tr.mu.Unlock()
if len(tr.txs) != 3 {
t.Fatalf("expected 3 tracked txs, got %d", len(tr.txs))
}
if len(tr.order) != 3 {
t.Fatalf("expected order length 3, got %d", len(tr.order))
}
for i, h := range hashes {
if got := tr.txs[h]; got != "peerA" {
t.Fatalf("tx %d: expected deliverer=peerA, got %q", i, got)
}
if tr.order[i] != h {
t.Fatalf("order[%d] mismatch", i)
}
}
}
func TestInclusionEMA(t *testing.T) {
tr := New()
chain := newMockChain()
tr.Start(chain)
defer tr.Stop()
tx := makeTx(1)
tr.NotifyAccepted("peerA", []common.Hash{tx.Hash()})
// Block 1 includes peerA's tx.
chain.addBlock(1, []*types.Transaction{tx})
chain.sendHead(1)
waitStep(t, tr)
stats := tr.GetAllPeerStats()
if stats["peerA"].RecentIncluded <= 0 {
t.Fatalf("expected RecentIncluded > 0 after inclusion, got %f", stats["peerA"].RecentIncluded)
}
ema1 := stats["peerA"].RecentIncluded
// Block 2 has no txs from peerA — EMA should decay.
chain.addBlock(2, nil)
chain.sendHead(2)
waitStep(t, tr)
stats = tr.GetAllPeerStats()
if stats["peerA"].RecentIncluded >= ema1 {
t.Fatalf("expected EMA to decay, got %f >= %f", stats["peerA"].RecentIncluded, ema1)
}
}
func TestFinalization(t *testing.T) {
tr := New()
chain := newMockChain()
tr.Start(chain)
defer tr.Stop()
tx := makeTx(1)
tr.NotifyAccepted("peerA", []common.Hash{tx.Hash()})
// Include in block 1.
chain.addBlock(1, []*types.Transaction{tx})
chain.sendHead(1)
waitStep(t, tr)
// Not finalized yet.
stats := tr.GetAllPeerStats()
if stats["peerA"].Finalized != 0 {
t.Fatalf("expected Finalized=0 before finalization, got %d", stats["peerA"].Finalized)
}
// Finalize block 1, then send head 2 to trigger checkFinalization.
chain.setFinalBlock(1)
chain.addBlock(2, nil)
chain.sendHead(2)
waitStep(t, tr)
stats = tr.GetAllPeerStats()
if stats["peerA"].Finalized != 1 {
t.Fatalf("expected Finalized=1 after finalization, got %d", stats["peerA"].Finalized)
}
}
func TestMultiplePeers(t *testing.T) {
tr := New()
chain := newMockChain()
tr.Start(chain)
defer tr.Stop()
tx1 := makeTx(1)
tx2 := makeTx(2)
tr.NotifyAccepted("peerA", []common.Hash{tx1.Hash()})
tr.NotifyAccepted("peerB", []common.Hash{tx2.Hash()})
// Both included in block 1.
chain.addBlock(1, []*types.Transaction{tx1, tx2})
chain.sendHead(1)
waitStep(t, tr)
// Finalize.
chain.setFinalBlock(1)
chain.addBlock(2, nil)
chain.sendHead(2)
waitStep(t, tr)
stats := tr.GetAllPeerStats()
if stats["peerA"].Finalized != 1 {
t.Fatalf("peerA: expected Finalized=1, got %d", stats["peerA"].Finalized)
}
if stats["peerB"].Finalized != 1 {
t.Fatalf("peerB: expected Finalized=1, got %d", stats["peerB"].Finalized)
}
}
func TestFirstDelivererWins(t *testing.T) {
tr := New()
chain := newMockChain()
tr.Start(chain)
defer tr.Stop()
tx := makeTx(1)
tr.NotifyAccepted("peerA", []common.Hash{tx.Hash()})
tr.NotifyAccepted("peerB", []common.Hash{tx.Hash()}) // duplicate, should be ignored
chain.addBlock(1, []*types.Transaction{tx})
chain.sendHead(1)
waitStep(t, tr)
chain.setFinalBlock(1)
chain.addBlock(2, nil)
chain.sendHead(2)
waitStep(t, tr)
stats := tr.GetAllPeerStats()
if stats["peerA"].Finalized != 1 {
t.Fatalf("peerA should be credited, got Finalized=%d", stats["peerA"].Finalized)
}
if stats["peerB"].Finalized != 0 {
t.Fatalf("peerB should NOT be credited, got Finalized=%d", stats["peerB"].Finalized)
}
}
func TestNoFinalizationCredit(t *testing.T) {
tr := New()
chain := newMockChain()
tr.Start(chain)
defer tr.Stop()
tx := makeTx(1)
tr.NotifyAccepted("peerA", []common.Hash{tx.Hash()})
// Include but don't finalize.
chain.addBlock(1, []*types.Transaction{tx})
chain.sendHead(1)
waitStep(t, tr)
// Send more heads without finalization.
chain.addBlock(2, nil)
chain.sendHead(2)
waitStep(t, tr)
stats := tr.GetAllPeerStats()
if stats["peerA"].Finalized != 0 {
t.Fatalf("expected Finalized=0 without finalization, got %d", stats["peerA"].Finalized)
}
}
func TestEMADecay(t *testing.T) {
tr := New()
chain := newMockChain()
tr.Start(chain)
defer tr.Stop()
tx := makeTx(1)
tr.NotifyAccepted("peerA", []common.Hash{tx.Hash()})
// Include in block 1.
chain.addBlock(1, []*types.Transaction{tx})
chain.sendHead(1)
waitStep(t, tr)
// Send 30 empty blocks — EMA should decay close to zero.
for i := uint64(2); i <= 31; i++ {
chain.addBlock(i, nil)
chain.sendHead(i)
waitStep(t, tr)
}
stats := tr.GetAllPeerStats()
if stats["peerA"].RecentIncluded > 0.02 {
t.Fatalf("expected RecentIncluded near zero after 30 empty blocks, got %f", stats["peerA"].RecentIncluded)
}
}
// TestReorgSafety verifies that handleChainHead resolves the head block by
// HASH (not just by number), so a head event announcing a sibling block at
// the same height does not credit transactions from the canonical block.
//
// Regression check: if the tracker were changed to use GetBlockByNumber,
// it would always fetch the canonical block A and credit peerA even when
// the head points to sibling B.
func TestReorgSafety(t *testing.T) {
tr := New()
chain := newMockChain()
tr.Start(chain)
defer tr.Stop()
tx := makeTx(1)
tr.NotifyAccepted("peerA", []common.Hash{tx.Hash()})
// Two blocks at height 1: canonical A contains tx; sibling B does not.
blockA := chain.addBlockAtHeight(1, 1, []*types.Transaction{tx}, true)
blockB := chain.addBlockAtHeight(1, 2, nil, false)
if blockA.Hash() == blockB.Hash() {
t.Fatal("sibling blocks ended up with the same hash")
}
// Head announces sibling B. A hash-aware tracker fetches B, sees no
// peerA txs, and leaves the EMA at zero. A number-only tracker would
// instead fetch A and credit peerA.
chain.sendHeadBlock(blockB)
waitStep(t, tr)
if got := tr.GetAllPeerStats()["peerA"].RecentIncluded; got != 0 {
t.Fatalf("expected RecentIncluded=0 after sibling-B head event, got %f (tracker followed the wrong block)", got)
}
// Now announce canonical A; peerA should be credited.
chain.sendHeadBlock(blockA)
waitStep(t, tr)
if got := tr.GetAllPeerStats()["peerA"].RecentIncluded; got <= 0 {
t.Fatalf("expected RecentIncluded>0 after canonical-A head event, got %f", got)
}
}