Skip to content

feat(discovery): republish the ENR's eth2 entry at fork boundaries - #735

Draft
MegaRedHand wants to merge 3 commits into
feat/discv5-predicate-queriesfrom
feat/discv5-enr-edit
Draft

MegaRedHand wants to merge 3 commits into
feat/discv5-predicate-queriesfrom
feat/discv5-enr-edit

Conversation

@MegaRedHand

@MegaRedHand MegaRedHand commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

Draft, stacked on #732. Pins ethrex to 80308d0d, the head of ethrex's feat/discv5-lock-free-local-enr. That branch is lambdaclass/ethrex#7488, one commit stacked on feat/discv5-predicate-queries (#732's pin). Not mergeable until the ethrex side is in a release and the pin goes back to a tag.

Motivation

#732 made /eth/v1/node/identity read the ENR discv5 serves, but the other direction was impossible: ethrex served a private copy of the record and gave a consumer no way to edit it. So the served eth2 entry kept the startup digest across every fork-digest boundary, and peers discovering a long-running node after one read the stale digest and rejected it.

What this does

1. ethrex pin bump

ethrex's new commit makes SharedLocalNode the only copy of the served record: a lock-free ArcSwap slot that discovery reads on every PING/PONG/NODES/handshake, and that every writer updates by compare-and-swap.

ethrex API Behavior
SharedLocalNode::load() Snapshot, no lock
SharedLocalNode::edit_record(&signer, FnMut(&mut NodeRecordPairs)) NodeRecord::edit (one seq bump, re-signed), swapped in by CAS; on a conflict (e.g. IP voting got in first) the edit is re-applied on the newer record
DiscoveryServer::spawn(local_node, signer, ...) Takes the slot in place of the node, record and mirror

arc-swap enters the lockfile at 1.9.2: 1.9.3 is inside the 14-day cooldown until Nov 1. make cooldown-check passes.

2. Republishing eth2

transition::apply(epoch)
  ├─ filter.set_fork_id(fork_id, held digests)        (as before)
  └─ local_enr_writer.publish_fork_id(&fork_id)        (new)
       ├─ served eth2 == fork_id ? ── yes ──► nothing (no seq bump)
       └─ no ──► edit_record: set eth2, re-sign, seq + 1
                   └─► discv5 advertises the new seq; peers fetch the record
Piece Choice
When Every apply call, not only on a switch. The compare makes it a no-op between boundaries, and the served entry always matches the schedule
No-op check Outside the edit, since every edit bumps seq and a bumped seq sends every peer back for an identical record
Key ownership LocalEnrWriter (crate-private) holds the slot and the node key. LocalEnr (read-only) is what goes to the RPC server, so the key does not
Retry safety The edit only replaces eth2 from bytes computed once, so re-running it on a newer record gives the same result

update_served_enr (the warn-only stub) is gone.

Testing

  • make lint, cargo check --workspace --all-targets --locked, make cooldown-check
  • cargo test --workspace --profile release-fast --lib --bins: all pass. leanSpec spec suites not run.
  • New tests:
    • a_new_fork_id_is_what_discovery_serves_next: the edited record carries the new eth2, seq + 1, a valid signature, unchanged attnets/quic/ip/udp, and a peer on the new digest admits it
    • republishing_the_served_fork_id_leaves_the_record_alone: same entry, byte-identical record
    • a_crossing_moves_the_served_eth2_entry_once: through transition::apply, one edit at the boundary, none on a repeat call
  • Not covered here: a remote peer fetching the edited record. ethrex's a_consumer_edit_is_what_peers_fetch covers that with two real discv5 nodes.

Follow-ups

  • Return the pin to an ethrex release tag.
  • syncnets can go in the ENR now (same writer); nothing writes it yet. beacon_wire.md and spec_deviations.md now say so.

The served ENR kept its startup digest across every fork-digest
boundary, because ethrex gave a consumer no way to edit the record it
serves. Peers discovering a long-running node after a boundary read the
stale digest and rejected it.

ethrex's feat/discv5-lock-free-local-enr makes the SharedLocalNode slot
the only copy of the served record: an ArcSwap that discovery reads on
every packet and that every writer updates by compare-and-swap. It also
adds edit_record, which re-signs with one seq bump and re-applies the
edit if IP voting swapped a record in first. The pin moves there, and
arc-swap enters the lockfile at 1.9.2, since 1.9.3 is still inside the
publish-age cooldown.

LocalEnrWriter holds the slot and the node key, and transition::apply
offers it the schedule's ENRForkID on every call. It edits only when the
served entry differs, since every edit bumps seq and a bumped seq sends
each peer back for the whole record. It is a separate, crate-private
type so the key does not ride along with LocalEnr into the RPC server.
LocalEnr itself now reads the slot without a lock.
The gap the previous commit closes was documented in five places. Two of
them used it to explain why the ENR carries no syncnets entry; those now
say the record can be edited and nothing writes the set to it yet.
ethrex's update_local_ip sets ip or ip6 and clears the other; it never
touches the udp or tcp ports, which the new table claimed it did.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant