Skip to content

eth: protect high-value peers from random dropping based on tx inclusion stats - #34702

Merged
cskiraly merged 38 commits into
ethereum:masterfrom
cskiraly:peerdrop-protected-pools
Jul 9, 2026
Merged

eth: protect high-value peers from random dropping based on tx inclusion stats#34702
cskiraly merged 38 commits into
ethereum:masterfrom
cskiraly:peerdrop-protected-pools

Conversation

@cskiraly

@cskiraly cskiraly commented Apr 11, 2026

Copy link
Copy Markdown
Contributor

The peer dropper periodically disconnects random peers to create churn. This was previously blind to peer quality.
This PR adds peer-score based peer protection, handling the multi-dimensionality problem of peer scoring through the concept of protected peer pools.

Assisted-by: Claude:claude-3-opus

@cskiraly
cskiraly marked this pull request as ready for review April 13, 2026 08:02
@cskiraly
cskiraly requested a review from rjl493456442 as a code owner April 13, 2026 08:02
@cskiraly
cskiraly requested review from fjl and healthykim April 13, 2026 08:03
Comment thread eth/dropper.go Outdated
Comment thread eth/dropper.go Outdated
cskiraly added 16 commits June 23, 2026 10:09
… stats

The dropper periodically disconnects random peers to create churn.
This was blind to peer quality. Add inclusion-based peer protection
using two categories:

1. Total inclusions: protects peers with the highest cumulative
   count of delivered txs that were included on chain
2. Recent inclusions (EMA): protects peers with the best recent
   inclusion rate, giving newly productive peers faster protection

Each category independently protects the top 10% of inbound and
top 10% of dialed peers. The union of both sets is protected. Only
peers with positive scores qualify.

The dropper defines its own PeerInclusionStats struct and callback
type (getPeerInclusionStatsFunc) so any stats provider (e.g. a
transaction tracker) can plug in without a package dependency. The
callback is nil by default (protection disabled until wired).

The protectionCategories slice is designed for easy extension —
adding a new category requires only appending a struct with a name,
scoring function, and protection fraction.
Minimal txtracker that records which peer delivered each transaction
and credits peers when their transactions appear on chain. Provides
the PeerInclusionStats needed by the dropper's protection logic.

Design:
- NotifyReceived(peer, txs): records deliverer per tx hash (called
  from handler_eth.go when tx bodies arrive via P2P)
- Subscribes to ChainHeadEvent, fetches block txs, credits the
  delivering peer for each included tx
- Per-peer EMA of recent inclusions (alpha=0.05), updated every block
- LRU eviction at 262K entries to bound memory
- Mutex-based (not channel-based) for simplicity — the hot path
  (NotifyReceived) is a fast map insert

Wired into the dropper via an adapter callback in backend.go that
converts txtracker.PeerStats to the dropper's PeerInclusionStats.
Change the long-term protection category from total inclusions to
total finalized inclusions. Finalized txs are harder to game (require
actual block finality, not just inclusion) and represent confirmed
on-chain value.

The recent-inclusion EMA stays on chain head inclusions for
responsiveness — a peer delivering txs that appear in the latest
blocks gets quick protection without waiting for finalization.

The tracker now checks CurrentFinalBlock() on each chain head event
and credits delivering peers for all newly finalized blocks since
the last check.
Expand the txtracker package doc to describe the tracking flow
(NotifyReceived → chain head → finalization → peer credit) and its
role as stats provider for the dropper.

Rewrite the dropper struct comment to document the full behavior
including the inclusion-based peer protection: two scoring categories
(total finalized + recent EMA), top 10% per pool, union of protected
sets.
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).
lastFinalNum started at 0, so the first checkFinalization after
startup iterated from block 1 to the current finalized head (~20M
blocks on mainnet) under the mutex, stalling the tracker and
potentially awarding bogus credit for ancient txs whose hashes
happened to match recently-received ones.

Seed lastFinalNum from chain.CurrentFinalBlock() in Start() so only
blocks finalized after startup are processed.
NotifyReceived was called before pool validation, allowing a peer
to claim deliverer credit by replaying already-included txs or
sending invalid packets.

Rename to NotifyAccepted (takes hashes, not full txs). Call it from
a new enqueueAndTrack helper in handler_eth.go that runs after
Enqueue and checks pool.Has to identify accepted txs. Only accepted
txs are credited to the delivering peer.
handleChainHead fetched the block by number only. If the tracker
goroutine lagged and that height was reorged before processing,
the EMA was computed from the wrong canonical block.

Use GetBlock(hash, number) with the header hash from the event to
fetch the exact block the event refers to, not whatever is currently
canonical at that height.
Peer stats were never pruned, so the peers map grew with every peer
ever seen. The EMA decay loop and stats copy iterated all historical
peers on every block/query.

Add NotifyPeerDrop(peer) that deletes the peer's stats entry. Called
from handler.unregisterPeer alongside txFetcher.Drop.
protectTopN used maxPeers (configured capacity) to compute the
number of peers to protect. With small droppable sets this could
protect everyone, permanently disabling churn.

Use len(entries) (current droppable count in each category) instead.
With 20 droppable dialed peers and 10% fraction, 2 are protected.
With 3 droppable peers, 0 are protected — churn is never blocked.
Add the standard go-ethereum LGPL header to tracker.go,
tracker_test.go, and dropper_test.go.
enqueueAndTrack used pool.Has() after Enqueue to determine accepted
txs. Under concurrent delivery of the same tx from two peers, both
could see Has()==true, making attribution non-deterministic.

Add an onAccepted callback to the fetcher, called from Enqueue with
(peer, acceptedHashes) immediately after pool.Add returns for each
batch. Attribution happens atomically inside Enqueue using the per-tx
error from addTxs (nil = accepted), before another goroutine can
race.

Remove the enqueueAndTrack helper from handler_eth.go — the fetcher
now handles notification directly.
Compute the protected peer set once in dropRandomPeer via
protectedPeers(), then include protection as a condition in
selectDoNotDrop alongside trusted/static/recent checks. This
eliminates the separate filterProtectedPeers post-pass and the
awkward "all protected → skip" branch.

Rename filterProtectedPeers to protectedPeers, returning
map[*p2p.Peer]bool instead of filtering a slice. The map is
checked directly in selectDoNotDrop via protected[p].
Replace peerWithStats wrapper, manual slice copying, and protectTopN
closure with a generic topN[T] function that sorts by score and
returns top elements. protectedPeers now works directly with
[]*p2p.Peer slices, building per-category score functions that close
over the stats map.
Remove the custom topN generic function. Use slices.SortedFunc
(creates a sorted copy from an iterator) + slices.DeleteFunc (filters
score <= 0) from the standard library. No custom generics needed.
cskiraly added 18 commits June 23, 2026 10:11
Signed-off-by: Csaba Kiraly <csaba.kiraly@gmail.com>
order = order[1:] reslices without releasing the backing array.
After N total insertions the array retains N hashes (32 bytes each)
but only the last maxTracked are live. On a long-running node
processing ~100 txs/s this leaks ~275 MB/day.

Compact by copying to a fresh array when capacity exceeds 2×maxTracked.
NotifyPeerDrop deleted t.peers[peer] but left t.txs entries pointing
to that peer. When those txs later finalized, checkFinalization
recreated the peer entry, and the EMA loop decayed it forever.

Fix: create peer entries in NotifyAccepted (when txs are first
accepted), not in handleChainHead or checkFinalization. Both chain
event handlers now skip peers with no entry — disconnected peers
whose entries were deleted by NotifyPeerDrop stay deleted.
Tests used time.Sleep(50ms) to wait for async chain head processing,
making the suite slow (~2s) and flaky under CI load.

Add a step channel (buffered 1) to the Tracker, sent after each
event in the loop. Tests wait on the channel with a 1s timeout.
Suite now completes in <10ms.
Fix goimports violations: align callback field comments in
tx_fetcher.go, sort import of eth/txtracker after eth/protocols
in handler.go. Add missing onAccepted parameter (nil) to the
txfetcher fuzzer.
When neither the dialed nor inbound peer pool is close to capacity,
every non-trusted/non-static peer is already marked do-not-drop by
the pool-threshold rules in selectDoNotDrop, so the droppable set is
guaranteed empty regardless of inclusion protection.

Return early in that case to avoid the wasted peerStatsFunc call,
per-direction split, and per-category sort in protectedPeers.
Rename eth/dropper/protected to eth/dropper/skipped and mark it on
every skip (fast-path headroom, all candidates un-droppable, or
protection emptied the list). Add eth/dropper/skipped_protected to
count the subset of skips where at least one otherwise-droppable
peer was kept only because of inclusion protection.

The pair lets operators see both the total churn-miss rate and how
often peer protection specifically is the cause.
The original assertions read stats["peerA"].Finalized and .RecentIncluded,
both of which return zero for a missing key — so the test would pass even
if NotifyAccepted were a complete no-op, contradicting its stated purpose.

Assert that exactly one peer entry exists, peerA is present, and the
internal txs map and FIFO order slice are populated as NotifyAccepted is
meant to do.
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.
The protection feature promises top-N per inbound/dialed pool, but
every existing test constructed peers via p2p.NewPeer (which produces
no-flag peers), so all test peers landed in the dialed pool and the
per-pool split was never validated.

Extract the selection logic from protectedPeers into a pure helper
protectedPeersByPool(inbound, dialed, stats) that accepts pre-split
pools. This sidesteps the unexported p2p.connFlag types and makes the
interesting behavior directly testable. Add three tests covering:

  - exact top-N selected independently in each pool
  - cross-category union with overlap deduplication
  - per-pool independence: top dialed peers stay protected even when
    every inbound peer scores higher globally
