Skip to content

feat(l1): kademlia k-bucket routing table v2 - #6511

Merged
ilitteri merged 29 commits into
mainfrom
feat/kademlia-v2
May 29, 2026
Merged

ilitteri merged 29 commits into
mainfrom
feat/kademlia-v2

Conversation

@azteca1998

@azteca1998 azteca1998 commented Apr 21, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Replaces the flat peer table with a proper Kademlia k-bucket routing table and iterative convergence lookup (geth-style), while maintaining comparable traffic and sync performance to main.

Key changes

  • Kademlia k-bucket routing table: 256 buckets × 16 entries with replacement lists, XOR distance-based routing
  • Flat connection pool: Decoupled from k-buckets (up to 10K entries) so RLPx connection initiation has a larger candidate pool
  • Iterative convergence lookup: Seeds from connection pool, queries alpha=3 closest unqueried nodes per timer tick, feeds Neighbors/Nodes responses back into the result set
  • Timer-driven lookup advance: Each timer tick advances by alpha=3 queries. Responses feed results into the lookup but don't trigger new queries, naturally throttling traffic
  • Scaled lookup interval: 500ms initial → 10s steady-state (vs main's 100ms→600ms) to account for iterative lookups generating ~16 FindNodes each vs main's 1

Traffic comparison (Prometheus metrics, 30 min capture)

Metric main (srv10, /min) kademlia (srv3, /min) Ratio
v4 FindNode (steady) 131 144 1.1x
v5 FindNode (steady) 132 148 1.1x
v4 Ping 61 34 0.6x
v5 Ping 61 60 1.0x
Total FindNode 263 292 1.1x
Total Ping 122 94 0.8x

Bootstrap burst (first 5 min): ~630 FindNode/min total, dropping to ~290/min by minute 29.

Multisync comparison (same hardware, same Docker setup)

Three-network snapsync (mainnet + sepolia + hoodi) on srv3:

Network main kademlia Delta
hoodi baseline −1 min faster
sepolia baseline −1 min faster
mainnet baseline +2 min slower

Result: within noise. No regression on sync time despite the architectural change.

Note: Docker multisync uses bridge networking (outbound-only, no inbound P2P), which limits peer count to ~50-60 and disproportionately affects kademlia (pool diversity matters more when 100% of peers come from the pool). Bare-metal performance should be slightly better since inbound connections add peer diversity independent of discovery strategy.

Bare-metal peer discovery (clean restart, no peer cache)

Time Kademlia peers Main peers
1m 3 8
5m 17 34
10m 55 61
20m 97 88

Slower first 5 min, catches up by 10 min, beats main at 20 min.

Why Kademlia matters

The current flat peer table works well for filling RLPx slots but doesn't provide structured routing. Kademlia gives us:

  • O(log n) routing for future DHT lookups (e.g., EIP-7594 PeerDAS)
  • Resilience: k-buckets with replacement lists handle churn better
  • Scalability: structured routing scales to large networks without random probing
  • Standards compliance: aligns with devp2p/discv5 spec expectations

What was explored and removed

Several approaches were tried and removed for generating excessive traffic:

  • Pre-bonding (pinging discovered nodes before querying): 7x traffic burst
  • Concurrent lookups: multiplied traffic without proportional peer gains
  • Response-driven advance: discv5 lookups converged in <200ms, churning 60+ lookups/min
  • Back-to-back lookup chaining: kept tick permanently active

The final design uses timer-driven advance with a scaled easing curve, naturally throttling traffic while benefiting from structured routing.

…talls

Fixes snapsync failures where peer count stays constant and sync
eventually fails with "Failed to receive block headers" after hours
of operation.

Root cause: After PR #6458 introduced Kademlia k-buckets, peers that
became unresponsive during sync weren't marked as disposable, so they
remained in the routing table indefinitely. New peers went into
replacement lists but were never promoted because dead peers weren't
pruned.

Changes:
- Enhanced prune() to remove disposable contacts from both main and
  replacement lists, with automatic promotion of replacements
- Mark peers as disposable when they timeout during RLPx operations
  (block headers, block bodies, sync head requests)
- Added periodic pruning in the snap_sync main loop to ensure dead
  peers are regularly removed and replaced

Evidence from CI artifacts showed peer count stuck at 6 throughout
3h35m sync before failure. This fix enables peer rotation so healthy
peers from replacement lists can take over when active peers become
unresponsive.
The Kademlia k-bucket implementation only iterated over main bucket
contacts, ignoring replacement entries. This caused peer starvation
because dead contacts in the main list were never replaced by fresher
peers from the replacement list.

Fix iter_contacts() and do_get_contact_to_initiate() to also check
replacement contacts, allowing the node to discover and connect to
peers that were previously invisible to the peer selection logic.
KBucket::get_mut and get_contact only searched the main contact list,
so any state mutation (set_disposable, ping tracking, find_node count,
mark_knows_us) silently failed for contacts in the replacement list.
Since iter_contacts and do_get_contact_to_initiate now return
replacement contacts, this caused phantom contacts that were visible
to selection but invisible to updates.

Update get_contact to use get_any (main + replacements) and get_mut
to search both lists, ensuring all contact state mutations work
regardless of which list holds the contact.
…able

Add a separate IndexMap<H256, Node> connection pool (capacity 50K) for
RLPx connection initiation, decoupled from the k-bucket routing table
(which is limited to 256 × 16 = 4,096 contacts by Kademlia design).

All discovered contacts are inserted into both the k-buckets (for
Kademlia protocol operations like FindNode/GetClosestNodes) and the
connection pool (for peer connection initiation). This restores the
large candidate pool that existed before the k-bucket migration while
preserving correct Kademlia routing semantics.

The connection pool is:
- Populated on every contact discovery (discv4, discv5, insert_if_new)
- Cleaned during prune() when contacts are marked disposable
- Capped at 50K entries with oldest-first eviction
- Used with random selection and k-bucket state filtering
Matches the candidate pool size used by Reth and Nethermind.
- Replace O(n) collect-then-choose in do_get_contact_to_initiate with
  O(k) random index probing on the IndexMap (rand % len, scan forward).
  The old approach scanned all 10K pool entries, cloned eligible ones
  into a Vec, then randomly picked — blocking the peer_table actor and
  starving snap sync's get_best_peer calls.

- Replace collect-then-choose in do_get_contact_for_lookup with
  IteratorRandom::choose (single-pass reservoir sampling, zero alloc).

- Remove discarded_contacts permanent blacklist entirely. Contacts
  pruned from k-buckets now remain in the connection pool so they can
  be retried — the RLPx handshake rejects truly incompatible peers.
  Previously, a single timeout permanently blacklisted a contact from
  both the pool and re-discovery.
@github-actions

github-actions Bot commented Apr 21, 2026 •

Copy link
Copy Markdown

Lines of code report

Total lines added: 557
Total lines removed: 41
Total lines changed: 598

Detailed view
+-----------------------------------------------------------+-------+------+
| File                                                      | Lines | Diff |
+-----------------------------------------------------------+-------+------+
| ethrex/cmd/ethrex/initializers.rs                         | 656   | +1   |
+-----------------------------------------------------------+-------+------+
| ethrex/cmd/ethrex/l2/initializers.rs                      | 385   | +4   |
+-----------------------------------------------------------+-------+------+
| ethrex/crates/networking/p2p/discovery/discv4_handlers.rs | 549   | +71  |
+-----------------------------------------------------------+-------+------+
| ethrex/crates/networking/p2p/discovery/discv5_handlers.rs | 777   | +59  |
+-----------------------------------------------------------+-------+------+
| ethrex/crates/networking/p2p/discovery/lookup.rs          | 152   | +152 |
+-----------------------------------------------------------+-------+------+
| ethrex/crates/networking/p2p/discovery/mod.rs             | 34    | +1   |
+-----------------------------------------------------------+-------+------+
| ethrex/crates/networking/p2p/discovery/server.rs          | 411   | -23  |
+-----------------------------------------------------------+-------+------+
| ethrex/crates/networking/p2p/discv4/server.rs             | 34    | -18  |
+-----------------------------------------------------------+-------+------+
| ethrex/crates/networking/p2p/discv5/server.rs             | 243   | +3   |
+-----------------------------------------------------------+-------+------+
| ethrex/crates/networking/p2p/peer_handler.rs              | 607   | +2   |
+-----------------------------------------------------------+-------+------+
| ethrex/crates/networking/p2p/peer_table.rs                | 1400  | +263 |
+-----------------------------------------------------------+-------+------+
| ethrex/crates/networking/p2p/sync/snap_sync.rs            | 1082  | +1   |
+-----------------------------------------------------------+-------+------+

@azteca1998 azteca1998 changed the title feat(p2p): Kademlia k-bucket routing table v2 feat(l1): Kademlia k-bucket routing table v2 Apr 21, 2026
@github-actions github-actions Bot added the L1 Ethereum client label Apr 21, 2026
@azteca1998 azteca1998 changed the title feat(l1): Kademlia k-bucket routing table v2 feat(l1): kademlia k-bucket routing table v2 Apr 21, 2026
azteca1998 added 14 commits May 13, 2026 14:22
Resolve conflicts in p2p module by adapting HEAD's new discovery/
handler files to main's PeerTable API (k-bucket table stores
local_node_id internally, removing extra node_id arguments from
callers).
Replace the direct random-contact lookup with a geth-style iterative
convergence approach: generate a random target, seed with closest known
nodes from the connection pool, query alpha=3 closest not-yet-asked
nodes, feed responses back, and iterate until convergence or timeout.
Change is_finished() to consider a lookup converged as soon as all
entries have been queried, without waiting for in-flight responses.
Late responses still get processed via handle_neighbors and feed
into the connection pool for future lookups.

This prevents a single unresponsive node from blocking the entire
lookup for up to 20 seconds (the timeout), which was causing very
slow peer acquisition compared to the old random-contact approach.
Instead of returning after clearing a finished lookup (which waits for
the easing interval before starting the next one), immediately start a
new lookup in the same tick. This matches geth's behavior of continuous
lookup chaining during bootstrapping.
…ment list

When a new peer pings us and the k-bucket at their distance is full,
insert_if_new was returning false (node went to replacement list),
which prevented the reciprocal ping. Without the reciprocal ping, the
bond is never established from our side, so we reject their FindNode
requests as UnknownContact. This cascading visibility loss explains
the ~2x slower peer acquisition compared to main (which uses a flat
map with no capacity limit).

Now insert_if_new returns true for any genuinely new node regardless
of whether it lands in the main list or replacement list. The
replacement-list contact is reachable by get_contact (via get_any),
so the full ping→pong→validate chain works correctly.
…t lookups, and adaptive alpha

Pre-bond newly discovered nodes by pinging them in discv4_handle_neighbors
so remote nodes accept our future FindNode queries. Track already-pinged
nodes to avoid re-pinging bonded contacts.

Run up to 3 concurrent iterative lookups (targeting different ID-space
regions) when below 30 peers, reverting to 1 above threshold.

Use aggressive alpha=16 (query all entries at once) during bootstrap
instead of the normal alpha=3, since most queries fail due to unbonded
nodes early on.
Pre-bonding was pinging every new node from every Neighbors response
unconditionally, even after reaching 100 peers. This generated ~39M
outgoing pings in 3.5 hours (~3100/sec). Now only pre-bonds when
peer_count < BOOTSTRAP_THRESHOLD (30).
Lookups were chaining continuously — a finished lookup was immediately
replaced in the same tick, keeping the timer locked at ACTIVE_LOOKUP_TICK
(50ms) forever. This caused ~360 FindNode/sec at steady-state.

Now, above BOOTSTRAP_THRESHOLD (30 peers), when the active lookup finishes
the function returns with empty active_lookups, letting the timer fall
back to get_lookup_interval() (~600ms at 100% peer completion).
Pre-bonding was pinging every node from Neighbors responses (~16 per
response), but the lookup only queries a few of those. Move pre-bond
pings to advance_v4_lookup so we only ping the exact nodes the lookup
selects to query — dramatically reducing the UDP burst during bootstrap.
Remove concurrent lookups (MAX_CONCURRENT_LOOKUPS), adaptive alpha
(BOOTSTRAP_ALPHA), and use a slower 200ms tick during bootstrap instead
of 50ms. This reduces FindNode and Ping traffic by ~10-20x while still
reaching 100 peers quickly via pre-bonding and back-to-back chaining.
…urve

Remove back-to-back lookup chaining, BOOTSTRAP_THRESHOLD, ACTIVE_LOOKUP_TICK,
and BOOTSTRAP_LOOKUP_TICK. The existing easing curve (100ms→600ms based on
peer completion) already provides fast lookups at low peer counts. Pre-bonding
is the only kademlia-specific addition and is now always active (idempotent
via pinged_nodes set). This should bring traffic in line with main while
still benefiting from pre-bonding's higher per-lookup success rate.
The lookup timer was calling advance_v4/v5_lookup on every tick even
when a lookup was already active, causing redundant FindNode queries.
Active lookups are already driven forward by incoming Neighbors/Nodes
responses. Now the timer only starts new lookups; convergence is
entirely response-driven. This eliminates the discv5 FindNode burst
where fast-converging lookups generated 45K+ queries in 3 minutes.
Each iterative lookup generates ~16 FindNode messages vs 1 in the old
single-shot approach. Scale the discovery lookup interval accordingly:
2s initial (vs 100ms) to 10s steady-state (vs 600ms). This produces
similar per-second FindNode traffic as main while preserving the easing
curve behavior. The RLPx initiator keeps its own faster interval since
it only controls TCP connection attempts.
Two changes to balance discovery speed vs traffic:

1. Lookups are now entirely timer-driven: each tick advances by alpha=3
   queries. Responses feed results but don't trigger new queries. This
   throttles discv5 lookups (which converged in <200ms response-driven)
   to ~6s per lookup, matching the controlled pace of discv4.

2. Pre-bonding moved from advance_v4_lookup to handle_neighbors. By
   pinging nodes when they're discovered (not when they're queried),
   bonds have ~1s+ to establish before the next lookup tick, greatly
   improving discv4 FindNode response rate.
Pre-bonding was responsible for most of the bootstrap traffic burst
(~1,800 pings in the first minute). Remove it and rely on natural
bonding (incoming pings, reciprocal pings, mutual discovery) for
discv4 nodes to accept our FindNode queries.
Remove pinged_nodes field and all its bookkeeping (bootnode recording,
pruning, handle_ping tracking) since pre-bonding was removed. Fix
redundant closure clippy lints in lookup tests (|i| make_node(i) →
make_node).
… bootstrap

The 1s interval gave 3 FindNode/sec (alpha=3 × 1 tick/sec), while main
sends 10 FindNode/sec. This caused kademlia to discover peers ~2x slower
in the first 5 minutes, leading to 32% slower snapsync (5h17m vs 4h).

Lowering to 500ms gives 6 FindNode/sec, closing most of the gap while
keeping bootstrap traffic well under 10x main's.
@azteca1998
azteca1998 marked this pull request as ready for review May 20, 2026 16:29
@azteca1998
azteca1998 requested review from a team as code owners May 20, 2026 16:29
@ethrex-project-sync ethrex-project-sync Bot moved this to In Review in ethrex_l1 May 20, 2026
@github-actions

Copy link
Copy Markdown

🤖 Kimi Code Review

This is a substantial and well-architected PR implementing proper Kademlia routing tables and iterative lookups for the discovery layer. The code quality is high, with good separation of concerns between the k-bucket routing table and the connection pool.

Summary of Changes

  • Kademlia Routing Table: Replaces flat IndexMap with 256 k-buckets (logarithmic distance-based organization).
  • Iterative Lookups: Implements proper Kademlia α=3 parallel iterative lookups instead of single-node random walks.
  • Connection Pool: Separates the RLPx initiation candidate pool (10K entries) from the routing table (4,096 max).
  • Peer Management: Removes per-peer FindNode limits in favor of disposable flags and proper Kademlia replacement policies.

Code Review

Correctness & Safety

1. Kademlia Bucket Index Calculation (crates/networking/p2p/peer_table.rs:148-156)

fn bucket_index(local_node_id: &H256, node_id: &H256) -> Option<usize> {
    let xor = *local_node_id ^ *node_id;
    let dist = U256::from_big_endian(xor.as_bytes());
    if dist.is_zero() {
        None
    } else {
        Some(dist.bits() - 1)
    }
}

Correctly computes floor(log2(XOR distance)). Returns None for local node (distance 0), which is handled appropriately by callers.

2. Lookup Convergence Logic (crates/networking/p2p/discovery/lookup.rs:180-186)

pub fn is_finished(&self) -> bool {
    if self.started_at.elapsed() >= LOOKUP_TIMEOUT {
        return true;
    }
    !self.result.iter().any(|e| !e.queried)
}

The logic correctly implements Kademlia convergence: finished when all k-closest nodes have been queried or timeout. The comment correctly notes that stragglers are not waited for.

3. Active Lookups Management (crates/networking/p2p/discovery/discv4_handlers.rs:130-140)

self.discv4
    .as_mut()
    .expect("discv4 state must exist")
    .active_lookups
    .retain(|(l, _)| !l.is_finished());

Properly cleans up finished lookups before processing. The expect is safe here because of the prior is_none() check.

Performance Considerations

1. Connection Pool Scanning (crates/networking/p2p/peer_table.rs:1267-1293)
The do_get_closest_from_pool function performs an O(N) linear scan over up to 10,000 entries to find the k-closest nodes. Given that this is only called during lookup seeding (not the hot path of query processing) and N=10K is relatively small, this is acceptable. For future optimization, consider maintaining the pool sorted by distance or using a heap if profiling indicates a bottleneck.

2. Result Feeding (crates/networking/p2p/discovery/discv4_handlers.rs:433-440)

for (lookup, _) in &mut discv4.active_lookups {
    lookup.feed_results(entries.clone());
}

Clones the entries vector for each active lookup. With typically 1-2 active lookups and 16 entries, this is negligible.

Rust Idioms & Best Practices

1. Efficient Optional Handling (crates/networking/p2p/peer_table.rs:686-695)

if let Some(contact) = self.get_contact_mut(&msg.node_id)
    && contact
        .ping_id
        .as_ref()
        .map(|value| *value == msg.ping_id)
        .unwrap_or(false)
{
    contact.ping_id = None;
}

Good use of let-chains (Rust 1.65+) for concise optional logic.

2. Borrow Checker Workaround (crates/networking/p2p/peer_table.rs:1112-1124)
The manual index tracking in get_contact_or_replacement_mut avoids borrow checker issues with iter_mut().find(). This is a necessary and safe pattern when dealing with mutable references into collections.

Security & Robustness

1. Peer Penalization (crates/networking/p2p/peer_handler.rs:428-431, 526-529)

let _ = self.peer_table.set_disposable(peer_id);

Properly marks peers as disposable when they return empty headers or timeout on block bodies, allowing the k-bucket replacement mechanism to evict them.

2. Message Expiration (crates/networking/p2p/discovery/discv4_handlers.rs:184)
The FindNode message expiration is correctly set to EXPIRATION_SECONDS (20s) and validated in handlers via is_msg_expired.

Minor Observations

1. Metric Description Accuracy (crates/blockchain/metrics/p2p.rs:101)
The updated description "Duration of Kademlia insert_contact operations" is more accurate than the previous generic text.

2. Test Coverage
Excellent unit test coverage added for k-bucket operations including:

  • Insertion and replacement list overflow
  • Promotion from replacement to main list
  • Bucket index edge cases (self, minimal distance, maximal distance)

3. Dead Code Removal
Good cleanup of MAX_FIND_NODE_PER_PEER, TARGET_CONTACTS, and the old change_find_node_message interval logic.

Suggestions (Non-blocking)

  1. Consider rayon for bucket operations: If the routing table grows large, parallel iteration over buckets could speed up iter_contacts, though currently unnecessary.

  2. Connection Pool Ordering: The current FIFO eviction from the


Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt

@github-actions

Copy link
Copy Markdown

⚠️ Known Issues — intentionally skipped tests

Source: docs/known_issues.md

Known Issues

Tests intentionally excluded from CI. Source of truth for the Known
Issues
section the L1 workflow appends to each ef-tests job summary
and posts as a sticky PR comment.

EF Tests — Stateless coverage narrowed to EIP-8025 optional-proofs

make -C tooling/ef_tests/blockchain test calls test-stateless-zkevm
instead of test-stateless. The zkevm@v0.3.3 fixtures are filled against
bal@v5.6.1, out of sync with current bal spec; the broad target trips ~549
fixtures. Re-broaden once the zkevm bundle is regenerated.

Why and resolution path

PR #6527 broadened
test-stateless to extract the entire for_amsterdam/ tree from the
zkevm bundle and run all of it under --features stateless; combined with
this branch's bal-devnet-7 semantics that scope produces ~549
GasUsedMismatch / ReceiptsRootMismatch /
BlockAccessListHashMismatch failures.

test-stateless-zkevm filters cargo to the eip8025_optional_proofs
suite, which still validates the stateless harness without the bal-version
mismatch.

Re-broaden by switching test: back to test-stateless in
tooling/ef_tests/blockchain/Makefile once the zkevm bundle is regenerated
against the current bal spec.

@github-actions

Copy link
Copy Markdown

🤖 Codex Code Review

  1. The new lookup can terminate before any in-flight responses arrive. next_to_query() marks entries as queried when the packet is sent, not when a response/timeout is observed, and is_finished() ignores queries_in_flight. Because both discovery loops drop “finished” lookups at the start of the next tick, a slow but valid response can arrive after the lookup has already been removed, so multi-hop convergence stops early instead of expanding through newly learned nodes. See discv4_handlers.rs and discv5_handlers.rs.

  2. discv5_handle_nodes_message() now treats every NODES reply as part of the active iterative lookup, but the code also sends unrelated FINDNODE requests for ENR refresh (distance = 0) in discv5_handle_pong(). Since discv5_handle_nodes_message() never matches on req_id, those ENR-refresh responses will contaminate the lookup state and call record_response() on it. That can make lookup progress nondeterministic and target-incorrect.

  3. The connection pool keeps stale endpoints after ENR updates. insert_to_connection_pool() is a no-op when the node ID already exists, but do_new_contact_records() updates the contact’s IP/UDP port on higher-seq ENRs without updating the pool entry. Since iterative lookup seeds are later taken from get_closest_from_pool(), discovery can keep sending packets to an obsolete address even after a newer ENR has been learned.

Aside from those, the refactor is directionally good: passing local_node_id into PeerTableServer and adding bucket/replacement tests both improve correctness.

I couldn’t run cargo test here because rustup failed to create temp files in the current environment (Read-only file system under /home/runner/.rustup/tmp).


Automated review by OpenAI Codex · gpt-5.4 · custom prompt

@github-actions

Copy link
Copy Markdown

🤖 Claude Code Review

Now I have a thorough understanding of the changes. Let me write the review.


PR #6511 — Kademlia k-bucket routing table v2

Overall: Solid architectural upgrade. The code is well-structured, well-tested, and the design rationale is clear. A few correctness and consistency issues are worth addressing before merge.


lookup.rs — IterativeLookup

queries_in_flight is tracked but never consulted

record_response() and record_timeout() decrement queries_in_flight, but is_finished() only checks the timeout and the queried flags — queries_in_flight plays no role in convergence detection. This means the field is dead state. Either use it in is_finished() (e.g., convergence = all queried AND no in-flight), or remove it entirely. Currently the comment on is_finished() correctly documents the "don't wait for stragglers" intent, so removal would be cleaner.

record_response() only applied to first active lookup

In both handle_neighbors_message (discv4) and discv5_handle_nodes_message (discv5), feed_results() is correctly broadcast to all active lookups, but record_response() is called only on active_lookups.first_mut():

for (lookup, _) in &mut discv4.active_lookups {
    lookup.feed_results(entries.clone()); // all lookups
}
if let Some((lookup, _)) = discv4.active_lookups.first_mut() {
    lookup.record_response(); // first lookup only
}

Since queries_in_flight is unused in is_finished(), this doesn't cause a correctness bug today, but it's inconsistent and will silently break if queries_in_flight is ever wired into convergence logic.

feed_results can evict an already-queried entry

When the result set is full and a closer node arrives, the farthest entry is evicted unconditionally. If the evicted entry was already queried (queried: true), the new entry starts with queried: false, so next_to_query will eventually query it. This inflates query count beyond the strict Kademlia k-bucket bound but is not a correctness error given the intentional "don't wait for stragglers" semantics.

Vec<IterativeLookup> vs. Option<IterativeLookup>

The design allows at most one active lookup at a time (a new lookup only starts when active_lookups.is_empty()), but the data structure is Vec. The Vec buys future flexibility without breaking the API, but it adds complexity to index tracking in advance_v4_lookup. Minor, but worth a comment or a future cleanup note.


peer_table.rs

Documentation mismatch: "up to 50,000" vs. 10_000

/// allows (k-buckets: 256 × 16 = 4,096 max; this pool: up to 50,000).
const MAX_CONNECTION_POOL_SIZE: usize = 10_000;

The comment says 50,000 but the constant is 10,000. Should be 10K (which also matches the stated Reth/Nethermind precedent in the PR description).

KBucket::insert does not guard against duplicates

The insert method pushes unconditionally; there is no check for an existing entry with the same node_id. The callers in do_new_contacts and insert_if_new correctly call contact_exists() before calling insert_contact(), so this is safe today, but the internal API is a trap for future callers. Consider adding a debug assertion or early return inside KBucket::insert:

fn insert(&mut self, node_id: H256, contact: Contact) -> bool {
    debug_assert!(!self.contains(&node_id), "duplicate insert");
    // ...
}

get_contact_mut vs. get_contact_or_replacement_mut are functionally equivalent

get_contact_mut delegates to bucket.get_mut() which already searches both main and replacement lists. get_contact_or_replacement_mut replicates that search inline to avoid borrow-checker issues. The duplication is justified by the comment, but the first method's name is misleading — get_contact_mut implies main-list-only lookup, yet it returns replacement-list contacts too (because KBucket::get_mut searches both). Consider renaming or adding a clarifying comment.

do_get_contact_to_initiate fallback protocol assignment

When a node exists in the connection pool but not in any k-bucket, the code synthesises a contact:

.unwrap_or_else(|| Contact::new(node, DiscoveryProtocol::Discv4));

Pool-only nodes discovered via discv5 will be assigned Discv4 as their protocol. This is likely benign for RLPx initiation (the protocol field is informational), but it's worth a comment explaining the fallback.

do_get_closest_from_pool — O(N·count) scan

The current implementation does a linear pass with max_by_key for each replacement candidate. For N=10_000 and count=16 this is ~160K comparisons per lookup start. A BinaryHeap<Reverse<(H256, ...)>> would give O(N log count) = ~56K. At one lookup per 10 seconds in steady state this is not a hot path, but it's worth noting for future scale.

Removed discarded_contacts blacklist

Previously, a discarded contact was permanently blacklisted in discarded_contacts and filtered from re-insertion. Now, disposable nodes are removed from k-buckets on prune but remain in the connection pool for retry. The PR comment acknowledges this ("the RLPx handshake will reject them if they're truly bad"). This is a defensible trade-off, but it means a misbehaving peer can cycle through the connection pool indefinitely if the pool hasn't filled its capacity and evicted them. No security issue for a discovery table, but worth flagging as a behaviour change.


discv4_handlers.rs / discv5_handlers.rs

Repeated .expect() after is_none() guard

if self.discv4.is_none() {
    return Ok(());
}
self.discv4.as_mut().expect("discv4 state must exist")...

This pattern appears multiple times. Preferring let Some(discv4) = &mut self.discv4 else { return Ok(()) }; at the top would eliminate the .expect() calls and is more idiomatic Rust. The current code is safe but adds noise.

peer_handler.rs — double penalty

self.peer_table.record_failure(peer_id)?;
let _ = self.peer_table.set_disposable(peer_id);

record_failure and set_disposable are both sent on timeout/body failures. The effect is additive — score penalty plus immediate disposable flag. Verify this is intentional; applying both may cause the peer to be pruned faster than a score-only penalty would allow for future reconnection attempts.


Tests

The unit tests in lookup.rs and peer_table.rs are comprehensive and cover the key invariants (sorting, dedup, bucket overflow, promotion on removal, bucket index math). No gaps noticed.


Summary

The architecture is sound and the implementation is clean. The top items to address:

  • Remove or use queries_in_flight (dead state / inconsistent application to only the first lookup)
  • Fix the "up to 50,000" comment to say 10,000
  • Add a duplicate guard or debug assertion inside KBucket::insert
  • Confirm the double-penalty (record_failure + set_disposable) in peer_handler.rs is intentional

Automated review by Claude (Anthropic) · sonnet · custom prompt

@greptile-apps

greptile-apps Bot commented May 20, 2026 •

Copy link
Copy Markdown

Greptile Summary

This PR replaces the flat peer table with a proper Kademlia k-bucket routing table (256 buckets × 16 entries, with 10-slot replacement lists per bucket) and a decoupled 10K flat connection pool for RLPx initiation. Discovery lookups are redesigned from a single-shot FindNode broadcast into a timer-driven iterative convergence lookup (geth-style, alpha=3) for both discv4 and discv5, with a scaled 500ms→10s easing interval to keep traffic comparable to the previous approach.

  • Kademlia routing table: peer_table.rs is largely rewritten around 256 KBucket structs; a separate connection_pool (IndexMap, max 10K) decouples RLPx candidate selection from k-bucket capacity limits.
  • Iterative lookup (lookup.rs): New IterativeLookup struct seeds from the connection pool, queries the alpha=3 closest unqueried nodes per timer tick, and feeds Neighbors/Nodes responses back without triggering immediate re-queries, naturally throttling traffic.
  • Peer lifecycle improvements: peer_handler.rs marks peers disposable on empty-headers or block-body timeout; snap_sync.rs calls prune_table() each loop iteration so k-bucket slots are recycled during sync.

Confidence Score: 4/5

Safe to merge; all findings are non-blocking style and maintainability concerns with no effect on correctness under the current single-active-lookup design.

The architectural change is well-scoped and backed by traffic/sync benchmarks. The dead queries_in_flight state, the record_response() always-on-first-lookup pattern, and the stale pool-size doc comment are all benign today but worth cleaning up before the lookup model is extended.

crates/networking/p2p/discovery/lookup.rs (dead queries_in_flight field) and crates/networking/p2p/peer_table.rs (stale pool size doc comment, unsorted do_get_closest_nodes output).

Important Files Changed

