Skip to content
Open
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
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ actual_slot = finalized_slot + 1 + relative_index
## Networking (libp2p)

### Protocols
- **Transport**: QUIC over UDP (TLS 1.3)
- **Transport**: QUIC over UDP (TLS 1.3), plus TCP (noise + yamux) on the same port number as a fallback: a peer whose advertised `quic` doesn't answer can still be reached over TCP, and libp2p races both addresses within one dial
- **Gossipsub**: Blocks + Attestations (snappy raw compression)
- Topic: `/leanconsensus/{fork_digest}/{block|aggregation|attestation_N}/ssz_snappy`
- `fork_digest` is a 4-byte hex string (no `0x` prefix); currently the dummy `12345678` agreed across clients
Expand All @@ -283,8 +283,8 @@ actual_slot = finalized_slot + 1 + relative_index
### Peer Discovery (discv5, opt-in)
- Off by default; `--discovery.enable` plus `--discovery.port` (own UDP socket, must differ from `--gossipsub-port`)
- Reuses ethrex's `DiscoveryServer` + `PeerTable` with discv4 disabled; `spawn` takes the prepared lean ENR, so the record ethrex serves is the one we report
- ENR follows the beacon phase0 spec: `ip`/`udp`/`quic`/`secp256k1`/`eth2`/`attnets`
- Admission mirrors lighthouse: `eth2.fork_digest` must match, `next_fork_*` may differ, `quic` entry required. Handed to the peer table as `LeanFilter: PeerFilter`, so records are judged on arrival, not at dial time; a reject is re-judged on a higher-`seq` ENR
- 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 the peer table as `LeanFilter: PeerFilter`, so records are judged on arrival, not at dial time; a reject is re-judged on a higher-`seq` ENR
- Candidates ranked by uncovered attestation subnets. See [`docs/discovery.md`](docs/discovery.md)

### Retry Strategy on Block Requests
Expand Down
3 changes: 3 additions & 0 deletions bin/ethlambda/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,9 @@ async fn run_node(options: NodeOptions) -> eyre::Result<()> {
bind_ip: p2p_socket.ip(),
discovery_port: options.discovery.port,
quic_port: p2p_socket.port(),
// Same port number as `quic_port`: TCP and UDP are separate namespaces,
// so `build_swarm` binds both without a collision.
tcp_port: p2p_socket.port(),
subscription_subnets: subscribed_subnets,
attestation_committee_count,
bootnodes,
Expand Down
118 changes: 102 additions & 16 deletions crates/net/p2p/src/discovery/admission.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,16 @@ use super::enr::{
ATTNETS_ENR_KEY, ETH2_ENR_KEY, EnrForkId, read_ip, read_public_key, read_quic_port,
subnets_from_attnets,
};
use crate::quic_multiaddr;
use crate::{quic_multiaddr, tcp_multiaddr};

/// A peer that passed admission and is ready to dial.
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct DiscoveredPeer {
pub(crate) peer_id: PeerId,
pub(crate) addr: Multiaddr,
/// Dial targets, QUIC first then TCP, built from whichever of the two ports
/// the record actually advertises. Never empty: [`admit`] rejects a record
/// with neither.
pub(crate) addrs: Vec<Multiaddr>,
/// Attestation subnets the peer advertises in `attnets`.
pub(crate) subnets: Vec<u64>,
}
Expand All @@ -51,9 +54,10 @@ pub(crate) enum RejectReason {
MissingForkId,
/// On a different network.
ForkDigestMismatch,
/// Discoverable over discv5, but advertises no dialable libp2p QUIC port
/// (see [`read_quic_port`] for what that folds together).
NoQuicPort,
/// Discoverable over discv5, but advertises no dialable transport: neither a
/// libp2p QUIC port nor a libp2p TCP port (see [`read_quic_port`] for what
/// "dialable" folds together; a `0` port is treated as absent either way).
NoDialableTransport,
/// No `secp256k1` entry, or one that is not a valid key.
BadPublicKey,
/// Neither `ip` nor `ip6`.
Expand Down Expand Up @@ -144,7 +148,15 @@ fn admit(
);
}

let quic_port = read_quic_port(record).ok_or(RejectReason::NoQuicPort)?;
// Mainnet peers widely advertise a `quic` entry that does not answer, so
// accepting TCP as well is what keeps a dial from timing out with nowhere to
// fall back to. A `0` port is absent for either transport: it RLP-decodes
// the same way an absent entry does, and is undialable regardless.
let quic_port = read_quic_port(record);
let tcp_port = pairs.tcp_port.filter(|port| *port != 0);
if quic_port.is_none() && tcp_port.is_none() {
return Err(RejectReason::NoDialableTransport);
}

let public_key = read_public_key(pairs).ok_or(RejectReason::BadPublicKey)?;
let peer_id = PeerId::from_public_key(&libp2p::identity::PublicKey::from(public_key));
Expand All @@ -156,9 +168,19 @@ fn admit(
.map(|bits| subnets_from_attnets(&bits, attestation_committee_count))
.unwrap_or_default();

// QUIC first, so it stays the preferred path when a peer offers both and

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

"QUIC first, so it stays the preferred path" doesn't hold: the pinned fork's ConcurrentDial (swarm pool/concurrent_dial.rs) pushes up to dial_concurrency_factor (default 8) addresses into a FuturesUnordered at once, so with two addresses list order confers no preference — both handshakes run on every dial (2x sockets and crypto on both ends) and the mesh nondeterministically lands on whichever finishes first. To make the ordering real and TCP a true fallback: .override_dial_concurrency_factor(NonZeroU8::new(1).unwrap()) on the DialOpts builders, or dial TCP only after a QUIC failure. (The same ordering claim appears in lib.rs, swarm_adapter.rs and docs/discovery.md.)

// libp2p races the list.
let mut addrs = Vec::with_capacity(2);
if let Some(port) = quic_port {
addrs.push(quic_multiaddr(ip, port, peer_id));
}
if let Some(port) = tcp_port {
addrs.push(tcp_multiaddr(ip, port, peer_id));
}

Ok(DiscoveredPeer {
peer_id,
addr: quic_multiaddr(ip, quic_port, peer_id),
addrs,
subnets,
})
}
Expand Down Expand Up @@ -264,7 +286,7 @@ mod tests {
fn for_test(subnets: Vec<u64>) -> Self {
Self {
peer_id: PeerId::random(),
addr: Multiaddr::empty(),
addrs: vec![Multiaddr::empty()],
subnets,
}
}
Expand All @@ -279,8 +301,52 @@ mod tests {
let peer = admit_record(&record).expect("accepted");
assert_eq!(peer.subnets, vec![2, 5]);
assert_eq!(
peer.addr.to_string(),
format!("/ip4/127.0.0.1/udp/9001/quic-v1/p2p/{}", peer.peer_id)
peer.addrs,
vec![
format!("/ip4/127.0.0.1/udp/9001/quic-v1/p2p/{}", peer.peer_id)
.parse()
.unwrap()
]
);
}

#[test]
fn accepts_a_tcp_only_peer() {
// Every published mainnet beacon-chain bootnode looks like this: `tcp`
// and `udp`, no `quic`. Before TCP support this was `NoQuicPort`.
let record = record_with(|pairs| {
set_eth2(pairs, EnrForkId::local());
pairs.tcp_port = Some(9001);
});
let peer = admit_record(&record).expect("accepted");
assert_eq!(
peer.addrs,
vec![
format!("/ip4/127.0.0.1/tcp/9001/p2p/{}", peer.peer_id)
.parse()
.unwrap()
]
);
}

#[test]
fn accepts_a_peer_with_both_transports_and_orders_quic_first() {
let record = record_with(|pairs| {
set_eth2(pairs, EnrForkId::local());
set_quic(pairs, 9001);
pairs.tcp_port = Some(9002);
});
let peer = admit_record(&record).expect("accepted");
assert_eq!(
peer.addrs,
vec![
format!("/ip4/127.0.0.1/udp/9001/quic-v1/p2p/{}", peer.peer_id)
.parse()
.unwrap(),
format!("/ip4/127.0.0.1/tcp/9002/p2p/{}", peer.peer_id)
.parse()
.unwrap(),
]
);
}

Expand Down Expand Up @@ -317,23 +383,43 @@ mod tests {
}

#[test]
fn rejects_a_peer_with_no_quic_port() {
// Reachable by discv5 but not over our only transport.
fn rejects_a_peer_with_no_quic_or_tcp_port() {
// Reachable by discv5 but over neither transport we speak.
let record = record_with(|pairs| set_eth2(pairs, EnrForkId::local()));
assert_eq!(admit_record(&record), Err(RejectReason::NoQuicPort));
assert_eq!(
admit_record(&record),
Err(RejectReason::NoDialableTransport)
);
}

#[test]
fn rejects_a_peer_with_a_quic_port_of_zero() {
fn rejects_a_peer_with_a_quic_port_of_zero_and_no_tcp() {
// A port of 0 is undialable, and this is also how an absent entry
// decodes (left-padded to 0u16), so it must hit the same reason as
// `rejects_a_peer_with_no_quic_port` rather than sail through as
// `rejects_a_peer_with_no_quic_or_tcp_port` rather than sail through as
// "accepted" with an unusable `/udp/0/quic-v1` multiaddr.
let record = record_with(|pairs| {
set_eth2(pairs, EnrForkId::local());
set_quic(pairs, 0);
});
assert_eq!(admit_record(&record), Err(RejectReason::NoQuicPort));
assert_eq!(
admit_record(&record),
Err(RejectReason::NoDialableTransport)
);
}

#[test]
fn rejects_a_peer_with_a_tcp_port_of_zero_and_no_quic() {
// `tcp: 0` decodes the same way an absent entry does, exactly as
// `quic: 0` does, so it must reach the same rejection.
let record = record_with(|pairs| {
set_eth2(pairs, EnrForkId::local());
pairs.tcp_port = Some(0);
});
assert_eq!(
admit_record(&record),
Err(RejectReason::NoDialableTransport)
);
}

#[test]
Expand Down
9 changes: 8 additions & 1 deletion crates/net/p2p/src/discovery/dial.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use std::collections::{HashMap, HashSet, VecDeque};

use ethrex_p2p::peer_table::{PeerTable, PeerTableServerProtocol as _};
use libp2p::PeerId;
use libp2p::swarm::dial_opts::DialOpts;
use tracing::info;

use super::admission::{DiscoveredPeer, LeanFilter, rank_by_uncovered_subnets};
Expand Down Expand Up @@ -109,7 +110,13 @@ pub(crate) async fn dial_tick(server: &mut P2PServer) {
// synchronously — already connected, already dialing, no addresses, denied —
// raises no `OutgoingConnectionError`, so `forget_discovered_peer` would
// never run and the entry would outlive the process's interest in it.
if !server.swarm_handle.dial_accepted(candidate.addr).await {
// One `DialOpts` carrying every address, not one dial per address: libp2p
// races them within the attempt, which is what lets a live TCP address
// rescue a peer whose advertised QUIC port does not answer.
let opts = DialOpts::peer_id(candidate.peer_id)
.addresses(candidate.addrs)
.build();
if !server.swarm_handle.dial_accepted(opts).await {
return;
}
metrics::inc_discovered_peers_dialed();
Expand Down
36 changes: 22 additions & 14 deletions crates/net/p2p/src/discovery/enr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,16 @@
//! The entry set follows the beacon-chain phase0 p2p spec's discovery domain:
//!
//! ```text
//! id, ip, udp=<discovery port>, quic=<libp2p QUIC port>, secp256k1,
//! id, ip, udp=<discovery port>, quic=<libp2p QUIC port>, tcp=<libp2p TCP port>,
//! secp256k1,
//! eth2 = SSZ(ENRForkID)
//! attnets = subscribed attestation subnet bitfield
//! ```
//!
//! There is deliberately no `tcp` entry. The spec defines it as the libp2p TCP
//! listening port and makes it optional; ethlambda speaks QUIC only, so
//! advertising one would invite a dial that cannot succeed.
//! `tcp` is the spec's own entry for the libp2p TCP listening port: ethlambda
//! binds one alongside QUIC (see `crates/net/p2p/src/lib.rs`'s `build_swarm`),
//! on the same port number, so advertising it is what lets a peer whose
//! advertised `quic` does not answer still reach us.
//!
//! Lean defines no fork schedule and its fork digest is a compile-time constant
//! rather than a genesis-derived value, so every field of [`EnrForkId`] is
Expand Down Expand Up @@ -113,29 +115,30 @@ pub(crate) struct LocalEnrParams {
pub(crate) discovery_port: u16,
/// UDP port the libp2p QUIC transport is bound to.
pub(crate) quic_port: u16,
/// TCP port the libp2p TCP transport is bound to. The same port number as
/// [`Self::quic_port`]: TCP and UDP are separate namespaces, so `build_swarm`
/// binds both without a collision.
pub(crate) tcp_port: u16,
pub(crate) subscription_subnets: HashSet<u64>,
pub(crate) attestation_committee_count: u64,
}

impl LocalEnrParams {
/// The `Node` ethrex's discovery server takes as its local identity.
///
/// `tcp_port` is 0, which ethrex reads as "no TCP listener" and omits from
/// the record.
/// `tcp_port` is the real port the libp2p TCP transport is bound to, now
/// that ethlambda has one.
pub(crate) fn local_node(&self) -> Node {
Node::new(
self.ip,
self.discovery_port,
0,
self.tcp_port,
public_key_from_signing_key(&self.signer),
)
}

/// The full entry set this node advertises.
///
/// `tcp_port` is left unset rather than zero: `from_pairs` takes the entry
/// set verbatim, so "no TCP listener" is spelled by the entry's absence.
///
/// The three consensus entries go through `set_extra`/`set_extra_int`,
/// which pick the RLP codec once. Encoding them by hand is the trap that
/// helper exists for: a bare `Vec<u8>` hits the generic `Vec<T>` impl and
Expand All @@ -145,7 +148,7 @@ impl LocalEnrParams {
fn local_pairs(&self) -> NodeRecordPairs {
let mut pairs = NodeRecordPairs {
udp_port: Some(self.discovery_port),
tcp_port: None,
tcp_port: Some(self.tcp_port),
..Default::default()
};
match self.ip.to_canonical() {
Expand Down Expand Up @@ -224,6 +227,7 @@ mod tests {
ip: IpAddr::from(Ipv4Addr::LOCALHOST),
discovery_port: 9010,
quic_port: 9001,
tcp_port: 9001,
subscription_subnets: HashSet::from([1u64, 4]),
attestation_committee_count: 8,
})
Expand Down Expand Up @@ -291,13 +295,17 @@ mod tests {
}

#[test]
fn local_enr_advertises_udp_and_quic_but_no_tcp() {
fn local_enr_advertises_udp_quic_and_tcp() {
// Inverts what this test used to pin: ethlambda now binds a TCP
// transport alongside QUIC (see `build_swarm`), so the ENR must
// advertise all three ports rather than omitting `tcp`.
let record = build();
let pairs = record.pairs();
assert_eq!(pairs.udp_port, Some(9010));
assert_eq!(
pairs.tcp_port, None,
"ethlambda has no TCP listener, so it must not advertise one"
pairs.tcp_port,
Some(9001),
"ethlambda now has a TCP listener and must advertise it"
);
assert_eq!(read_quic_port(&record), Some(9001));
}
Expand Down
5 changes: 5 additions & 0 deletions crates/net/p2p/src/discovery/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ pub struct DiscoverySpawnConfig {
pub bind_ip: IpAddr,
pub discovery_port: u16,
pub quic_port: u16,
/// TCP port the libp2p TCP transport is bound to. Same port number as
/// `quic_port`, since TCP and UDP are separate namespaces.
pub tcp_port: u16,
pub subscription_subnets: HashSet<u64>,
pub attestation_committee_count: u64,
pub bootnodes: Vec<Bootnode>,
Expand Down Expand Up @@ -140,6 +143,7 @@ pub async fn spawn_discovery(
ip: advertise_ip,
discovery_port: bound.port(),
quic_port: config.quic_port,
tcp_port: config.tcp_port,
subscription_subnets: config.subscription_subnets,
attestation_committee_count: config.attestation_committee_count,
};
Expand Down Expand Up @@ -228,6 +232,7 @@ mod tests {
bind_ip: IpAddr::from(Ipv4Addr::LOCALHOST),
discovery_port,
quic_port: 9001,
tcp_port: 9001,
subscription_subnets: HashSet::from([0u64]),
attestation_committee_count: 4,
bootnodes: Vec::new(),
Expand Down
Loading
Loading