mirror of
https://github.com/ethereum/go-ethereum.git
synced 2026-05-24 16:59:26 +00:00
The total-finalized protection category ranked peers by a monotonic cumulative count, so a peer that had been productive in the past kept a high score forever — even if they had since gone silent — and held a protected slot without contributing. Replace txtracker.PeerStats.Finalized (int64 cumulative) with RecentFinalized (float64 EMA). On each chain head, finalization credits accumulated over the newly-finalized range are folded into a slow EMA (alpha=0.0001, half-life ~6930 blocks ≈ 23 hours on 12s mainnet blocks). Peers that continue contributing keep a high score; peers that stop decay toward zero over roughly a day. The dropper category renames to "recent-finalized" accordingly. The type's docstring is rewritten to describe both categories as EMAs with different time horizons (slow finalized, fast included). Refactors checkFinalization to return a per-peer credits map rather than mutating state directly, so both EMAs update in the same loop over tracked peers.
456 lines
13 KiB
Go
456 lines
13 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.RecentFinalized != 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"].RecentFinalized != 0 {
|
||
t.Fatalf("expected RecentFinalized=0 before finalization, got %f", stats["peerA"].RecentFinalized)
|
||
}
|
||
|
||
// Finalize block 1, then send head 2 to trigger the finalization EMA update.
|
||
chain.setFinalBlock(1)
|
||
chain.addBlock(2, nil)
|
||
chain.sendHead(2)
|
||
waitStep(t, tr)
|
||
|
||
stats = tr.GetAllPeerStats()
|
||
if stats["peerA"].RecentFinalized <= 0 {
|
||
t.Fatalf("expected RecentFinalized>0 after finalization, got %f", stats["peerA"].RecentFinalized)
|
||
}
|
||
}
|
||
|
||
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"].RecentFinalized <= 0 {
|
||
t.Fatalf("peerA: expected RecentFinalized>0, got %f", stats["peerA"].RecentFinalized)
|
||
}
|
||
if stats["peerB"].RecentFinalized <= 0 {
|
||
t.Fatalf("peerB: expected RecentFinalized>0, got %f", stats["peerB"].RecentFinalized)
|
||
}
|
||
}
|
||
|
||
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"].RecentFinalized <= 0 {
|
||
t.Fatalf("peerA should be credited, got RecentFinalized=%f", stats["peerA"].RecentFinalized)
|
||
}
|
||
if stats["peerB"].RecentFinalized != 0 {
|
||
t.Fatalf("peerB should NOT be credited, got RecentFinalized=%f", stats["peerB"].RecentFinalized)
|
||
}
|
||
}
|
||
|
||
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"].RecentFinalized != 0 {
|
||
t.Fatalf("expected RecentFinalized=0 without finalization, got %f", stats["peerA"].RecentFinalized)
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
|
||
// TestRecentFinalizedDecays verifies that the finalization EMA decays
|
||
// for a peer that earned credits in the past but has no new
|
||
// finalization activity. The decay is slow (α=0.0001), so we
|
||
// just assert monotonic decrease, not convergence to zero.
|
||
func TestRecentFinalizedDecays(t *testing.T) {
|
||
tr := New()
|
||
chain := newMockChain()
|
||
tr.Start(chain)
|
||
defer tr.Stop()
|
||
|
||
tx := makeTx(1)
|
||
tr.NotifyAccepted("peerA", []common.Hash{tx.Hash()})
|
||
|
||
// Include and finalize in block 1.
|
||
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)
|
||
|
||
peak := tr.GetAllPeerStats()["peerA"].RecentFinalized
|
||
if peak <= 0 {
|
||
t.Fatalf("expected RecentFinalized>0 after finalization, got %f", peak)
|
||
}
|
||
|
||
// Send many empty heads — peer contributes zero each block,
|
||
// EMA should decay monotonically.
|
||
for i := uint64(3); i <= 50; i++ {
|
||
chain.addBlock(i, nil)
|
||
chain.sendHead(i)
|
||
waitStep(t, tr)
|
||
}
|
||
|
||
after := tr.GetAllPeerStats()["peerA"].RecentFinalized
|
||
if after >= peak {
|
||
t.Fatalf("expected RecentFinalized to decay, got %f >= peak %f", after, peak)
|
||
}
|
||
}
|