Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -366,10 +366,10 @@ actual_slot = finalized_slot + 1 + relative_index
- Fork-digest rollover happens at runtime (`ForkSchedule`, `beacon::transition`): the next
digest's topics are joined `SUBSCRIBE_LEAD_EPOCHS` before its boundary and the previous
one's left `UNSUBSCRIBE_LAG_EPOCHS` after, blob-parameter (BPO) boundaries included.
**Known gap**: ethrex's `DiscoveryServer` has no message that edits the served ENR (its one
consumer-driven edit, the `fork_id` watch, writes `eth`, not `eth2`), so its `eth2` entry
keeps the startup digest until a restart; peers discovering the node after a boundary
reject the record. See `discovery::update_served_enr`
Every `apply` also offers the schedule's `ENRForkID` to the served ENR
(`discovery::LocalEnrWriter::publish_fork_id`), which edits it only when it differs, so the
record is re-signed once per boundary and peers discovering the node afterwards read the
digest it is on now
- `voluntary_exit`, `proposer_slashing`, `attester_slashing` and `bls_to_execution_change`
validate in p2p like aggregates (`gossip::operations`, against the unadvanced head state,
which must be electra or fulu: under a gloas head every operation is IGNOREd) and feed
Expand All @@ -395,7 +395,7 @@ actual_slot = finalized_slot + 1 + relative_index
### Peer Discovery (discv5)
- Always on for `beacon` (mainnet bootnode ENRs are not statically dialable, so a crawl is its only way to find a peer); opt-in for `node` behind the lean-only `--discovery.enable`, off by default, so a lean node peers from `--bootnodes` alone and binds no discovery socket. `Network::discovery_enabled` is the one answer; `P2P::spawn` takes `Option<DiscoverySpawnConfig>` and `P2PServer.discovery` is an `Option`, `None` leaving the dial loop unscheduled
- Where it runs: `DEFAULT_DISCOVERY_PORT` (9000) unless `--discovery.port` says otherwise (own UDP socket, must differ from `--gossipsub-port`; checked once by `Options::validate_ports`, which skips the discovery rules when it is off). Co-located nodes that run discovery must each pass `--discovery.port`
- Reuses ethrex's `DiscoveryServer` with discv4 disabled, pinned to a commit of ethrex's `feat/discv5-predicate-queries` (on top of ethrex PR #7217) until both are in a release; `spawn` takes the prepared lean ENR. ethrex re-signs it on IP voting and mirrors it into the `SharedLocalNode` it was spawned with; `discovery::LocalEnr` reads that mirror, so `/eth/v1/node/identity` reports the record discv5 currently serves. Read-only: nothing written there reaches discv5
- Reuses ethrex's `DiscoveryServer` with discv4 disabled, pinned to a commit of ethrex's `feat/discv5-lock-free-local-enr` (on `feat/discv5-predicate-queries`, on ethrex PR #7217) until all three are in a release; `spawn` takes the prepared lean ENR in a `SharedLocalNode`, a lock-free `ArcSwap` slot that is the only copy of the record. Discovery serves from it and swaps in every record IP voting re-signs; `discovery::LocalEnr` reads it (so `/eth/v1/node/identity` reports the record discv5 currently serves), and `discovery::LocalEnrWriter`, which holds the node key and stays crate-private, edits it by compare-and-swap (`edit_record`: one `seq` bump, re-signed, retried on a concurrent IP update)
- ENR follows the beacon phase0 spec: `ip`/`udp`/`quic`/`tcp`/`secp256k1`/`eth2`/`attnets`
- Admission mirrors lighthouse: `eth2.fork_digest` must match, `next_fork_*` may differ, a `quic` or `tcp` entry required. Handed to discovery as `LeanFilter: PeerFilter`, so records are judged on arrival, not at dial time; a reject is re-judged on a higher-`seq` ENR
- The filter files each admitted peer in `DialTargets`, since discovery hands back a `Node` that carries no `quic`/`tcp`/`attnets`; the dial loop looks the node id back up there
Expand Down
44 changes: 27 additions & 17 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 13 additions & 10 deletions crates/net/p2p/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,20 +51,23 @@ rand = "0.8"

# Required for NodeEnr parsing.
#
# Pinned to the head of ethrex's `feat/discv5-predicate-queries`, which builds
# on PR #7217. #7217 moves the contact table into the discovery server and gives
# a consumer `DiscoveryHandle::record_peer_event` to report its own connections
# with, which is what lets a libp2p client feed ethrex's lookup pacing. The
# branch adds `DiscoveryHandle::find_nodes`/`records_matching`, the predicate
# queries the subnet hunt in `discovery/subnets.rs` runs on. See
# `docs/discovery.md`.
# Pinned to the head of ethrex's `feat/discv5-lock-free-local-enr`, which builds
# on `feat/discv5-predicate-queries`, itself on PR #7217. #7217 moves the contact
# table into the discovery server and gives a consumer
# `DiscoveryHandle::record_peer_event` to report its own connections with, which
# is what lets a libp2p client feed ethrex's lookup pacing. The middle branch
# adds `DiscoveryHandle::find_nodes`/`records_matching`, the predicate queries
# the subnet hunt in `discovery/subnets.rs` runs on. The top one makes the
# `SharedLocalNode` slot the only copy of the served record and lets a consumer
# edit it (`edit_record`), which is how a fork boundary moves our `eth2` entry.
# See `docs/discovery.md`.
#
# A commit rather than a branch, for the reason the release tag it replaces was
# chosen over `main`: what this crate builds against must not move without the
# manifest saying so. Goes back to a tag once both are in a release.
ethrex-p2p = { git = "https://github.com/lambdaclass/ethrex", rev = "8f84752408a126cfa8a9c21c3ebaa76658023cb7" }
ethrex-rlp = { git = "https://github.com/lambdaclass/ethrex", rev = "8f84752408a126cfa8a9c21c3ebaa76658023cb7" }
ethrex-common = { git = "https://github.com/lambdaclass/ethrex", rev = "8f84752408a126cfa8a9c21c3ebaa76658023cb7" }
ethrex-p2p = { git = "https://github.com/lambdaclass/ethrex", rev = "80308d0df591f97a3bb1f81cd868f13b9729b48e" }
ethrex-rlp = { git = "https://github.com/lambdaclass/ethrex", rev = "80308d0df591f97a3bb1f81cd868f13b9729b48e" }
ethrex-common = { git = "https://github.com/lambdaclass/ethrex", rev = "80308d0df591f97a3bb1f81cd868f13b9729b48e" }

# `rand` seeds the throwaway signing keys the ENR tests build records with.
secp256k1.workspace = true
Expand Down
55 changes: 47 additions & 8 deletions crates/net/p2p/src/beacon/transition.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ use tracing::{error, info};

use super::BeaconWire;
use super::topics::{self, BeaconTopics};
use crate::{P2PServer, Wire, discovery, metrics};
use crate::{P2PServer, Wire, metrics};

/// How far before a boundary a peer may already be on the next digest and still
/// count as current: clocks disagree by up to the gossip clock disparity.
Expand Down Expand Up @@ -114,9 +114,9 @@ pub(crate) fn advance(server: &mut P2PServer) -> Option<Duration> {
/// aggregator-subnet topics already held for the current slot.
/// 3. Leave the topics of every digest the window no longer covers.
/// 4. If the current digest changed, switch what this node publishes and
/// advertises: digest, fork, metadata sequence number, metric, and the
/// `eth2` entry.
/// 5. Point the admission filter at the new entry, admitting every held digest.
/// advertises: digest, fork, metadata sequence number and metric.
/// 5. Point the admission filter at the `eth2` entry the schedule prescribes at
/// `epoch`, admitting every held digest, and serve that entry in the ENR.
pub(crate) fn apply(server: &mut P2PServer, epoch: Epoch) -> Changes {
let mut changes = Changes::default();
let Wire::Beacon(wire) = &mut server.wire else {
Expand Down Expand Up @@ -244,12 +244,13 @@ pub(crate) fn apply(server: &mut P2PServer, epoch: Epoch) -> Changes {
.iter()
.map(|topics| topics.fork_digest)
.collect();
// Without discovery there is no admission policy to update.
// Without discovery there is no admission policy to update and no ENR to
// serve. The ENR is offered the entry on every call, not only on a switch:
// it skips one it already carries, so this costs nothing between
// boundaries and keeps the served record on the schedule's answer.
if let Some(state) = &server.discovery {
state.filter().set_fork_id(fork_id, also_admitted);
}
if switched {
discovery::update_served_enr(&fork_id);
state.local_enr_writer().publish_fork_id(&fork_id);
}
changes
}
Expand All @@ -265,6 +266,7 @@ fn sync_committee_topic(digest: ForkDigest, subnet_id: u64) -> IdentTopic {
#[cfg(test)]
mod tests {
use super::*;
use crate::discovery::enr::{ETH2_ENR_KEY, EnrForkId};
use crate::test_support::unconnected_beacon_server;
use ethlambda_types::beacon::fork::ForkName;
use libssz_types::SszList;
Expand Down Expand Up @@ -379,6 +381,43 @@ mod tests {
assert_eq!(server.wire.beacon().unwrap().fork, ForkName::Gloas);
}

/// The `eth2` entry discovery serves, and the `seq` it is served under.
fn served_eth2(server: &P2PServer) -> (EnrForkId, u64) {
use libssz::SszDecode;
let record = server
.discovery
.as_ref()
.expect("the test server runs discovery")
.local_enr_writer()
.served_record();
let raw = record.pairs().extra(ETH2_ENR_KEY).expect("eth2 entry");
let fork_id = EnrForkId::from_ssz_bytes(&raw).expect("a valid ENRForkID");
(fork_id, record.seq)
}

#[tokio::test]
async fn a_crossing_moves_the_served_eth2_entry_once() {
let mut server = unconnected_beacon_server(config(), 0).await;
let schedule = server.wire.beacon().expect("beacon").schedule.clone();

apply(&mut server, BPO - 1);
let (served, seq) = served_eth2(&server);
assert_eq!(served, schedule.enr_fork_id(BPO - 1));

// Between boundaries the entry is already right, so the record is left
// alone: a bumped `seq` would send peers back for an identical record.
apply(&mut server, BPO - 1);
assert_eq!(served_eth2(&server), (served, seq));

// At the boundary peers discovering the node read the new digest, under
// one new `seq`.
apply(&mut server, BPO);
let (crossed, crossed_seq) = served_eth2(&server);
assert_eq!(crossed, schedule.enr_fork_id(BPO));
assert_eq!(crossed.fork_digest, schedule.digest_at(BPO));
assert_eq!(crossed_seq, seq + 1);
}

#[tokio::test]
async fn a_node_started_inside_the_window_joins_both_digests() {
let config = config();
Expand Down
11 changes: 10 additions & 1 deletion crates/net/p2p/src/discovery/dial.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ use super::node_id::node_id_from_peer_id;
use super::subnets::SubnetHunt;
use super::{
DIAL_INTERVAL_AT_TARGET, DIAL_INTERVAL_AT_ZERO_PEERS, DISCOVERY_CANDIDATE_BATCH,
SpawnedDiscovery,
LocalEnrWriter, SpawnedDiscovery,
};
use crate::swarm_adapter::DialOutcome;
use crate::{ConnectionDirection, P2PServer, metrics};
Expand Down Expand Up @@ -58,6 +58,9 @@ pub(crate) struct DiscoveryState {
/// A handle on the admission policy discovery also runs, so a fork
/// boundary can move both with one call. See [`LeanFilter::set_fork_id`].
filter: LeanFilter,
/// The write side of the ENR discovery serves, so a fork boundary can move
/// its `eth2` entry along with the filter.
local_enr_writer: LocalEnrWriter,
}

impl DiscoveryState {
Expand All @@ -78,6 +81,7 @@ impl DiscoveryState {
local_peer_id,
target_peers: spawned.target_peers,
filter: spawned.filter,
local_enr_writer: spawned.local_enr_writer,
}
}

Expand All @@ -86,6 +90,11 @@ impl DiscoveryState {
&self.filter
}

/// The write side of the ENR discovery serves.
pub(crate) fn local_enr_writer(&self) -> &LocalEnrWriter {
&self.local_enr_writer
}

/// Connected-peer count above which the loop stops dialing.
pub(crate) fn target_peers(&self) -> usize {
self.target_peers
Expand Down
Loading
Loading