eth: add tests for txtracker and dropper peer protection

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).
This commit is contained in:
Csaba Kiraly 2026-04-10 09:07:38 +02:00
parent 58556173f6
commit 8bfddee2ea
3 changed files with 393 additions and 0 deletions

125
eth/dropper_test.go Normal file
View file

@ -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))
}
}

View file

@ -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])

View file

@ -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)
}
}