Skip to content

refactor(l1): build the local ENR outside the discovery server - #7147

Merged
ilitteri merged 2 commits into
feat/discovery-peer-filterfrom
refactor/build-local-enr-outside-discovery
Aug 19, 2026
Merged

ilitteri merged 2 commits into
feat/discovery-peer-filterfrom
refactor/build-local-enr-outside-discovery

Conversation

@MegaRedHand

@MegaRedHand MegaRedHand commented Aug 13, 2026 •

Copy link
Copy Markdown
Collaborator

Motivation

DiscoveryServer::spawn derived the record it advertises from local_node,
then stamped our own fork id onto it. Neither is discovery's decision to make.
What a node publishes about itself belongs to the caller: which entries the
record carries beyond the addresses, and what seq it resumes from. Deriving it
inside spawn left no way to say either.

It also forced a panic on a failure the caller reports as an error, via two
expects on the signing path.

Description

spawn takes a NodeRecord instead of building one, and the construction moves
to start_network, which already holds the node, the signer and the store.

The resulting record is byte-identical to what spawn produced: from_node at
INITIAL_ENR_SEQ followed by set_fork_id, which bumps seq once. No entry,
sequence number or signature changes.

That was the last reader of the Store spawn took. #7145 moved the fork-id
screening behind PeerFilter and removed the DiscoveryServer::store field it
fed; with the ENR built elsewhere, discovery no longer touches our chain at all,
so the parameter goes too. Neither change could drop it alone, which is why this
sits on top of #7145 rather than on main.

The two expects become a NetworkError. init_network ends in
.expect("Network starts"), so an unsignable record still aborts startup rather
than leaving a node running without discovery; it now does so from one place and
with the underlying NodeError attached. A chain that cannot supply a fork id
still leaves the entry out and starts, since the record is usable for discovery
and a peer that requires eth simply passes us over until we republish.

What this unblocks

cmd/ethrex already builds a local record, in get_local_node_record. That one
resumes from a seq persisted in the node config file (or a unix timestamp on
first run), and is the record admin_nodeInfo reports and that shutdown writes
back. Discovery never saw it, and gossiped its own starting from
INITIAL_ENR_SEQ, so the ENR peers receive and the ENR the node reports have
different sequence numbers, and only the gossiped one carries eth.

Restarting therefore re-announces at seq 2 no matter how high the previous
run climbed, and a peer holding a cached higher seq treats the new record as
stale. Handing discovery the record that already exists fixes that, but it is a
behaviour change and wants its own PR; this one only makes it expressible.

Tests

No new ones: the record is built from the same two calls in the same order, so
there is no new behaviour to pin. Existing p2p unit and integration tests pass.

Checklist

  • Updated STORE_SCHEMA_VERSION (crates/storage/lib.rs) if the PR includes breaking changes to the Store requiring a re-sync.

@MegaRedHand
MegaRedHand requested a review from a team as a code owner August 13, 2026 16:54
Copilot AI lite review requested due to automatic review settings August 13, 2026 16:54

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Your trial has ended. Reactivate Greptile to resume code reviews.

@github-actions

github-actions Bot commented Aug 13, 2026 •

Copy link
Copy Markdown

⚠️ Known Issues — intentionally skipped tests

Source: docs/known_issues.md

rpc-compat log-bearing cases excluded

Where: KNOWN_EXCLUDED_TESTS in .github/scripts/check-hive-results.sh counts out
eight hive rpc-compat cases — the four eth_getLogs cases, eth_getBlockReceipts/get-block-receipts-latest,
and three eth_getTransactionReceipt cases. They are exactly the cases whose recorded
response contains at least one log object; every case with an empty log array still runs.
Note this leaves eth_getLogs with no rpc-compat coverage at all, since all four of its
cases are in the set.

Why: ethrex populates blockTimestamp on log objects, as geth, besu, nethermind, reth
and erigon all do. hive's rpc-compat compares responses byte-exactly (jsondiff.FullMatch;
the lenient checkJSONStructure path applies only to cases upstream marks speconly), and
the corpus is pinned to execution-apis d08382ae (2025-02-10), whose recordings predate the
field — it entered the schema in execution-apis#639 and the fixtures in #846 (2026-07-22).
So the extra key cannot match, and this is a property of the pin rather than of the response.

The pin cannot move, and this is not temporary. The pin sits one commit before
execution-apis#627, which moved the test chain to a pre-merge genesis: the current corpus has
~36 proof-of-work blocks before its terminal total difficulty. ethrex does not support
pre-merge chains and will not, so importing that chain.rlp fails at block 1 —
validate_block_header has no pre-London base-fee path. Every revision carrying
blockTimestamp in its fixtures also carries that chain, so there is no revision that
satisfies both. Nor can the corpus be patched locally: rpc-compat's Dockerfile clones
ethereum/execution-apis by hard-coded URL, so the branch buildarg cannot point at a fork.

Coverage: the field itself is pinned by
block_timestamp_is_on_the_log_and_not_on_the_receipt in
crates/networking/rpc/types/receipt.rs, which asserts it is present on each log and absent
from the receipt level.

Removal: delete the entries if ethrex ever gains pre-merge chain import, or if upstream
marks these cases speconly so they are type-checked instead of compared byte-for-byte.


The stateless schema id does not identify the encoding

Where: STATELESS_INPUT_SCHEMA_ID in crates/common/types/stateless_ssz.rs.

Upstream keeps the stateless input schema id at 0x1501
(fork_index 0x15 << 8 | revision 0x01) across incompatible body changes. Three
encodings have now shipped under it: tests-zkevm@v0.6.2, then #3248 + #3278,
then #3356, which moved state, codes and public_keys from SszList to
ProgressiveList. ethrex speaks the last one.

The consequence is that the 2-byte prefix cannot be used to detect a stale or
mismatched bundle. A wrong-dialect input is accepted by the id check and then
fails later — in SSZ decode, or on a root that does not match — rather than being
rejected up front for what it is. only_amsterdam_schema_id_decodes therefore
proves less than its name suggests.

Worth raising upstream: a revision field that does not move across a body change
provides no version negotiation at all.


ZisK guest program hash changes with the unsync_cell gate

Where: crates/common/types/block.rs, transaction.rs.

The gate on the single-threaded unsync_cell::OnceCell moved from
all(feature = "eip-8025", target_arch = "riscv64") to
all(feature = "zisk", target_arch = "riscv64") when the eip-8025 feature was removed.

The guest ELFs were previously built --features "<zkvm>-build-elf,ci", which never enabled
eip-8025, so they compiled the atomic once_cell variant. bin/zisk/Cargo.toml does enable
ethrex-common/zisk, so the ZisK guest now compiles the unsafe impl Sync cell instead.
That changes the ELF bytes and therefore the program hash and verification key.

This is intended (the guest is single-threaded, so the unsync cell is sound and cheaper), but it
is a VK change rather than a no-op refactor, and the diffstat presents it as a file rename
(eip8025_cell.rs → unsync_cell.rs). Anyone pinning a ZisK VK across this change must
re-register it. The stateless-validator crate now forwards ethrex-common/zisk from its own
zisk feature so the two ZisK guests do not disagree on the cell type.


Release signing key is an unprotected repository secret

Where: .github/workflows/tag_release.yaml.

MINISIGN_SECRET_KEY is a plain repository secret. There is no environment: on
finalize-release or dry-run-release-assets, and gh api repos/lambdaclass/ethrex/rulesets
shows only branch-targeted rulesets, so the github.ref_type == 'tag' condition is a workflow
check rather than an enforced boundary: anyone who can push a tag can reach the signing key.

This is a repository-settings change, not a code change, so it is recorded here rather than
fixed in the tree. Recommended:

  1. Move MINISIGN_SECRET_KEY / MINISIGN_PASSWORD into a GitHub Environment with required
    reviewers, and add environment: to the two jobs that sign.
  2. Add a ruleset targeting refs/tags/v* restricting who may create release tags.

Until then, the compromise of that key is silent and durable: signatures would still verify
against the committed .github/minisign.pub.

@github-actions github-actions Bot added the L1 Ethereum client label Aug 13, 2026
@github-actions

Copy link
Copy Markdown

🤖 Kimi Code Review

The PR improves separation of concerns and error handling in the discovery service. Here is the review:

crates/networking/p2p/network.rs

  1. Lines 120-121: The new error variant LocalNodeRecordError correctly uses #[from] NodeError for ergonomic error propagation. This eliminates the previous expect() panics in server.rs.

  2. Lines 129-141: The build_local_node_record function properly handles the case where get_fork_id() fails by omitting the entry rather than failing startup. This is correct per EIP-2124 and maintains liveness for chains without fork ID support.

  3. Line 153: The local_node_record is constructed before spawning the discovery server, ensuring that record creation failures are caught early with proper error reporting rather than panicking inside the actor.

crates/networking/p2p/discovery/server.rs

  1. Lines 109-116: The doc comment accurately explains why the NodeRecord is supplied by the caller rather than derived internally. This improves testability by allowing injection of arbitrary ENRs.

  2. Line 117: The #[expect(clippy::too_many_arguments)] attribute is justified here. While the function has many parameters, the refactoring actually reduces coupling by removing the storage dependency from record construction logic.

  3. Lines 127-136: Removing the expect() calls and the storage parameter from DiscoveryServer::spawn is a significant robustness improvement. The previous expect("Failed to create local node record") and expect("Failed to set fork_id on local node record") could crash the node on database errors or signing failures; these now propagate as Result errors.

General observations

  • The change maintains consensus safety: the ENR signing uses the same signer key and INITIAL_ENR_SEQ, and the fork ID is still retrieved from storage and included when available.
  • No memory safety issues introduced; the NodeRecord is moved (not cloned) into the spawn function, which is efficient.
  • The API change to DiscoveryServer::spawn is a breaking change for any other callers, but the diff correctly updates the call site in start_network.

Verdict: This is a clean refactor that improves error handling and separation of concerns. No changes required.


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

@github-actions

Copy link
Copy Markdown

🤖 Codex Code Review

No findings.

The diff looks sound. Moving ENR construction out of discovery and into start_network preserves the existing fork-id behavior while removing the expect-panic path and surfacing signing/build failures as a regular startup error instead (crates/networking/p2p/network.rs, crates/networking/p2p/network.rs, crates/networking/p2p/discovery/server.rs). That is a net improvement for correctness and operability.

Residual risk: there’s no added test coverage for the new startup error path or for preserving the startup ENR contents, so this is relying on existing discovery tests rather than a targeted regression test.


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

@github-actions

Copy link
Copy Markdown

🤖 Claude Code Review

I've reviewed the diff, the surrounding code in network.rs, discovery/server.rs, types.rs (NodeRecord::from_node, set_fork_id, NodeError), and the call sites (init_network in cmd/ethrex/initializers.rs). Here's the review.

Review: PR 7147 — build the local ENR outside the discovery server

This is a clean, mechanical refactor. The record-construction logic is moved verbatim from DiscoveryServer::spawn into a new build_local_node_record helper in network.rs, and the two .expect() panics become a proper NodeError -> NetworkError conversion. I traced the resulting control flow through to cmd/ethrex/initializers.rs:390 (.expect("Network starts")) and confirmed the byte-identical-output claim holds: same from_node(..., INITIAL_ENR_SEQ, ...) call followed by the same conditional set_fork_id.

