CHAMP: Hardening the Mempool with CHain-Anchored, Multi-dimensional Peer Protection

Author: @cskiraly

TL;DR

  • Clients keep their peer set fresh with random churn — periodically dropping random peers. This keeps the network open to newcomers and resistant to eclipse attacks, but it is blind to peer quality: it drops our most useful peers as readily as idle ones.
  • We add CHAMP, a set of protected peer pools: in each pool, a small set of our champion peers is shielded from random dropping, layered on top of churn. The churn rate is unchanged: drops simply land on the unprotected majority (≥70% of each pool, even with all three categories).
  • Peer quality is multi-dimensional. Instead of collapsing it into a single score, we protect the top slice in each dimension independently — a peer survives if it is valuable along any one axis.
  • Two of the three categories are anchored to on-chain outcomes: they credit a peer only when transactions it delivered first get included at the head and, later, finalized. The signal is objective, and the way to earn it is to feed us transactions that land on-chain.
  • The design is on by default and extensible — new quality dimensions slot in without re-tuning a global score. Shipped in go-ethereum v1.17.5 (#34702); request-latency protection (the third dimension) is in progress in #34771.

Introduction

Every node in a peer-to-peer network keeps a bounded number of connections. In an open network that creates a subtle problem: once established nodes fill their slots they rarely free them, newcomers struggle to find room, and the connection graph ossifies — bad for accessibility, and bad for security, since a static, predictable graph is easier to attack. The standard fix is to keep the peer set churning: periodically drop peers at random so slots keep reopening and the graph never stays fixed.

That random dropping does its job, but it is blind to quality. The peer that has been reliably feeding us transactions that end up on-chain is exactly as droppable as one that has done nothing for us yet. In keeping the door open for newcomers, we keep evicting the peers we would most like to keep.

The transaction mempool makes this sting more, because it lives at the edge of the protocol. It is off-chain: there is no consensus on who behaved well, no ground truth about which peer is worth keeping — yet its health (how fresh it is, how fast transactions propagate) depends heavily on which peers we are connected to.

We want to keep the churn and the random graph, and add a quality overlay on top: protect the peers that are demonstrably useful, and let the rest churn freely. The rest of this post builds that overlay — and, crucially, does it without collapsing peer quality into a single score.

The idea of chain-anchored peer protection goes back to early 2025, partly implemented in #31974. Inclusion-based protection has since shipped in go-ethereum v1.17.5 (#34702), and request-latency protection is in progress in #34771.

Saturated peer sets, and the churn that fixes them

This problem is not specific to Geth, or even to Ethereum: it shows up in any open peer-to-peer network where nodes have a bounded number of connection slots.

A node accepts a bounded number of connections. On a well-established, long-running node those slots fill up — especially the inbound ones — and then stay full, because there is no reason for a healthy long-lived connection to close on its own. From the incumbent’s point of view everything is fine. From a new joiner’s point of view the network looks closed: every good node it tries to reach is already at capacity and turns it away. The network drifts toward an ossified set of old connections, and newcomers have a hard time getting in at all. Not something we can afford in a permissionless network.

The established remedy is induced churn: deliberately perturb each node’s connections so that slots keep opening up and no connection graph stays fixed for long. This is not a Geth invention. Induced churn was proposed as a defence against routing-table poisoning and eclipse attacks [1] in structured overlays by Condie et al. [2]. Why keeping the graph random matters is just as well studied: gossip-based peer sampling keeps an overlay close to a random graph and highly resilient to massive node failure [3], and CYCLON shows such overlays can combine low diameter with low clustering [4]. And the threat this defends against is concrete for Ethereum — eclipse attacks have been demonstrated against Bitcoin [5] and against Ethereum specifically [6], several of whose countermeasures shipped in geth 1.8.0 (February 2018). More recent work eclipses today’s execution-layer nodes by exploiting exactly the saturated, rarely-idle inbound slots described above [7].

Geth applies induced churn with a simple mechanism: it periodically disconnects a random peer when a pool (inbound or dialed) is full, added in 2025 in #31476. (We will call this the peer dropper, or just the dropper, for the rest of the post.) That one mechanism does a surprising amount of work:

  • It keeps the door open. Every drop frees a slot, so there is always a steady supply of openings for nodes trying to join. Accessibility for newcomers is the original motivation.
  • It keeps the overlay fresh, and keeps us exploring. Stale or low-quality connections do not linger forever. When a dialed peer is dropped, the dial scheduler refills the slot from discovery candidates, so the node keeps sampling the network and getting chances to find better peers.
  • It keeps the overlay a random graph. Random dialing plus random dropping means each node’s neighbourhood is a fresh, unpredictable sample of the network — which buys eclipse resistance (an attacker cannot cheaply pin which peers we keep) and good propagation (random regular-ish graphs have short diameters and strong expansion).

So random dropping is not a nuisance to be minimised — it is load-bearing infrastructure for an open, healthy network. Whatever we add on top must preserve the churn and the randomness, not fight them. Protected peer pools are meant to sit inside this well-understood churn regime, not to replace it.

The tension: we keep evicting our best peers

Here is the cost. “Drop a random peer” treats all peers as interchangeable, and they are not. Some peers are the reason our mempool is fresh; some are the reason our transactions propagate; some answer our requests in tens of milliseconds while others time out. Because the drop is random, a peer’s usefulness has no bearing on whether it survives the next round of churn.

Dropping one of the good ones is not fatal — we re-dial and recover — but it is wasteful, and doing it repeatedly likely hurts exactly the metrics we care about. We are paying for network openness with the currency of our best connections.

So the real question is: can we keep the churn and the random graph — the things that keep the network open — and still spare the peers that are demonstrably useful?

Why not just score peers?

The obvious answer is “score each peer and protect the top ones.” libp2p’s GossipSub already does something like this: it maintains a per-peer score and uses it for mesh maintenance and pruning [8], [9]. It is a well-tested design, and it is tempting to reach for the same hammer. Classic P2P reputation systems such as EigenTrust take a different route, aggregating peers’ local ratings of each other into a single global trust value [10].

But single-scalar scoring has a structural weakness we want to avoid: it collapses many behaviours into one number. In GossipSub, time-in-mesh, message deliveries, invalid messages, and application-specific signals are all folded together through a set of weights. That forces two uncomfortable things:

  • You have to pick the weights. A weighted score sets exchange rates between different kinds of service, and the right rates are hard to find across heterogeneous peers and changing conditions.
  • It can overlook peers that are excellent along one axis. Depending on normalization and weights, a peer that is superb at exactly one useful job but average elsewhere can rank below peers that are merely decent at everything — and get treated as average.

Peer quality in the mempool is genuinely multi-dimensional:

  • A peer whose transactions consistently get finalized — a strong signal, but slow.
  • A peer whose transactions consistently show up at the chain head right now — faster, noisier.
  • A peer that is very fast to answer requests, even when its transactions rarely appear on-chain.

Squashing these into one score throws information away by construction.

We are not the first to protect peers along several criteria. When pruning an oversubscribed mesh, GossipSub keeps its highest-scoring peers and picks the rest at random, subject to an outbound quota [9]. Closest to what we do, Bitcoin Core’s inbound eviction has protected peers along several distinct criteria since #6374 (v0.12.0, 2016) [11], originally network group, ping, and connection age, since extended with the recency of novel transactions and blocks and with network diversity. When inbound capacity runs out, it applies these protections one after another, then evicts from what is left. So multi-criteria protection is not new. What we add is anchoring it to chain outcomes, selecting each category independently and taking the union, and running it on top of timer-driven random churn of full pools — including the peers we dialed — rather than capacity-driven eviction of inbound peers:

Geth dropper + CHAMP Bitcoin Core inbound eviction
Trigger One timer, every 3–7 minutes: when synced, attempt one drop among eligible peers of full pools, independently of incoming connection demand Capacity-driven: when an inbound connection would exceed the inbound limit, or inbound transaction-relay peers exceed theirs
Scope Inbound and dynamically dialed peers; a separate dial scheduler refills outbound capacity Inbound only (outbound peers are explored and rotated through separate mechanisms)
Victim choice Uniformly random among the remaining candidates Rule-based: after protection (and any preferential-eviction filter), the youngest peer in the largest remaining network group
Protection Independent per category: up to ⌊10% of the current pool⌋ with a positive score, then unioned Sequential: fixed counts per stage (e.g. 4 by network group, 8 by lowest ping, 4 by recent novel transactions), each taken from what the previous stages left, then half of the remainder by connection age and network diversity
Signals Eventual inclusion and finality of transactions the peer delivered first; request latency in #34771 Keyed network group, minimum ping, recency of novel transactions and blocks, relay type, connection age, network diversity

Churning dialed peers is deliberate. Induced churn is not only about making room for newcomers; it is also how a node explores the topology. When a dialed peer is dropped, the dial scheduler refills the slot from discovery candidates, so the node keeps sampling the network instead of settling on the first peers it happened to find. Bitcoin explores too, through separate outbound probing; what differs here is that established peers themselves keep rotating. Protection and churn are two halves of one loop: churn explores, and protection keeps the good peers that exploration turns up. Protecting the best peers only makes sense if we keep looking for better ones.

Protected peer pools: a quality overlay, not a ranking

Protected peer pools sidestep the weighting problem entirely. Instead of ranking peers on a combined score, we rank them separately in each dimension and protect the top slice of each ranking. The protected set is the union across dimensions. A peer only has to be good at one thing to be safe, and we never have to decide how many “fast responses” equal one “finalized transaction.”

Figure 1. The same 50 peers (synthetic, for illustration) and the same budget of 10 protected peers, chosen two ways. A combined score (here an equal-weight average) protects everything above a diagonal, so it keeps peers that are decent at both but best at neither, and leaves unprotected the peers that are the best along one axis. Ranking each dimension separately protects an L-shaped region, and keeps those one-axis champions.

Crucially, this is an overlay, not a replacement. Random churn still runs; it just runs around the protected set. The random graph is preserved for the unprotected majority, and we carve out protection only for the peers that have earned it. We are hardening the mempool without giving up the properties that make the overlay robust in the first place.

The mechanics are intentionally boring, which is what you want in the dropper path:

  1. Each protection category produces its own ranking of peers by its own score.
  2. Within each category we protect the top ~10% of each pool, computed per pool: inbound peers and dialed peers are ranked independently. Inbound and outbound connections have very different trust and attack profiles, and we do not want a flood of inbound peers to crowd out protection for the peers we chose to dial (or vice-versa).
  3. Only peers with a positive score in a category can be protected by it — an idle peer with no signal is not “top 10%”, it is simply unranked.
  4. The dropper computes the protected set first, then makes its random choice from whatever is left.

Figure 2. The mechanism on Geth’s default pools (34 inbound, 16 dialed), with the latency category of #34771; which peers are protected is made up for illustration. Each category marks its top 10% of each pool independently; peers in several categories count once. Assuming both pools are full, the node is synced, and no peer is trusted, static, or younger than 10 minutes, everything left is an ordinary random drop candidate.

How much churn do we give up? None, in terms of rate. Protection does not reduce the churn rate: the dropper removes protected peers from the candidates before its random pick, so it still drops one peer per period, just never a protected one. Slots open for newcomers as often as before, which matters most for the problem we started from; unprotected peers simply turn over slightly faster.

What protection does change is who can be dropped, and that is easy to bound. Each category protects at most ~10% of a pool, and a peer needs only one of them, so with three categories at most ~30% of each pool is protected. With Geth’s defaults (50 peers: 16 dialed, 34 inbound) and rounding down, that is at most 3 of 16 dialed peers and 9 of 34 inbound ones. In practice it is less, since the good peers tend to overlap across categories. So at least ~70% of every pool stays a drop candidate. That bound holds for any number of attacker identities: protection itself exempts at most that many slots. It does not bound how many slots an attacker holds overall — attackers can also sit in unprotected slots, where churn keeps rotating them out like everyone else.

Protection is not tenure. Nothing is protected for good. Every score is an exponential moving average (EMA): at each update (each block; for latency, each request), score ← (1 − α)·score + α·(new value), so older contributions fade geometrically, with a half-life of about ln 2 / α updates. Protection is recomputed from these scores each time the dropper runs. A peer that stops delivering useful transactions, or starts answering slowly, decays, and active peers soon overtake it — within minutes for the fast included EMA, within about a day for the finalized one — making it an ordinary churn candidate again. Decay is not expiry: in a quiet pool a peer with a small leftover score can still rank in the top slice, but it falls behind as soon as others contribute. That decay is what keeps the protected set from ossifying into exactly the static graph that churn was introduced to prevent.

Protection is on by default in Geth and needs no configuration. It also degrades gracefully: only peers with a positive score can be protected, so a fresh node with no stats behaves exactly like plain random churn. The category list is a simple extensible slice, so adding a fourth dimension later is a local change.

Anchoring protection to the chain

Here is the part we think is the most interesting.

The mempool is off-chain. There is no consensus about it, no canonical record of who sent what first, no ground truth we can point to when deciding whether a peer is honest or useful. That is precisely the environment in which peer scoring tends to go wrong: the signals are local, gameable, and unverifiable.

So instead of scoring peers on off-chain behaviour, we bind protection to on-chain outcomes. We watch which peer first delivered each transaction, and we credit that peer only when the transaction actually lands on-chain — at the head, and later at finality. The chain is the arbiter. A peer’s protection is earned by contributing transactions that the network as a whole agreed to include.

This turns an off-chain popularity contest into something closer to quality assurance:

  • The signal is objective — inclusion and finalization are facts, not opinions.
  • It is hard to fake — a peer cannot claim inclusions it did not contribute to.
  • It rewards the behaviour we actually care about — feeding us transactions that matter, not merely being chatty.

Off-chain overlay, on-chain accountability. That pairing is the core idea.

Can protection be earned without being useful?

A natural objection: can’t a peer simply buy protection, by sending its own transactions and paying to get them included? It can — and that is not an attack. A peer that originates real transactions, pays their fees, and delivers them to us first is doing exactly what we want to reward. Busy senders such as exchanges, rollup batchers, and bridge relayers will earn protection this way, and they should. Whether a peer is the source of a transaction or a fast relay for someone else’s, what we credit is the same: it got us transactions that the chain then accepted, before anyone else did.

What would matter is a peer using a protected slot for something we do not measure. That is where the cap does its work: however protection is earned, it exempts at most 3 of 16 dialed and 9 of 34 inbound peers. Protection can help an attacker keep a foothold, but on its own it cannot give it more of our slots than that.

Which signals earn protection?

Recent-included and recent-finalized (inclusion stats)

A minimal tracker records the deliverer of each transaction; when that transaction appears at the head we credit the peer’s recent-included EMA, and when it finalizes we credit the recent-finalized EMA.

The two EMAs are deliberately tuned to different timescales:

  • Recent-included is a fast EMA (α = 0.05, a half-life of ~14 blocks, under 3 minutes) — it reacts within a handful of blocks, so a peer that starts being useful is protected quickly, and one that goes quiet loses protection quickly.
  • Recent-finalized is a slow EMA (roughly a day’s half-life) — it rewards sustained, long-run usefulness and is much harder to fake with a short burst.

Keeping both means we protect both the peer that is hot right now and the peer that has been quietly reliable for a day.

Request-latency

The third dimension (#34771) scores peers on how quickly and reliably they answer our GetPooledTransactions requests. The tx fetcher gained an onRequestResult(peer, latency, timeout) callback that fires once per request outcome — either a delivery with the real round-trip, or a timeout with the timeout value. A delivery only counts if the pool accepted at least one of the transactions we requested: answering with duplicates, rejected transactions, or something else entirely earns nothing. Since a transaction we already have is a duplicate, a peer only earns samples by getting new transactions to us ahead of the network’s normal diffusion.

Peers are scored by 1 / RequestLatencyEMA, so faster peers score higher:

  • The latency EMA is slow (α = 0.01, ~70-sample half-life). One lucky fast response does not buy protection; one unlucky slow one does not lose it.
  • Timeouts count as the timeout value fed into the EMA. A peer that chronically fails to answer drags its own score down — there is no way to look fast by simply not responding.

This one is not anchored to inclusion the way the other two categories are, but it is not free-floating either: only requested transactions the pool accepts — valid against the current chain state — earn a sample, and eligibility requires such deliveries in at least ~1 block in 5. Beyond that, it has its own built-in ground truth: latency is measured, not reported, and a timeout is a timeout.

Defending against gaming

Any protection scheme is an incentive, and in an open, permissionless network an adversary can cheaply spin up identities to farm it — the Sybil problem [12]. So each dimension has guards against a peer cheating its way into a protected slot:

  • Inclusion credit requires an accepted, early delivery. Only transactions the pool accepted from that peer count, and only if delivered before they were included. Spraying transactions we already have, or relaying ones already on-chain, earns nothing.
  • Reorg-aware: finalized credit is only awarded if the inclusion block is still canonical.
  • Request-latency requires sustained activity. A peer’s latency only counts while it keeps making accepted deliveries (in ~1 block in 5). A burst cannot front-load eligibility, and a peer that goes silent soon loses it — and eventually its latency history.
  • Memory is bounded: the tracker keeps at most 2^18 transaction-to-peer mappings, so it cannot be turned into a memory-exhaustion vector.

How is it implemented in Geth?

The work is split in three: a tracker that records which peer delivered each transaction and turns new head and finalized blocks into per-peer credits, a passive stats aggregator that keeps the EMAs and exposes a per-peer snapshot, and the dropper, which reads that snapshot through a callback and applies the per-pool selection. The dropper never touches transaction machinery, so the churn path stays as simple as it was.

Open questions

This is a first cut, and there are knobs we have not pinned down:

  • Is 10% per pool the right slice? Too small and we barely protect anyone; too large and we starve churn and weaken the random-graph properties. We intentionally started low, but proper tuning requires further analysis.
  • The EMA constants are educated guesses. The included/finalized/latency half-lives were chosen to feel right at different timescales, not fit to data yet.
  • Is reliability its own dimension? Today timeouts feed into the latency average, which is the right place for them: a peer that often times out drags its average toward the timeout, so it can only win latency protection when there is no better candidate. How often a peer answers at all, as opposed to how fast, might still deserve a category of its own.
  • How much does the overlay perturb the random graph? The churn rate is unchanged and the ~30% bound caps how many peers are exempt from it, but we have not characterised the resulting graph, nor its behaviour under adversarial inbound floods.
  • The drop rate itself is an educated guess. There is no ideal rate: no two days on a p2p network look the same. What matters is that a peer gets a fair chance to earn protection before random selection reaches it. New peers are exempt for their first 10 minutes, and with one drop every ~5 minutes over ~40 candidates an unprotected peer expects to last ~3.4 hours — long enough to earn protection in any category. Even the finalized score turns positive with a peer’s first finalized transaction, about 13 minutes after inclusion; its day-scale half-life governs how long evidence is kept, not how long it takes to earn.

Future work

The extensible-dimension design is the point: each new signal is a small, self-contained addition rather than a re-tuning of one global score. Candidates we are thinking about:

  • A blob-serving dimension. With the sparse blobpool (#34047), how reliably and quickly a peer serves blob data becomes a quality of its own.
  • Remembering peers across disconnects. Keeping (bounded) stats for disconnected peers, so a good peer that loses its connection for unrelated reasons does not start from zero when it reconnects.
  • Measuring it on mainnet: the protected fraction of each pool, how much the categories overlap, and the effect on mempool freshness.

What we introduce here is a concept, not just a Geth design or an EL mechanism. The CL, whose peer management currently rests largely on GossipSub’s single-score model, is a good candidate for the same approach, with block, payload and blob propagation as its natural dimensions.

Feedback very welcome — especially on the pool fraction, the EMA timescales, and any gaming vector we have not thought of.

References

[1] A. Singh, T.-W. Ngan, P. Druschel, D. S. Wallach. “Eclipse Attacks on Overlay Networks: Threats and Defenses.” IEEE INFOCOM, 2006. [doi]

[2] T. Condie, V. Kacholia, S. Sankararaman, J. M. Hellerstein, P. Maniatis. “Induced Churn as Shelter from Routing-Table Poisoning.” NDSS, 2006. [pdf]

[3] M. Jelasity, S. Voulgaris, R. Guerraoui, A.-M. Kermarrec, M. van Steen. “Gossip-based Peer Sampling.” ACM Transactions on Computer Systems, 25(3), Art. 8, 2007. [doi] [pdf]

[4] S. Voulgaris, D. Gavidia, M. van Steen. “CYCLON: Inexpensive Membership Management for Unstructured P2P Overlays.” Journal of Network and Systems Management, 13(2), 197–217, 2005. [doi]

[5] E. Heilman, A. Kendler, A. Zohar, S. Goldberg. “Eclipse Attacks on Bitcoin’s Peer-to-Peer Network.” USENIX Security, 129–144, 2015. [pdf]

[6] Y. Marcus, E. Heilman, S. Goldberg. “Low-Resource Eclipse Attacks on Ethereum’s Peer-to-Peer Network.” IACR ePrint 2018/236, 2018. [eprint]

[7] R. Shi, Y. Liang, Z. Guo, Q. Wang, L. Lan, C. Wang, Z. Zheng. “Eclipse Attacks on Ethereum’s Peer-to-Peer Network.” The ACM Web Conference (WWW), 2026. [arxiv]

[8] D. Vyzovitis, Y. Napora, D. McCormick, D. Dias, Y. Psaras. “GossipSub: Attack-Resilient Message Propagation in the Filecoin and ETH2.0 Networks.” arXiv:2007.02754,
2020. [arxiv]

[9] libp2p. “gossipsub v1.1: Security extensions to improve on attacks and mitigate risks.” Specification. [github]

[10] S. D. Kamvar, M. T. Schlosser, H. Garcia-Molina. “The EigenTrust Algorithm for Reputation Management in P2P Networks.” WWW, 640–651, 2003. [doi] [pdf]

[11] P. Strateman. “Connection slot exhaustion DoS mitigation.” Bitcoin Core pull request #6374, released in v0.12.0, 2016. [github]; current logic in src/node/eviction.cpp

[12] J. R. Douceur. “The Sybil Attack.” IPTPS, LNCS 2429, 251–260, 2002. [doi]

Acknowledgments

Thanks to the Geth team for review and discussion on #34702 and #34771.

2 Likes