The skipped_protected metric (added earlier on this branch) counted the
subset of drop skips where inclusion protection was the cause. The
signal can be inferred from rising dropSkipped rate plus the existing
"Protecting high-value peers" debug log, which wasn't worth the second
metric, the causality-check loop over the protected set, and the
baseNotDrop closure extracted solely to share the predicate.

Collapse baseNotDrop back into selectDoNotDrop and remove the metric.
dropSkipped still fires on every skip (fast-path headroom + all-filtered).
…ctly

PeerInclusionStats was declared identically to txtracker.PeerStats as a
decoupling abstraction: any stats provider could implement the dropper's
callback by returning this shape. In practice there's one provider and
the two types were kept in sync by a rote copy adapter in backend.go.

Delete PeerInclusionStats, have the dropper consume txtracker.PeerStats
directly via getPeerStatsFunc. backend.go now passes
txTracker.GetAllPeerStats as the callback with no adapter.

If a second stats provider ever appears, the abstraction can come back;
until then, one fewer type and 8 fewer lines of ceremony.
… EMA

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.
Add a per-entry arrival timestamp at NotifyAccepted time and skip inclusion
and finalization credit when the delivery was at or after the slot of the
inclusion block. This prevents a peer that learned a tx from the
just-propagated block (or any post-slot source) from harvesting credit it
didn't earn through genuine pre-slot relay work.

Does not defeat builder-feed peers that deliver within the feed's lead
time before slot start; but that's also not fundamentally different from
sending to the mempool in the first place.

Signed-off-by: Csaba Kiraly <csaba.kiraly@gmail.com>
…r model

No functional change. Prepares the minimal model to mirror the field
naming of the richer txtracker variants on adjacent branches so that
upcoming cherry-picks (lastFinalNum seed, IncludedDeliverer freeze,
collectFinalization pivot to iterate t.txs) can apply with smaller
deltas.

Changes:
  - txEntry         -> TxInfo (exported, pointer in t.txs map)
  - txEntry.peer    -> TxInfo.Deliverer
  - txEntry.addedAt -> TxInfo.AddedAt
  - add TxStatus enum (StatusUnknown / StatusIncluded / StatusFinalized);
    not yet assigned on any code path here
  - add TxInfo.BlockNum and TxInfo.BlockHash; not yet assigned
Instead of walking every newly-finalized block from disk, iterate the
tracker's existing per-tx state (which already records BlockNum and
BlockHash at inclusion time) and confirm canonicality with one cheap
GetCanonicalHash lookup per unique BlockNum.

The walk-by-block design was the dominant cost during catch-up after a
restart: the chain's recent-block LRU does not cover blocks that
finalized 32+ blocks ago, so each iteration paid a full RLP decode plus
fresh tx.Hash() and types.Sender() against cache-cold *Transaction
instances. With ~150 txs/block × ~100µs ECDSA × N blocks of finality to
catch up, that scales to seconds-to-minutes of CPU on long restarts —
and the per-tx work was waste, since most decoded txs are not tracked.

The pivot keeps the same correctness guarantee. Each TxInfo's BlockHash
is what we recorded at inclusion; comparing it to the canonical hash at
that height confirms the tx really is on the canonical chain (an
orphan-block reference would not match). When the recorded hash and
canonical hash agree, transition the entry to StatusFinalized; when
they disagree, skip — the tx's recorded inclusion was reorged out and
the entry will see a fresh inclusion or eviction signal through the
normal head/reorg path.

Adds GetCanonicalHash to the Chain interface; *core.BlockChain already
satisfies it. The mock in the unit tests gains a tiny stub.

(cherry picked from commit 7a44f66118467c3a30d89e29685235078dfe1e4c)
The finalization-credit pivot replaced the per-block walk with an
iterate-t.txs scan, eliminating the only caller of GetBlockByNumber in
this package. Drop it from the Chain interface and from mockChain.
*core.BlockChain still satisfies the slimmed interface (it has all the
remaining methods); production wiring is unchanged.

Updates two stale test comments that referenced the removed call.
@cskiraly
cskiraly force-pushed the peerdrop-protected-pools branch from e9ecf5d to ffb92fe Compare June 23, 2026 08:39
@cskiraly

Copy link
Copy Markdown
Contributor Author

@fjl @healthykim I've rebased this and extended it with 2 things:

  • a better way to handle finalizations without loading block content (needs a few MBs of memory for the hashes, but less block body and disk access)
  • protection agains gaining credit by forwarding "just arrived in block" transactions

Ready for review

@healthykim healthykim left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@cskiraly I pushed some changes, I think it is good to merge

@cskiraly cskiraly added this to the 1.17.5 milestone Jul 9, 2026
@cskiraly
cskiraly merged commit 111e7b8 into ethereum:master Jul 9, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants