From 8bfddee2ea4c45988e4af96ee434bf5e89295471 Mon Sep 17 00:00:00 2001 From: Csaba Kiraly Date: Fri, 10 Apr 2026 09:07:38 +0200 Subject: [PATCH] eth: add tests for txtracker and dropper peer protection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit txtracker tests (7 tests): - NotifyReceived: stats empty before chain events - InclusionEMA: EMA increases on inclusion, decays on empty blocks - Finalization: Finalized counter credited after finalization - MultiplePeers: each peer credited for own txs only - FirstDelivererWins: duplicate delivery ignored - NoFinalizationCredit: no credit without finalization - EMADecay: EMA approaches zero after 30 empty blocks dropper tests (6 tests): - FilterProtectedNoStats: nil stats → all droppable - FilterProtectedEmptyStats: empty map → all droppable - FilterProtectedTopPeer: top-scored peers removed from droppable - FilterProtectedZeroScore: zero scores → no protection - FilterProtectedOverlap: peer top in both categories → counted once - FilterProtectedAllProtected: all droppable protected → empty list Also fix: create peer entries during EMA update for peers with inclusions in the current block (previously only created during finalization, so EMA was not tracked before first finalization). --- eth/dropper_test.go | 125 ++++++++++++++++ eth/txtracker/tracker.go | 6 + eth/txtracker/tracker_test.go | 262 ++++++++++++++++++++++++++++++++++ 3 files changed, 393 insertions(+) create mode 100644 eth/dropper_test.go create mode 100644 eth/txtracker/tracker_test.go diff --git a/eth/dropper_test.go b/eth/dropper_test.go new file mode 100644 index 0000000000..8c08893ee3 --- /dev/null +++ b/eth/dropper_test.go @@ -0,0 +1,125 @@ +package eth + +import ( + "fmt" + "testing" + + "github.com/ethereum/go-ethereum/p2p" + "github.com/ethereum/go-ethereum/p2p/enode" +) + +func makePeers(n int) []*p2p.Peer { + peers := make([]*p2p.Peer, n) + for i := range peers { + id := enode.ID{byte(i)} + peers[i] = p2p.NewPeer(id, fmt.Sprintf("peer%d", i), nil) + } + return peers +} + +func TestFilterProtectedNoStats(t *testing.T) { + // When the stats func returns nil/empty, all peers remain droppable. + cm := &dropper{maxDialPeers: 20, maxInboundPeers: 30} + cm.peerStatsFunc = func() map[string]PeerInclusionStats { return nil } + + peers := makePeers(10) + result := cm.filterProtectedPeers(peers) + if len(result) != len(peers) { + t.Fatalf("expected all peers droppable with nil stats, got %d/%d", len(result), len(peers)) + } +} + +func TestFilterProtectedEmptyStats(t *testing.T) { + cm := &dropper{maxDialPeers: 20, maxInboundPeers: 30} + cm.peerStatsFunc = func() map[string]PeerInclusionStats { + return map[string]PeerInclusionStats{} + } + + peers := makePeers(10) + result := cm.filterProtectedPeers(peers) + if len(result) != len(peers) { + t.Fatalf("expected all peers droppable with empty stats, got %d/%d", len(result), len(peers)) + } +} + +func TestFilterProtectedTopPeer(t *testing.T) { + // 20 peers, maxDialPeers=20, 10% = 2 protected per category. + // NewPeer creates non-inbound peers, so all go to dialed bucket. + cm := &dropper{maxDialPeers: 20, maxInboundPeers: 30} + + peers := makePeers(20) + stats := make(map[string]PeerInclusionStats) + // Peer 0: top by Finalized + stats[peers[0].ID().String()] = PeerInclusionStats{Finalized: 100} + // Peer 1: top by RecentIncluded + stats[peers[1].ID().String()] = PeerInclusionStats{RecentIncluded: 5.0} + + cm.peerStatsFunc = func() map[string]PeerInclusionStats { return stats } + + result := cm.filterProtectedPeers(peers) + // 2 categories × 2 protected each = up to 4, but peers 0 and 1 are + // different so both should be removed. Other peers have zero scores. + protected := len(peers) - len(result) + if protected != 2 { + t.Fatalf("expected 2 protected peers, got %d", protected) + } + // Verify peers 0 and 1 are not in result. + for _, p := range result { + id := p.ID().String() + if id == peers[0].ID().String() || id == peers[1].ID().String() { + t.Fatalf("peer %s should be protected", id) + } + } +} + +func TestFilterProtectedZeroScore(t *testing.T) { + cm := &dropper{maxDialPeers: 20, maxInboundPeers: 30} + + peers := makePeers(10) + stats := make(map[string]PeerInclusionStats) + // All peers have zero stats. + for _, p := range peers { + stats[p.ID().String()] = PeerInclusionStats{} + } + cm.peerStatsFunc = func() map[string]PeerInclusionStats { return stats } + + result := cm.filterProtectedPeers(peers) + if len(result) != len(peers) { + t.Fatalf("expected no protection with zero scores, got %d protected", len(peers)-len(result)) + } +} + +func TestFilterProtectedOverlap(t *testing.T) { + // One peer is top in both categories — should only be removed once. + cm := &dropper{maxDialPeers: 20, maxInboundPeers: 30} + + peers := makePeers(20) + stats := make(map[string]PeerInclusionStats) + // Peer 0 is top in both. + stats[peers[0].ID().String()] = PeerInclusionStats{Finalized: 100, RecentIncluded: 5.0} + + cm.peerStatsFunc = func() map[string]PeerInclusionStats { return stats } + + result := cm.filterProtectedPeers(peers) + protected := len(peers) - len(result) + if protected != 1 { + t.Fatalf("expected 1 protected peer (overlap), got %d", protected) + } +} + +func TestFilterProtectedAllProtected(t *testing.T) { + // Only 2 droppable peers, both are top by different categories. + cm := &dropper{maxDialPeers: 20, maxInboundPeers: 30} + + peers := makePeers(2) + stats := make(map[string]PeerInclusionStats) + stats[peers[0].ID().String()] = PeerInclusionStats{Finalized: 100} + stats[peers[1].ID().String()] = PeerInclusionStats{RecentIncluded: 5.0} + + cm.peerStatsFunc = func() map[string]PeerInclusionStats { return stats } + + result := cm.filterProtectedPeers(peers) + if len(result) != 0 { + t.Fatalf("expected all peers protected, got %d droppable", len(result)) + } +} diff --git a/eth/txtracker/tracker.go b/eth/txtracker/tracker.go index aa962cdf32..5a18402645 100644 --- a/eth/txtracker/tracker.go +++ b/eth/txtracker/tracker.go @@ -156,6 +156,12 @@ func (t *Tracker) handleChainHead(ev core.ChainHeadEvent) { blockIncl[peer]++ } } + // Ensure peers with inclusions in this block have entries. + for peer := range blockIncl { + if t.peers[peer] == nil { + t.peers[peer] = &peerStats{} + } + } // Update EMA for all tracked peers (decay inactive ones). for peer, ps := range t.peers { ps.recentIncluded = (1-emaAlpha)*ps.recentIncluded + emaAlpha*float64(blockIncl[peer]) diff --git a/eth/txtracker/tracker_test.go b/eth/txtracker/tracker_test.go new file mode 100644 index 0000000000..dc346f5255 --- /dev/null +++ b/eth/txtracker/tracker_test.go @@ -0,0 +1,262 @@ +package txtracker + +import ( + "math/big" + "sync" + "testing" + "time" + + "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. +type mockChain struct { + mu sync.Mutex + headFeed event.Feed + blocks map[uint64]*types.Block + finalNum uint64 +} + +func newMockChain() *mockChain { + return &mockChain{blocks: make(map[uint64]*types.Block)} +} + +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() + return c.blocks[number] +} + +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)} +} + +func (c *mockChain) addBlock(num uint64, txs []*types.Transaction) { + c.mu.Lock() + defer c.mu.Unlock() + header := &types.Header{Number: new(big.Int).SetUint64(num)} + c.blocks[num] = types.NewBlock(header, &types.Body{Transactions: txs}, nil, trie.NewListHasher()) +} + +func (c *mockChain) setFinalBlock(num uint64) { + c.mu.Lock() + defer c.mu.Unlock() + c.finalNum = num +} + +func (c *mockChain) sendHead(num uint64) { + c.headFeed.Send(core.ChainHeadEvent{ + Header: &types.Header{Number: new(big.Int).SetUint64(num)}, + }) +} + +func makeTx(nonce uint64) *types.Transaction { + return types.NewTx(&types.LegacyTx{Nonce: nonce, GasPrice: big.NewInt(1), Gas: 21000}) +} + +// waitForHead gives the tracker time to process a chain head event. +func waitForHead() { + time.Sleep(50 * time.Millisecond) +} + +func TestNotifyReceived(t *testing.T) { + tr := New() + chain := newMockChain() + tr.Start(chain) + defer tr.Stop() + + txs := []*types.Transaction{makeTx(1), makeTx(2), makeTx(3)} + tr.NotifyReceived("peerA", txs) + + // No chain events yet — stats should be empty. + stats := tr.GetAllPeerStats() + if len(stats) != 0 { + t.Fatalf("expected empty stats before any chain events, got %d peers", len(stats)) + } +} + +func TestInclusionEMA(t *testing.T) { + tr := New() + chain := newMockChain() + tr.Start(chain) + defer tr.Stop() + + tx := makeTx(1) + tr.NotifyReceived("peerA", []*types.Transaction{tx}) + + // Block 1 includes peerA's tx. + chain.addBlock(1, []*types.Transaction{tx}) + chain.sendHead(1) + waitForHead() + + 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) + waitForHead() + + 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.NotifyReceived("peerA", []*types.Transaction{tx}) + + // Include in block 1. + chain.addBlock(1, []*types.Transaction{tx}) + chain.sendHead(1) + waitForHead() + + // 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) + waitForHead() + + 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.NotifyReceived("peerA", []*types.Transaction{tx1}) + tr.NotifyReceived("peerB", []*types.Transaction{tx2}) + + // Both included in block 1. + chain.addBlock(1, []*types.Transaction{tx1, tx2}) + chain.sendHead(1) + waitForHead() + + // Finalize. + chain.setFinalBlock(1) + chain.addBlock(2, nil) + chain.sendHead(2) + waitForHead() + + 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.NotifyReceived("peerA", []*types.Transaction{tx}) + tr.NotifyReceived("peerB", []*types.Transaction{tx}) // duplicate, should be ignored + + chain.addBlock(1, []*types.Transaction{tx}) + chain.sendHead(1) + waitForHead() + + chain.setFinalBlock(1) + chain.addBlock(2, nil) + chain.sendHead(2) + waitForHead() + + 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.NotifyReceived("peerA", []*types.Transaction{tx}) + + // Include but don't finalize. + chain.addBlock(1, []*types.Transaction{tx}) + chain.sendHead(1) + waitForHead() + + // Send more heads without finalization. + chain.addBlock(2, nil) + chain.sendHead(2) + waitForHead() + + 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.NotifyReceived("peerA", []*types.Transaction{tx}) + + // Include in block 1. + chain.addBlock(1, []*types.Transaction{tx}) + chain.sendHead(1) + waitForHead() + + // Send 30 empty blocks — EMA should decay close to zero. + for i := uint64(2); i <= 31; i++ { + chain.addBlock(i, nil) + chain.sendHead(i) + waitForHead() + } + + stats := tr.GetAllPeerStats() + if stats["peerA"].RecentIncluded > 0.02 { + t.Fatalf("expected RecentIncluded near zero after 30 empty blocks, got %f", stats["peerA"].RecentIncluded) + } +}