ethlambda can find peers over discv5 instead of relying only on the static bootnode list. The implementation reuses ethrex's discovery stack, with discv4 disabled.
Discovery is always on for beacon and opt-in for node. beacon
never had a choice: published mainnet bootnode ENRs carry no quic entry, so
none of them is statically dialable and a crawl is the only way to reach a peer.
On lean it stays behind --discovery.enable (off by default), because nothing
else on that network speaks discv5 yet, so a crawl there finds only other
ethlambda nodes. Without the flag a lean node binds no discovery socket and
peers from its --bootnodes list alone, which is also what lets several devnet
nodes share one host without a --discovery.port each.
| Flag | Default | Meaning |
|---|---|---|
--discovery.enable |
false |
node only: run discv5. beacon has no such flag and always runs it |
--discovery.port |
9000 (DEFAULT_DISCOVERY_PORT) |
UDP port for the discv5 socket |
--discovery.advertise-ip |
bind address (0.0.0.0) |
IP address to advertise in the ENR |
--discovery.target-peers |
200 |
Peers this node holds: the dial loop's cutoff, and on beacon the connection limits too |
All but --discovery.enable are common flags with the same default on node
and beacon; on a node without --discovery.enable they are accepted and
ignored. See the CLI reference.
--discovery.port and --gossipsub-port (default 9001, libp2p QUIC) are both
UDP and so cannot share a port. --gossipsub-port also binds a libp2p TCP
listener on the same number, which collides with neither: TCP and UDP are
separate namespaces. The defaults are one apart, so a default invocation works;
pointing either flag at the other's port is rejected at startup, before the node
touches its data directory. Co-located nodes on one host that run discovery need
an explicit --discovery.port each, the same way they already need an explicit
--gossipsub-port. Neither check applies to a node without
--discovery.enable, since it binds no discovery socket and publishes no ENR.
The discv5 socket always binds the wildcard 0.0.0.0, since that is where we
listen, not where peers should dial us. Without --discovery.advertise-ip the
published ENR inherits that same 0.0.0.0, which is not a dialable address:
set the flag to 127.0.0.1 for a local devnet or to the host's public address
so the ENR is usable as soon as it is published. discv5's PONG-based IP voting
may still replace the advertised address later, once a peer's response tells
the node what its external address looks like.
The layout follows the discovery domain of the beacon-chain phase0 p2p interface spec.
| Entry | Value |
|---|---|
id |
v4 |
ip |
--discovery.advertise-ip, or the bind address (0.0.0.0) if unset |
udp |
--discovery.port |
quic |
--gossipsub-port, the libp2p QUIC listener; omitted when 0 |
tcp |
--gossipsub-port, the libp2p TCP listener; omitted when 0 |
secp256k1 |
compressed public key from --node-key |
eth2 |
SSZ ENRForkID, 16 bytes |
attnets |
subscribed attestation subnet bitfield |
cgc |
--custody-group-count, on beacon only; omitted on lean, which has no data-availability domain |
tcp and quic share the same port number: TCP and UDP are separate
namespaces, so build_swarm binds both without a collision. Advertising both
is what lets a peer whose quic port does not answer still reach this node
over TCP. It also gets us past lighthouse's discovery predicate, which requires
enr.tcp4().is_some() || enr.tcp6().is_some() on top of the spec's
fork_digest comparison; the lean fork digest is still the cross-client dummy
0x12345678, so a beacon-chain client rejects us on that instead.
Both ports come from configuration rather than from the bound listeners, so a
--gossipsub-port 0 would name neither of the two real OS-assigned ports.
Startup rejects that combination when discovery is enabled, and the writer omits
a 0 either way, matching every reader's rule that 0 means absent.
The local ENR is logged once at startup.
This same record is handed to ethrex's DiscoveryServer, so it is what answers
discv5 queries: what we report and what peers see are the same bytes. If IP
voting later changes our external address, ethrex edits and re-signs that record
rather than rebuilding one, so the consensus entries survive the bump; only the
sequence number and ip move, which the reported ENR then lags.
A discovered peer is admitted only if:
- its ENR carries a decodable
eth2entry, and - that entry's
fork_digestequals ours, and - it advertises a
quicport, atcpport, or both.
The digest compared against is the caller's, not a constant: lean passes its
hardcoded dummy and beacon passes the digest it derived at startup and moves
it at each fork-digest boundary, so one admission policy serves both chains. The
record this node serves is not refreshed at a boundary (ethrex's
DiscoveryServer cannot replace it at runtime), so its eth2 entry keeps the
startup digest until a restart; see beacon_wire.md.
A differing next_fork_version or next_fork_epoch is not grounds for
rejection: the spec permits connecting to a peer that is incompatible with an
upcoming fork but compatible now.
A peer advertising both transports is dialed with a QUIC-first address list, so
libp2p races them within one attempt and a quic entry that does not answer
falls back to TCP rather than ending the dial. Most mainnet beacon nodes are in
exactly that state, which is why a QUIC-only dialer peered so poorly with them.
These checks are handed to ethrex's peer table as a PeerFilter, so each record
is judged the moment it arrives and a peer that fails is not offered for dialing.
No rejection is final: the peer table runs the filter again as soon as the peer
publishes a higher-seq ENR, so a node that adds a transport entry, or gains an
address through discv5's IP voting, is reconsidered without a restart.
A peer's dial list carries every address it advertises, quic and tcp both,
in one dial attempt, quic first. libp2p walks that list dial_concurrency_factor
addresses at a time, and ethlambda pins the factor to one, so the tcp address is
reached only after the QUIC attempt ahead of it has failed. A peer whose quic
port does not answer still connects over tcp with no separate retry, but it
waits out libp2p_quic's handshake timeout first.
The list order is therefore a preference and should be read as one. It was a race
until the mainnet follower was profiled: both handshakes started at once, TCP won
most of them, and every TCP win is a connection carrying mplex, whose waker
bookkeeping cost more CPU than the entire beacon state transition. QUIC multiplexes
natively and reaches none of that code, so the cheaper transport is now asked for
first instead of merely offered. A lean peer advertises quic alone and is
unaffected either way.
Admitted peers are ranked by how many attestation subnets they advertise that no
currently connected peer covers, so discovery preferentially fills gaps in subnet
coverage. A peer advertising no attnets is ranked last but never dropped.
Dialing stops once --discovery.target-peers peers are connected, and resumes
if that count drops. Nothing in ethrex's peer table or discv5's own pacing
enforces it (see below).
On beacon the flag is more than the dial loop's cutoff: the swarm's connection
limits are derived from it too, so what this node refuses and what it goes
looking for are two readings of one number.
| Derived from the target | At the default 200 |
|---|---|
| Ceiling on established connections | 200 |
| Of those, the most inbound demand may hold (70%) | 140 |
| The rest, reserved for peers this node dials | 60 |
The reservation is what the dial loop chases as its second shortfall, alongside
the plain "are we at target" one, so inbound saturation cannot suppress dialing:
outbound peers are the only ones this node chooses, and so the only lever it has
on which custody columns its peer set covers. Deriving both from one number is
what keeps the loop from chasing slots the swarm would refuse, or stopping while
slots it reserved sit empty. --discovery.target-peers 0 therefore holds no
peers at all, dialing none and admitting none.
Lean is unaffected: it runs unlimited connections, so it has nothing to reserve against and paces on the total peer count alone.
The loop opens one dial per tick and varies the tick instead of the batch, on
ethrex's own easeInOutCubic curve, so this node's dialing, its discv5 lookups and
ethrex's RLPx dialing all ramp the same way. A node with no peers dials at
MAX_DIAL_RATE_PER_SECOND; the gap eases out to ethrex's LOOKUP_INTERVAL_MS
as the table fills, and dialing ends outright at the target. A tick that opens no
dial waits that same ceiling regardless of the curve, which is what keeps a
network with fewer peers to offer than --discovery.target-peers — every lean
devnet, against a default target of 200 — from holding the loop at its floor for
the life of the process, re-drawing candidates it is already connected to.
The three entries a bootnode ENR can carry are read independently, because they answer different questions:
| Entry | Absent means |
|---|---|
quic |
Not part of the static dial list over QUIC |
tcp |
Not part of the static dial list over TCP |
udp |
Not seeded into the discv5 routing table |
A bootnode is dropped only when it has none of the three: neither transport to
dial nor a udp port to seed discv5 from. Any other combination is kept,
including one with only quic, only tcp, only udp, or any pair. The ENRs
lean-quickstart generates today carry ip/quic/secp256k1 and no udp,
so they stay reachable but contribute nothing to discovery. The built-in
mainnet bootnode list is close to the mirror image, udp and no quic, and a
tcp entry is what makes such a record statically dialable rather than a
discv5 seed only: four of the seventeen carry one. A record missing an ip or
a secp256k1 key is dropped regardless of its transports.
The ENR a node logs at startup is only useful to a peer if that node was
started with a real --discovery.advertise-ip.
Copying an ENR built from the default 0.0.0.0 into another node's bootnode
list produces a udp/quic/tcp target that cannot be dialed, since 0.0.0.0
names no reachable host. Set --discovery.advertise-ip before pointing other
nodes at this one's ENR: 127.0.0.1 on a local devnet, or the host's public
address otherwise.
Everything under Which peers get dialed applies to
discovered peers. A static bootnode reaches the swarm by a different path:
parse_enr reads ip, secp256k1 and the three port entries and never looks at
eth2, so a --bootnodes list is dialed as given. Now that a tcp-only record
is dialable, a beacon-chain ENR is a valid static target, and the noise+yamux
handshake to a lighthouse node succeeds: the peer occupies a
--discovery.target-peers slot, contributes no attestation subnets, and is
eventually dropped by the remote for sharing no protocols. Each drop re-arms the
redial timer, which runs at a flat interval with no backoff and no cap for the
life of the process.
Accepted rather than fixed. Bootnodes are operator-supplied, so a list naming another network's infrastructure is a configuration mistake, and the unbounded redial is what keeps a devnet's own bootnode reachable across its restarts. Splitting the flag into initial peers, dialed once, and bootnodes, seeded into discv5, is the real fix and is left to a follow-up.
The record is signed at ethrex's INITIAL_ENR_SEQ, a constant. A peer
identifies a record by (node id, seq) and accepts a replacement only at a
strictly higher seq, and ethrex's WHOAREYOU responder does not even send the
record when the requester's enr_seq already matches. So when an ethlambda
release changes which entries it publishes, as adding tcp did, a peer holding
the previous record under the same seq keeps it: the new entries reach only
peers that meet this node for the first time.
A fixed local floor above INITIAL_ENR_SEQ does not close this. ethrex re-signs
the record at seq + 1 whenever discv5's IP voting moves the advertised
address, so a node behind NAT may already be serving a seq above any constant
the code could pick, which is exactly the case the floor was meant to cover.
Closing it needs a seq that grows without bound across restarts: a persisted
counter bumped on every content change, or one derived from the wall clock at
startup, which is what several beacon clients do.
The spec's fork_digest is derived from genesis, so it separates one chain from
another. Lean's is the hardcoded cross-client dummy 0x12345678, and lean
defines no fork schedule, so every ENRForkID field it publishes is a constant.
The eth2 check therefore separates lean from non-lean but not one lean
devnet from another: two devnets running this code will peer with each other.
Closing that gap requires lean adopting a genesis-derived fork digest, which is
a cross-client change to gossip topic names.
This is a lean limitation only. ethlambda beacon derives a real digest from
mainnet's fork schedule and genesis_validators_root, so its eth2 check
separates mainnet from every other beacon network; see
beacon_wire.md.
ethrex paces its discv5 iterative lookups by how full its own peer table is,
easing from one lookup every 500ms at startup to one every 10s once the table
reaches its target. That table only counts peers registered through
NewConnectedPeer, which carries an RLPx connection; ethlambda connects over
libp2p and registers nothing, so the count is permanently zero and the pacing
never eases off the startup rate. A lean node therefore keeps looking up every
500ms rather than settling at 10s, roughly 20x the intended steady-state
FindNode traffic, for the life of the process.
--discovery.target-peers deliberately does not feed that computation, since a
target of 0 would make it divide by zero and re-fire the lookup timer with no
delay at all. Closing the gap properly means ethrex learning about non-RLPx
connections, which is an upstream change.
The spec's attnets is Bitvector[ATTESTATION_SUBNET_COUNT], a constant every
conformant client shares, which is what makes an undelimited bitfield decodable.
ethlambda derives the width from attestation_committee_count, which is runtime
configuration, so two nodes can legitimately exchange bitfields of different
lengths. The bit-packing convention is identical to the spec's; only the width
is negotiable. Readers tolerate a foreign length by treating bits past the end
as unset, and a peer's advertised subnets are clamped to the local committee
count before they influence anything.