Correctness

  • The construction order and semantics are preserved exactly: from_node at INITIAL_ENR_SEQ, then set_fork_id only if let Ok(fork_id) = ...get_fork_id(), silently skipping on error just as before. No behavior change here.
  • Panic-to-Result conversion is sound. A failure in from_node or set_fork_id now bubbles as NetworkError::LocalNodeRecordError, through start_network, and is still terminal at the single existing call site (init_network's .expect("Network starts")), so the net effect for cmd/ethrex is unchanged (still aborts startup) — just centralized with the underlying NodeError attached instead of a bare panic message. Good improvement for anyone consuming ethrex_p2p::start_network directly (e.g. embedding it in a library context) since they now get a Result instead of an unconditional panic.
  • Only one call site for DiscoveryServer::spawn exists in the codebase, so no other caller needed updating.

Minor/nit observations (non-blocking)

  • network.rs:135 — NodeRecord::from_node(&context.local_node, INITIAL_ENR_SEQ, &context.signer)? now propagates via ? inside an async fn returning Result<NodeRecord, NodeError>; this is fine, but note the #[from] on NetworkError::LocalNodeRecordError means any future NodeError-returning call added to this function will silently be attributed to "Failed to build the local node record", even if unrelated. Low risk given the function's narrow scope today.
  • .expect("Network starts") at cmd/ethrex/initializers.rs:390 uses Debug formatting on panic (not the thiserror Display message), so the nicer "Failed to build the local node record: {0}" wording won't actually surface in the panic output — the underlying NodeError will still be visible via Debug, just not the friendlier custom message chain. This is a pre-existing pattern, not something introduced by this PR, but it slightly undercuts the stated benefit of "reporting... with the underlying NodeError attached" in a human-readable way. Not worth blocking on.
  • The new #[expect(clippy::too_many_arguments)] on DiscoveryServer::spawn (server.rs:117) is a reasonable, honest acknowledgment given the parameter count grew to 8; consistent with the existing #[allow(clippy::too_many_arguments)] on P2PContext::new.
  • Doc comments on spawn and build_local_node_record are good — they explain the why (ownership of what a node announces belongs to the caller) rather than just restating the code, consistent with the PR's stated goal.

Security / consensus

No consensus-relevant logic here — no opcode, gas, trie, or RLP changes. The ENR content and signing logic are untouched; only which layer constructs the record changed. No new attack surface introduced.

Overall

The refactor does exactly what it claims: moves ENR construction to the caller and turns two panics into propagated errors, with no behavior change in the current single call path. Nothing blocking. The PR is honest about being a narrow prerequisite step (not fixing the seq-mismatch bug described in the motivation) and that scoping looks right.


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

@github-actions

github-actions Bot commented Aug 13, 2026 •

Copy link
Copy Markdown

Lines of code report

Total lines added: 10
Total lines removed: 8
Total lines changed: 18

Detailed view
+--------------------------------------------------+-------+------+
| File                                             | Lines | Diff |
+--------------------------------------------------+-------+------+
| ethrex/crates/networking/p2p/discovery/server.rs | 399   | -8   |
+--------------------------------------------------+-------+------+
| ethrex/crates/networking/p2p/network.rs          | 737   | +10  |
+--------------------------------------------------+-------+------+

MegaRedHand added a commit that referenced this pull request Aug 13, 2026
Integration commit, with no PR of its own: neither #7145 nor #7147 can make this
change alone, because each removes one of the two things `storage` was for.

#7145 moved the discv4 fork-id check behind `PeerFilter`, which left the
`DiscoveryServer::store` field unused and removed it. #7147 moved the local ENR
out of `spawn`, which was the last reader of the parameter. Together the
parameter is dead, and `spawn` drops back under the argument-count lint, so the
`#[expect]` #7147 needed goes with it.

`DiscoveryServerError::Store` stays. Nothing in `discovery/` produced a
`StoreError` even before either PR, so that variant is pre-existing dead code
rather than something these changes stranded.
@MegaRedHand
MegaRedHand force-pushed the refactor/build-local-enr-outside-discovery branch from b44e5cc to cf74eff Compare August 13, 2026 16:59
@MegaRedHand
MegaRedHand changed the base branch from main to feat/discovery-peer-filter August 13, 2026 16:59
@MegaRedHand
MegaRedHand force-pushed the refactor/build-local-enr-outside-discovery branch from cf74eff to 06cd48b Compare August 13, 2026 19:56
Comment thread crates/networking/p2p/discovery/server.rs Outdated
Comment on lines 110 to +111
local_node: Node,
local_node_record: NodeRecord,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

These should be deduped in the future

@MegaRedHand
MegaRedHand force-pushed the refactor/build-local-enr-outside-discovery branch from abb7120 to 55c3798 Compare August 14, 2026 18:42
@MegaRedHand
MegaRedHand force-pushed the refactor/build-local-enr-outside-discovery branch from 55c3798 to 802fc81 Compare August 19, 2026 15:35
`DiscoveryServer::spawn` derived the record it advertises from `local_node`,
then stamped our own fork id onto it. Neither is discovery's decision to make.
What a node publishes about itself is the caller's: which entries the record
carries beyond the addresses, and what `seq` it resumes from. Deriving it inside
`spawn` left no way to say either, and forced a `panic` on a signing failure
that the caller returns as an error.

Take a `NodeRecord` instead of building one, and move the construction to
`start_network`, which already holds the node, the signer and the store. The
resulting record is byte-identical: `from_node` at `INITIAL_ENR_SEQ` followed by
`set_fork_id`, which bumps `seq` once, exactly as before.

That was the last reader of the `Store` `spawn` took. With the fork-id screening
moved behind `PeerFilter` in the previous commit, discovery no longer touches our
chain at all, so the parameter goes.

The two `expect`s become a `NetworkError`. `init_network` ends in
`.expect("Network starts")`, so an unsignable record still aborts startup rather
than leaving a node running without discovery; it now does so from one place and
with the underlying `NodeError` attached. A chain that cannot supply a fork id
still leaves the entry out and starts, since that record is usable and a peer
requiring `eth` simply passes us over.

This is a prerequisite for handing discovery the record `get_local_node_record`
already builds at startup. That one carries a `seq` persisted across restarts
and is the record `admin_nodeInfo` reports, while discovery gossips a separate
one starting from 1, so the two disagree today.
@MegaRedHand
MegaRedHand force-pushed the refactor/build-local-enr-outside-discovery branch from 802fc81 to 11cc8bb Compare August 19, 2026 16:12
@github-project-automation github-project-automation Bot moved this to In Review in ethrex_l1 Aug 19, 2026
@ilitteri
ilitteri added this pull request to the merge queue Aug 19, 2026
Merged via the queue into main with commit 1cf93cb Aug 19, 2026
54 checks passed
@ilitteri
ilitteri deleted the refactor/build-local-enr-outside-discovery branch August 19, 2026 21:07
@github-project-automation github-project-automation Bot moved this from In Review to Done in ethrex_l1 Aug 19, 2026
ilitteri added a commit that referenced this pull request Aug 27, 2026
Resolutions, all in main's favor where main superseded the branch:

- DiscoveryConfig: main dropped initial_lookup_interval (#7163); the
  branch's nat_extip_set field is added to main's struct and every
  construction site sets all three fields explicitly, so the branch's
  Default impl is gone too.
- update_local_ip: main's edit-based version (#7143) wins. It preserves
  every ENR entry, which strictly subsumes the branch's rebuild that
  carried over only the fork id.
- Discv5State keeps main's shape minus the IP-voting predictor this
  branch extracts into discovery/ip_predictor.rs. The five in-file
  voting tests go with the mechanism; their coverage lives on in
  test/tests/p2p/discovery/ip_predictor_tests.rs, and main's three
  update_local_ip ENR-preservation tests are kept.
- DiscoveryServer drops the branch's store field: main moved ENR
  building out of discovery (#7147), so discovery no longer needs
  storage; apply_predicted_ip goes through main's update_local_ip.
- start_network/DiscoveryServer::spawn keep main's signatures plus the
  branch's shared_local_node parameter.
- The native-rollup RPC path (#6418, newer than this branch) is ported
  to the SharedLocalNode-taking init_rpc_api; no discovery runs there,
  so the shared identity is constant after construction.
AnkushinDaniil pushed a commit to AnkushinDaniil/ethrex that referenced this pull request Aug 30, 2026
…ambdaclass#6811)

Follow-up to lambdaclass#6713, which deferred geth-style devp2p endpoint
prediction. Closes lambdaclass#6714.

On discv4-only devnets the majority-vote IP predictor never reached
quorum because it only ran on discv5, so the node kept advertising its
private/bridge IP and got 0 inbound peers.

Extract `IpPredictor` out of `Discv5State` into
`discovery/ip_predictor.rs`, owned by `DiscoveryServer` and fed by both
discv4 (`PONG.to`, the peer's observed source of our PING) and discv5
PONGs. Applied via the existing `update_local_ip`, which bumps ENR seq
and re-signs. `--nat.extip` sets `ip_override_locked`, preventing any
predictor update; address-family mismatches are skipped.

Votes discard only never-routable addresses
(loopback/link-local/unspecified); RFC1918/unique-local private IPs are
kept as candidates. On finalize the predictor prefers a public winner
and falls back to a private one only if no public IP reaches quorum. A
NAT/SNAT node converges on its public IP (peers observe the routable
source); a node on a flat private network (e.g. a kurtosis enclave,
where no public IP is ever observed) converges on its reachable private
IP instead of advertising nothing.

Tests from `discv5/server.rs` are relocated to `ip_predictor.rs`, plus
discv4 PONG feeding, flat-network private convergence, and
prefer-public-over-private.

## Post-restructure merge (2026-08-27)

Main restructured discovery underneath this PR; the merge resolves in
main's favor wherever main superseded it:

- **`update_local_ip`**: main's edit-based version (lambdaclass#7143) wins — it
preserves every ENR entry, strictly subsuming this branch's rebuild that
carried over only the fork id.
- **`DiscoveryServer` keeps no `store`**: main moved ENR building out of
discovery (lambdaclass#7147), so the field this branch added for fork-id
re-derivation is no longer needed; `apply_predicted_ip` goes through
main's `update_local_ip`.
- **`DiscoveryConfig`**: main dropped `initial_lookup_interval` (lambdaclass#7163);
only `nat_extip_set` is added, and all construction sites set every
field explicitly.
- **The five in-file `Discv5State` voting tests** go with the mechanism
they tested; their coverage lives on in
`test/tests/p2p/discovery/ip_predictor_tests.rs` (8 tests), and main's
three `update_local_ip` ENR-preservation tests are kept.
- **The native-rollup RPC path** (lambdaclass#6418, newer than this branch) is
ported to the `SharedLocalNode`-taking `init_rpc_api`.

Post-merge verification: workspace clippy and `--features l2` clippy
clean, fmt clean, 105 `ethrex-p2p` unit + 151 p2p integration + 57 rpc
integration tests pass, and the vote wiring is confirmed intact (discv4
PONG → `record_ip_vote` → `apply_predicted_ip("discv4")`, discv5
likewise, prune tick → `check_timeout` →
`apply_predicted_ip("timeout")`).

## Test plan

- `cargo test -p ethrex-test ip_predictor` passes
- discv4-only devnet (discv5 disabled): node converges to the correct
external IP after 3 PONGs from distinct peers
- `--nat.extip` still hard-locks the advertised address

---------

Co-authored-by: ilitteri <ivanlitteri@hotmail.com>
Co-authored-by: Ivan Litteri <67517699+ilitteri@users.noreply.github.com>
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.

3 participants