Filename Overview
crates/networking/p2p/peer_table.rs Core architectural change: replaces flat peer table with 256-bucket Kademlia routing table (k=16) plus a decoupled 10K connection pool. Logic is generally sound; doc comment incorrectly says pool is "up to 50,000"; do_get_closest_nodes doesn't sort its output by distance.
crates/networking/p2p/discovery/lookup.rs New iterative convergence lookup with geth-style alpha=3 querying. Well-structured with tests. queries_in_flight is tracked but never consulted by is_finished() — effectively dead state that could mislead maintainers.
crates/networking/p2p/discovery/discv4_handlers.rs Timer-driven iterative lookup for discv4: creates one cached FindNode per lookup, sends alpha=3 per tick. record_response() is always credited to the first active lookup rather than the lookup that dispatched the specific query — benign under the single-lookup invariant but fragile.
crates/networking/p2p/discovery/discv5_handlers.rs Parallel discv5 iterative lookup using distance-bucketed FindNode. Same record_response() first-only attribution pattern as discv4. Otherwise clean.
crates/networking/p2p/discovery/server.rs Unified discovery server with updated timer constants for iterative lookups (500ms→10s easing). Discv4State is now derived Default removing manual construction. Clean refactor.
crates/networking/p2p/discv4/server.rs Removed the cached random FindNode message from Discv4State; now stored per-lookup. Active lookups list added. Default derive replaces manual new. Clean simplification.
crates/networking/p2p/peer_handler.rs Marks peers disposable on empty-headers response and on block-body timeout, allowing pruning to recycle those k-bucket slots. Targeted improvement to churn handling.
crates/networking/p2p/sync/snap_sync.rs Adds a prune_table() fire-and-forget call at the top of each sync loop iteration to keep the k-buckets fresh during snap sync.

Sequence Diagram

sequenceDiagram
    participant Timer
    participant DiscoveryServer
    participant IterativeLookup
    participant PeerTable
    participant RemoteNode

    Timer->>DiscoveryServer: LookupV4/V5 tick
    DiscoveryServer->>DiscoveryServer: retain(not finished)
    alt active_lookups is empty
        DiscoveryServer->>PeerTable: get_closest_from_pool(target, 16)
        PeerTable-->>DiscoveryServer: seed nodes (sorted by XOR dist)
        DiscoveryServer->>IterativeLookup: new(target, seeds)
        DiscoveryServer->>DiscoveryServer: push lookup to active_lookups
    end
    DiscoveryServer->>IterativeLookup: "next_to_query(alpha=3)"
    IterativeLookup-->>DiscoveryServer: [(node_id, node) x 3]
    loop for each queried node
        DiscoveryServer->>RemoteNode: FindNode (UDP)
        DiscoveryServer->>PeerTable: pending_find_node.insert(node_id, now)
    end
    RemoteNode-->>DiscoveryServer: Neighbors / Nodes (UDP)
    DiscoveryServer->>PeerTable: new_contacts(nodes)
    DiscoveryServer->>IterativeLookup: feed_results(entries)
    DiscoveryServer->>IterativeLookup: record_response()
    Note over Timer,DiscoveryServer: next tick fires (500ms-10s later)
    Timer->>DiscoveryServer: LookupV4/V5 tick
    DiscoveryServer->>IterativeLookup: is_finished()?
    alt "all queried OR elapsed >= 20s"
        DiscoveryServer->>DiscoveryServer: retain removes lookup
    else still unqueried entries
        DiscoveryServer->>IterativeLookup: "next_to_query(alpha=3)"
        DiscoveryServer->>RemoteNode: FindNode (UDP)
    end
Loading

Comments Outside Diff (1)

  1. crates/networking/p2p/peer_table.rs, line 1364-1389 (link)

    P2 do_get_closest_nodes returns the k-closest set in arbitrary order

    The function correctly maintains a heap-like structure to find the MAX_NODES_IN_NEIGHBORS_PACKET closest nodes, but nodes.into_iter().map(|(node, _)| node).collect() returns them in insertion order, not sorted by XOR distance. By contrast, do_get_closest_from_pool (used for seeding lookups) sorts before returning. The discv4 spec doesn't mandate ordering in a Neighbors packet, so this is not a protocol violation, but recipients that use the response to seed their own lookups get a sub-optimal ordering.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: crates/networking/p2p/peer_table.rs
    Line: 1364-1389
    
    Comment:
    **`do_get_closest_nodes` returns the k-closest set in arbitrary order**
    
    The function correctly maintains a heap-like structure to find the `MAX_NODES_IN_NEIGHBORS_PACKET` closest nodes, but `nodes.into_iter().map(|(node, _)| node).collect()` returns them in insertion order, not sorted by XOR distance. By contrast, `do_get_closest_from_pool` (used for seeding lookups) sorts before returning. The discv4 spec doesn't mandate ordering in a Neighbors packet, so this is not a protocol violation, but recipients that use the response to seed their own lookups get a sub-optimal ordering.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
Fix the following 4 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 4
crates/networking/p2p/discovery/lookup.rs:33-35
**`queries_in_flight` is tracked but never consulted**

`queries_in_flight` is incremented by `next_to_query` and decremented by `record_response`/`record_timeout`, but `is_finished()` ignores it entirely — convergence is driven purely by whether all entries are `queried`. The field documents a useful concept but has no effect on behavior; it will mislead future maintainers who expect it to gate lookup completion or backpressure.

### Issue 2 of 4
crates/networking/p2p/peer_table.rs:482-486
Documentation says "up to 50,000" but the constant is 10,000. Stale copy-paste from an earlier design iteration.

```suggestion
    /// Flat pool of discovered contacts for RLPx connection initiation.
    /// Decoupled from the k-bucket routing table so that connection initiation
    /// has access to a much larger candidate pool than the k-bucket structure
    /// allows (k-buckets: 256 × 16 = 4,096 max; this pool: up to 10,000).
    /// K-buckets are still used for all Kademlia protocol operations.
```

### Issue 3 of 4
crates/networking/p2p/discovery/discv4_handlers.rs:438-447
**`record_response()` always credits the first lookup, not the one that sent the query**

`feed_results` is correctly broadcast to all active lookups, but `record_response()` is only called on `active_lookups.first_mut()`. The architecture currently enforces a single-active-lookup invariant (a new lookup is only started when the list is empty), so `first()` is always the only entry. However, if that invariant ever relaxes, lookups at indices > 0 will accumulate a `queries_in_flight` counter that never decreases, leaving them wedged until the 20-second timeout fires. The same pattern exists in `discv5_handle_nodes_message`.

### Issue 4 of 4
crates/networking/p2p/peer_table.rs:1364-1389
**`do_get_closest_nodes` returns the k-closest set in arbitrary order**

The function correctly maintains a heap-like structure to find the `MAX_NODES_IN_NEIGHBORS_PACKET` closest nodes, but `nodes.into_iter().map(|(node, _)| node).collect()` returns them in insertion order, not sorted by XOR distance. By contrast, `do_get_closest_from_pool` (used for seeding lookups) sorts before returning. The discv4 spec doesn't mandate ordering in a Neighbors packet, so this is not a protocol violation, but recipients that use the response to seed their own lookups get a sub-optimal ordering.

Reviews (1): Last reviewed commit: "Merge branch 'main' into feat/kademlia-v..." | Re-trigger Greptile

Comment on lines +33 to +35
queries_in_flight: usize,
started_at: Instant,
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 queries_in_flight is tracked but never consulted

queries_in_flight is incremented by next_to_query and decremented by record_response/record_timeout, but is_finished() ignores it entirely — convergence is driven purely by whether all entries are queried. The field documents a useful concept but has no effect on behavior; it will mislead future maintainers who expect it to gate lookup completion or backpressure.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/networking/p2p/discovery/lookup.rs
Line: 33-35

Comment:
**`queries_in_flight` is tracked but never consulted**

`queries_in_flight` is incremented by `next_to_query` and decremented by `record_response`/`record_timeout`, but `is_finished()` ignores it entirely — convergence is driven purely by whether all entries are `queried`. The field documents a useful concept but has no effect on behavior; it will mislead future maintainers who expect it to gate lookup completion or backpressure.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +482 to +486
/// Flat pool of discovered contacts for RLPx connection initiation.
/// Decoupled from the k-bucket routing table so that connection initiation
/// has access to a much larger candidate pool than the k-bucket structure
/// allows (k-buckets: 256 × 16 = 4,096 max; this pool: up to 50,000).
/// K-buckets are still used for all Kademlia protocol operations.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Documentation says "up to 50,000" but the constant is 10,000. Stale copy-paste from an earlier design iteration.

Suggested change
/// Flat pool of discovered contacts for RLPx connection initiation.
/// Decoupled from the k-bucket routing table so that connection initiation
/// has access to a much larger candidate pool than the k-bucket structure
/// allows (k-buckets: 256 × 16 = 4,096 max; this pool: up to 50,000).
/// K-buckets are still used for all Kademlia protocol operations.
/// Flat pool of discovered contacts for RLPx connection initiation.
/// Decoupled from the k-bucket routing table so that connection initiation
/// has access to a much larger candidate pool than the k-bucket structure
/// allows (k-buckets: 256 × 16 = 4,096 max; this pool: up to 10,000).
/// K-buckets are still used for all Kademlia protocol operations.
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/networking/p2p/peer_table.rs
Line: 482-486

Comment:
Documentation says "up to 50,000" but the constant is 10,000. Stale copy-paste from an earlier design iteration.

```suggestion
    /// Flat pool of discovered contacts for RLPx connection initiation.
    /// Decoupled from the k-bucket routing table so that connection initiation
    /// has access to a much larger candidate pool than the k-bucket structure
    /// allows (k-buckets: 256 × 16 = 4,096 max; this pool: up to 10,000).
    /// K-buckets are still used for all Kademlia protocol operations.
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +438 to +447
if let Some(discv4) = &mut self.discv4 {
let entries: Vec<(H256, Node)> =
nodes.iter().map(|n| (n.node_id(), n.clone())).collect();
for (lookup, _) in &mut discv4.active_lookups {
lookup.feed_results(entries.clone());
}
if let Some((lookup, _)) = discv4.active_lookups.first_mut() {
lookup.record_response();
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 record_response() always credits the first lookup, not the one that sent the query

feed_results is correctly broadcast to all active lookups, but record_response() is only called on active_lookups.first_mut(). The architecture currently enforces a single-active-lookup invariant (a new lookup is only started when the list is empty), so first() is always the only entry. However, if that invariant ever relaxes, lookups at indices > 0 will accumulate a queries_in_flight counter that never decreases, leaving them wedged until the 20-second timeout fires. The same pattern exists in discv5_handle_nodes_message.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/networking/p2p/discovery/discv4_handlers.rs
Line: 438-447

Comment:
**`record_response()` always credits the first lookup, not the one that sent the query**

`feed_results` is correctly broadcast to all active lookups, but `record_response()` is only called on `active_lookups.first_mut()`. The architecture currently enforces a single-active-lookup invariant (a new lookup is only started when the list is empty), so `first()` is always the only entry. However, if that invariant ever relaxes, lookups at indices > 0 will accumulate a `queries_in_flight` counter that never decreases, leaving them wedged until the 20-second timeout fires. The same pattern exists in `discv5_handle_nodes_message`.

How can I resolve this? If you propose a fix, please make it concise.

@ElFantasma ElFantasma 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.

There is no CHANGELOG.md entry for this change in the diff. For a routing-table architecture migration this size, an entry under a Perf or Refactor section (with a one-liner pointing at the structural shift) would help anyone bisecting a future P2P regression understand the cutover. Easy to add as a follow-up.

for (lookup, _) in &mut discv4.active_lookups {
lookup.feed_results(entries.clone());
}
if let Some((lookup, _)) = discv4.active_lookups.first_mut() {

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.

record_response attribution mismatch. feed_results is called on EVERY active lookup just above (line 437-441), but record_response is called only on active_lookups.first_mut(). So if multiple lookups are in flight, only the first one's queries_in_flight counter is decremented for any Neighbors response, regardless of which lookup the FindNode actually came from.

return;
}
if self.connection_pool.len() >= MAX_CONNECTION_POOL_SIZE {
self.connection_pool.shift_remove_index(0);

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.

shift_remove_index(0) is O(N) per overflow. Once the pool reaches the 10K cap, every new insert pays a 10K-element shift to drop the front entry. Under churn (e.g., bootstrap burst, peer rotation), that's a real cost on the hot path of every newly-discovered contact.

IndexMap has swap_remove_index(0) which is O(1) but breaks insertion order — and the LRU-like eviction here depends on insertion order to drop the OLDEST entry. So swap_remove_index would evict the wrong thing.

Two cleaner shapes:

  1. VecDeque<(H256, Node)> + separate FxHashMap<H256, usize> index: pop_front() is O(1), push_back() is O(1) amortized. Indices in the map need updating on pop, but only ~once per overflow, not per access.
  2. Ring-buffer with monotonic head/tail counters: pure O(1), no shifting, no map index updates.

Not urgent — under steady state the pool fills slowly. But under bootstrap or recovery the cost is real (the PR description's "~630 FindNode/min during bootstrap" implies a steady stream of inserts the moment the pool hits cap).

@ilitteri
ilitteri added this pull request to the merge queue May 29, 2026
Merged via the queue into main with commit 6f2eb28 May 29, 2026
69 checks passed
@ilitteri
ilitteri deleted the feat/kademlia-v2 branch May 29, 2026 14:06
@github-project-automation github-project-automation Bot moved this from In Review to Done in ethrex_l1 May 29, 2026
benbencik pushed a commit to benbencik/ethrex that referenced this pull request Jun 13, 2026
## Summary

Replaces the flat peer table with a proper Kademlia k-bucket routing
table and iterative convergence lookup (geth-style), while maintaining
comparable traffic and sync performance to main.

### Key changes

- **Kademlia k-bucket routing table**: 256 buckets × 16 entries with
replacement lists, XOR distance-based routing
- **Flat connection pool**: Decoupled from k-buckets (up to 10K entries)
so RLPx connection initiation has a larger candidate pool
- **Iterative convergence lookup**: Seeds from connection pool, queries
alpha=3 closest unqueried nodes per timer tick, feeds Neighbors/Nodes
responses back into the result set
- **Timer-driven lookup advance**: Each timer tick advances by alpha=3
queries. Responses feed results into the lookup but don't trigger new
queries, naturally throttling traffic
- **Scaled lookup interval**: 500ms initial → 10s steady-state (vs
main's 100ms→600ms) to account for iterative lookups generating ~16
FindNodes each vs main's 1

### Traffic comparison (Prometheus metrics, 30 min capture)

| Metric | main (srv10, /min) | kademlia (srv3, /min) | Ratio |
|--------|-------------------|-----------------------|-------|
| v4 FindNode (steady) | 131 | 144 | 1.1x |
| v5 FindNode (steady) | 132 | 148 | 1.1x |
| v4 Ping | 61 | 34 | 0.6x |
| v5 Ping | 61 | 60 | 1.0x |
| **Total FindNode** | **263** | **292** | **1.1x** |
| **Total Ping** | **122** | **94** | **0.8x** |

Bootstrap burst (first 5 min): ~630 FindNode/min total, dropping to
~290/min by minute 29.

### Multisync comparison (same hardware, same Docker setup)

Three-network snapsync (mainnet + sepolia + hoodi) on srv3:

| Network | main | kademlia | Delta |
|---------|------|----------|-------|
| hoodi | baseline | −1 min | faster |
| sepolia | baseline | −1 min | faster |
| mainnet | baseline | +2 min | slower |

**Result: within noise.** No regression on sync time despite the
architectural change.

Note: Docker multisync uses bridge networking (outbound-only, no inbound
P2P), which limits peer count to ~50-60 and disproportionately affects
kademlia (pool diversity matters more when 100% of peers come from the
pool). Bare-metal performance should be slightly better since inbound
connections add peer diversity independent of discovery strategy.

### Bare-metal peer discovery (clean restart, no peer cache)

| Time | Kademlia peers | Main peers |
|------|---------------|------------|
| 1m   | 3             | 8          |
| 5m   | 17            | 34         |
| 10m  | 55            | 61         |
| 20m  | 97            | 88         |

Slower first 5 min, catches up by 10 min, beats main at 20 min.

### Why Kademlia matters

The current flat peer table works well for filling RLPx slots but
doesn't provide structured routing. Kademlia gives us:
- **O(log n) routing** for future DHT lookups (e.g., EIP-7594 PeerDAS)
- **Resilience**: k-buckets with replacement lists handle churn better
- **Scalability**: structured routing scales to large networks without
random probing
- **Standards compliance**: aligns with devp2p/discv5 spec expectations

### What was explored and removed

Several approaches were tried and removed for generating excessive
traffic:
- **Pre-bonding** (pinging discovered nodes before querying): 7x traffic
burst
- **Concurrent lookups**: multiplied traffic without proportional peer
gains
- **Response-driven advance**: discv5 lookups converged in <200ms,
churning 60+ lookups/min
- **Back-to-back lookup chaining**: kept tick permanently active

The final design uses timer-driven advance with a scaled easing curve,
naturally throttling traffic while benefiting from structured routing.
MysticRyuujin pushed a commit to MysticRyuujin/ethrex that referenced this pull request Aug 24, 2026
…aclass#7163)

**Motivation**

`DiscoveryConfig::initial_lookup_interval` is dead data. lambdaclass#6511 (kademlia
k-bucket routing table v2) replaced the configurable bound in
`DiscoveryServer::get_lookup_interval` with its own constants, because
each iterative lookup emits ~16 FindNode messages instead of 1 and
needed a rescaled schedule:

```diff
         lookup_interval_function(
             peer_completion,
-            self.config.initial_lookup_interval,
-            super::LOOKUP_INTERVAL_MS,
+            ITERATIVE_LOOKUP_INITIAL_MS,   // 500.0
+            ITERATIVE_LOOKUP_INTERVAL_MS,  // 10_000.0
         )
```

The field itself survived, still threaded from `--p2p.lookup-interval`
through `P2PContext` and `start_network` into a `DiscoveryServer` that
never reads it. The only live consumer of the flag is the RLPx
initiator, so the help text was also misleading.

While tracing it, a second problem turned up: `impl Default for Options`
sets `lookup_interval: Default::default()`, which is `0.0` for `f64`
rather than the `INITIAL_LOOKUP_INTERVAL_MS` that clap uses for the
flag. `default_l1()`, `default_l2()` and the L2 node options all build
on that impl, so they hand the RLPx initiator a 0ms lower bound. With no
peers connected the easing curve returns `Duration::ZERO`, and
`handle_look_for_peer` re-arms its timer with no delay at all. Present
since lambdaclass#5518 added the flag.

**Description**

- Remove `initial_lookup_interval` from `DiscoveryConfig` and stop
  overriding it in `start_network`. `DiscoveryConfig`'s `Default` impl
  goes with it: both remaining fields are set explicitly at the single
  construction site, and clap already defaults them to `true`.
- Reword `--p2p.lookup-interval`'s help to describe what it actually
  controls, and refresh the now-stale doc comments on
  `INITIAL_LOOKUP_INTERVAL_MS` / `LOOKUP_INTERVAL_MS` /
  `lookup_interval_function`.
- Default `Options::lookup_interval` to `INITIAL_LOOKUP_INTERVAL_MS`.

No behavior change for the discovery server, which was already running
on its own constants. The second commit does change behavior for the
dev/L2 paths that go through `Options::default()`, restoring the 100ms
floor they were meant to have.

**Checklist**

- [ ] Updated `STORE_SCHEMA_VERSION` (crates/storage/lib.rs) if the PR
includes breaking changes to the `Store` requiring a re-sync.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

L1 Ethereum client

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

4 participants