Skip to content

feat(l1): add EIP-1459 DNS discovery for bootstrap resilience - #7077

Open
ilitteri wants to merge 2 commits into
mainfrom
fix/dns-discovery-bootstrap
Open

feat(l1): add EIP-1459 DNS discovery for bootstrap resilience#7077
ilitteri wants to merge 2 commits into
mainfrom
fix/dns-discovery-bootstrap

Conversation

@ilitteri

Copy link
Copy Markdown
Collaborator

Motivation

Discovery bootstrapping relies entirely on the bootnodes compiled into the binary: 4 for mainnet, 5 for sepolia, 16 for hoodi. When those hosts stop answering, a node whose peer table is empty has no path into the DHT at all — it re-pings the same dead addresses forever and sits at zero peers.

That is not hypothetical. All five of sepolia's EF execution-layer bootnodes stopped answering UDP discovery on 2026-07-30 and have not come back, and every fresh sepolia node has been stuck at zero peers since, unable to start snap sync even though the network itself is healthy and the consensus client peers normally. Reproduced on two unrelated hosts and networks: ~9.9k RLPx handshake timeouts and zero inbound discv4 messages from those five addresses, while a hoodi node on the same box peered normally.

Every other client survives this because it ships a DNS node list as a second, independent bootstrap source. ethrex was the only one without one. Note that mainnet is the more exposed network here, with one fewer bootnode than sepolia.

A bundled list of discv5 ENRs is not an equivalent fallback, even though it looks like one. go-ethereum's V5Bootnodes and Nethermind's discv5-bootnodes.json are the same 15 records in both clients, and all 15 are consensus-layer nodes: an eth2/attnets key, no eth key, and a TCP port of either 0 or a beacon port. None of them can ever become an execution-layer peer. A DNS node list is the only bootstrap source that is both independent of the bootnodes and actually made of execution-layer nodes. This is recorded in the module docs so the option isn't mistaken for a fix later.

Description

Implements EIP-1459 DNS-based node discovery in a new crates/networking/p2p/discovery/dns.rs.

A node list is a DNS-hosted Merkle tree of ENRs described by an enrtree://<base32-pubkey>@<domain> URL, so it can be refreshed without shipping a new binary:

  • The root TXT record is signature-verified against the tree's public key, so a hijacked resolver cannot inject nodes. The signed preimage is the record without its sig= field; a test pins this against the real sepolia root so a change in how it is constructed fails loudly.
  • Every interior record is addressed by base32(keccak256(record)[..16]), and that label is verified on fetch, which makes the whole tree tamper-evident under the one root signature.
  • Each ENR's own signature is verified before its node is accepted.
  • Both the node count and the DNS request count per sync are bounded, so a cyclic or adversarial tree cannot walk us indefinitely. Links to other trees are followed one level deep.

The walk runs as a background task rather than gating startup, since a slow resolver must never delay the node coming up. Nodes are added to the peer table exactly the way bootnodes are, so they get pinged, validated and dialed through the existing paths. A sync that yields nothing retries after a minute and backs off toward the 30-minute steady-state interval — when the bootnodes are unreachable this is the only way in, so waiting a full interval over a momentary resolver failure would leave the node peerless for that whole time.

Trees for mainnet, sepolia and hoodi are enabled by default. --discovery.dns / ETHREX_DISCOVERY_DNS overrides them; passing an empty string disables the feature.

Deliberately uses new_contacts rather than new_contact_records: the record path runs evaluate_fork_id, which returns Some(false) for an ENR carrying no eth key, and is_fork_id_valid == Some(false) marks a contact for pruning — routing DNS-discovered ENRs through it would silently discard them.

Adds one dependency, hickory-resolver (default features off, system-config + tokio), for TXT lookups. It sits behind a TxtResolver trait, so the tree walk is tested without DNS and the backend is a one-line swap.

Testing

16 unit tests covering base32 round-tripping and label lengths, enrtree URL parsing, root parsing and signature verification against the real sepolia root, rejection of a root signed by another key and of a tampered root, label-substitution detection, real ENR leaf parsing plus signature verification, an end-to-end walk over a locally-signed tree, request-budget termination on a cyclic tree, one unreachable tree not blocking the others, and the retry backoff schedule.

Validated A/B on sepolia while the bootnodes were still unreachable:

contacts from DNS peers @30s peers @35min
DNS discovery on 250 3 5
off (--discovery.dns "") 0 0

Checklist

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

Not applicable — no storage format change, and no resync is required.


Stopgap that unblocks CI while this is reviewed: #7076. That PR should be reverted once this lands.

ilitteri added 2 commits July 31, 2026 11:23
…nto the

network when the hardcoded bootnodes stop answering.

Discovery bootstrapping relied entirely on the handful of bootnodes compiled
into the binary. When those hosts go silent, a node whose peer table is empty
has no path into the DHT at all: it re-pings the same dead addresses forever
and sits at zero peers. That is not hypothetical -- all five of the sepolia
execution-layer bootnodes stopped answering UDP discovery, and every fresh
sepolia node has been stuck at zero peers since, unable to start snap sync
even though the network itself is healthy and the consensus client peers fine.

A DNS node list is the independent bootstrap path the other clients already
use for this: a DNS-hosted Merkle tree of ENRs, published under a key whose
signature covers the root, so the list can be refreshed without shipping a new
binary and a hijacked resolver cannot inject nodes. Interior records are
addressed by base32(keccak256(record)[..16]), which is verified on every fetch,
making the whole tree tamper-evident under that one signature. ENRs carry their
own signatures and are verified too.

The walk runs as a background task rather than gating startup, since DNS is a
supplement and a slow resolver must not delay the node coming up. Nodes are
added to the peer table exactly the way bootnodes are, so they get pinged,
validated and dialed through the existing paths, and re-syncing on a slow timer
keeps a supply of contacts available for a long-running node. Both the node
count and the DNS request count per sync are bounded, and a cyclic or
adversarial tree cannot walk us indefinitely.

Trees for mainnet, sepolia and hoodi are enabled by default.
--discovery.dns overrides them; passing an empty string disables the feature.
…y a bundled

discv5 ENR list is not an equivalent fallback.

Two things came out of investigating why the bootnode outage bit us and not the
other clients.

First, DNS is not merely a supplement. When the bootnodes are unreachable it is
the only way into the network, so sleeping the full steady-state interval after a
sync that produced nothing would leave the node peerless for half an hour over
what may have been a momentary resolver failure. Empty syncs now retry after a
minute and double up to the steady-state interval, resetting on the first sync
that yields nodes.

Second, the obvious-looking alternative is a dead end and deserves to be written
down before someone implements it. Both go-ethereum and Nethermind bundle a
default list of discv5 ENRs, and at a glance that looks like the second
bootstrap path we were missing. Decoding those lists shows they are the same 15
records in both clients and all 15 are consensus-layer nodes: an eth2/attnets
key, no eth key, and a TCP port of either 0 or a beacon port. None of them can
ever become an execution-layer peer, so copying the list would add contacts that
can only ever fail to dial. A DNS node list is the only bootstrap source that is
both independent of the bootnodes and actually made of execution-layer nodes.
@ilitteri
ilitteri requested a review from a team as a code owner July 31, 2026 15:15
Copilot AI review requested due to automatic review settings July 31, 2026 15:15
@github-actions github-actions Bot added the L1 Ethereum client label Jul 31, 2026
@github-actions

Copy link
Copy Markdown

⚠️ Known Issues — intentionally skipped tests

Source: docs/known_issues.md

Stateless (zkEVM) Amsterdam+ EF tests skipped

Where: tooling/ef_tests/blockchain/test_runner.rsparse_and_execute skips
fixtures with network >= Fork::Amsterdam when running with a stateless backend.
Affects make test-stateless (the vectors_zkevm/ run); make test-levm is
unaffected.

Why: The stateless run uses the tests-zkevm@v0.5.0 bundle, filled against
glamsterdam-devnet v6.1.0, which predeploys the EIP-8282 builder deposit/exit
contracts at the OLD addresses (0x0000884d…d9008282 / 0x000014574a…0f008282).
This client uses the devnet-7 addresses (0x0000bff4…300d8282 /
0x000064d6…800e8282, matching the live tests-glamsterdam-devnet@v7.2.0 bundle
used by make test-levm). Every Amsterdam+ block runs the end-of-block EIP-8282
builder system call; with the new addresses absent from the v0.5.0 bundle, each
stateless Amsterdam+ block fails with
SystemContractCallFailed("System contract: 0x0000…8282 has no code after deployment").
The skip is by fork rather than by test name, since cross-fork directories such as
for_amsterdam/prague/... still execute at the Amsterdam fork.

Removal: Delete the skip_stateless_amsterdam branch in parse_and_execute
once a tests-zkevm bundle filled with the devnet-7 builder predeploy addresses is
released and .fixtures_url_zkevm is bumped to it.

@github-actions

Copy link
Copy Markdown

🤖 Kimi Code Review

This is a high-quality implementation of EIP-1459 DNS discovery. The code is well-structured, thoroughly tested, and handles security-critical aspects (signature verification, hash chain validation) correctly.

Minor Issues & Suggestions

1. Multiple TXT Record Handling

File: crates/networking/p2p/discovery/dns.rs
Line: ~583-595 (SystemResolver::lookup_txt)

The resolver picks the first TXT record it finds. If a domain has multiple TXT records (e.g., SPF, other metadata), it might pick the wrong one. Consider iterating through all answers to find one starting with the expected prefix (enrtree-root:v1, enrtree-branch:, or enr:).

// Suggested change:
let record = lookup
    .answers()
    .filter_map(|r| match &r.data {
        RData::TXT(txt) => Some(txt.txt_data.concat()),
        _ => None,
    })
    .find(|data| {
        // For roots, look for specific prefix; for others accept any
        name == link.domain || data.starts_with(ROOT_PREFIX) || 
        data.starts_with(BRANCH_PREFIX) || data.starts_with(ENR_PREFIX)
    })
    .ok_or_else(|| DnsDiscoveryError::NoRecord(name.clone()))?;

2. Base64 Decoding Error Handling

File: crates/networking/p2p/discovery/dns.rs
Line: 170 (RootEntry::parse)

ethrex_common::base64::decode is assumed to never fail. If it returns an empty vector or error, the code proceeds and fails later with a less clear error. Consider handling the error explicitly:

"sig" => {
    let decoded = ethrex_common::base64::decode(value.as_bytes())
        .map_err(|_| invalid("invalid base64 in sig"))?;
    signature = Some(decoded);
}

3. RNG in Async Context

File: crates/networking/p2p/discovery/dns.rs
Line: 300 (sync_tree)

Uses rand::thread_rng() directly in async code. While unlikely to block, consider using tokio::task::spawn_blocking for the shuffle if the tree is large, or ensure the RNG is non-blocking. Given the small size of children (typically few entries), this is acceptable but worth noting.

4. Missing Metrics

Consider adding Prometheus metrics for:

  • DNS sync duration
  • Number of nodes discovered per sync
  • DNS query failure rates
  • Signature verification failures

This helps operators diagnose why their nodes might not be finding peers.

5. Documentation Clarity

File: cmd/ethrex/cli.rs
Line: 402-403

The help text says "Defaults to the known network list; pass an empty string to disable." This is implemented correctly in get_dns_discovery_links, but clarify that this overrides (not appends to) the default list:

help = "Comma separated enrtree:// URLs for EIP-1459 DNS discovery. \
        If not set, uses the network's built-in list. \
        Pass an empty string to disable DNS discovery entirely.",

Security Verification

The implementation correctly handles:

  • Root signature verification using secp256k1 (lines 186-203)
  • Label hash verification ensuring tamper-evidence (line 337, label_matches_record)
  • ENR self-signatures (line 306)
  • Request budgeting preventing DoS via deep/cyclic trees (lines 239-240, 283-284)
  • Link depth limiting preventing infinite recursion across trees (line 272)

Correctness


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

@greptile-apps

greptile-apps Bot commented Jul 31, 2026

Copy link
Copy Markdown

Greptile Summary

Adds signed EIP-1459 DNS discovery as an asynchronous bootstrap source.

  • Introduces bounded DNS tree traversal with root, child-label, and ENR signature verification.
  • Adds mainnet, Sepolia, and Hoodi defaults plus CLI and environment overrides.
  • Feeds discovered nodes into the existing peer table and periodically refreshes them with retry backoff.
  • Adds Hickory Resolver for asynchronous TXT lookups.

Confidence Score: 4/5

The PR appears safe to merge, with a non-blocking gap in the test intended to protect request-budget enforcement.

The DNS discovery implementation authenticates roots, hash-addressed records, and ENRs while bounding traversal, but the new adversarial-tree test terminates on an empty branch and would not detect a broken request ceiling.

Files Needing Attention: crates/networking/p2p/discovery/dns.rs

Important Files Changed

Filename Overview
crates/networking/p2p/discovery/dns.rs Implements bounded, authenticated DNS tree traversal and periodic peer-table updates; its request-budget regression test does not actually exercise the budget.
crates/networking/p2p/network.rs Starts DNS discovery alongside enabled UDP discovery protocols and routes discovered nodes into the shared peer table.
cmd/ethrex/initializers.rs Selects CLI-provided or network-default enrtree URLs and safely ignores malformed entries.
crates/common/config/networks.rs Adds default EF DNS discovery trees for the three supported public networks.
cmd/ethrex/cli.rs Adds the DNS discovery CLI and environment override with an explicit empty-string disable path.
Cargo.toml Adds Hickory Resolver with only Tokio and system resolver configuration features enabled.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  Config[Network defaults or discovery.dns override] --> Task[Background DNS discovery task]
  Task --> Root[Resolve and verify signed tree root]
  Root --> Walk[Walk hash-addressed branches within request and node limits]
  Walk --> ENR[Decode and verify ENRs]
  ENR --> PT[PeerTable contacts]
  PT --> Disc[discv4 and discv5 validation]
  PT --> Dial[RLPx dial candidates]
  Dial --> Peers[Execution peers]
Loading
Prompt To Fix All With AI
### Issue 1
crates/networking/p2p/discovery/dns.rs:815-833
**Request-budget test ends naturally**

The stored record is an empty `enrtree-branch:` with no child labels, so this test ends after the initial lookups without exercising `max_requests`. It therefore continues passing when request-budget enforcement regresses, leaving the protection against adversarial high-fanout trees untested.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "Retry DNS discovery quickly after a sync..." | Re-trigger Greptile

Comment on lines +815 to +833
domain: domain.to_string(),
}],
);
assert!(
discovery.sync().await.is_empty(),
"a root signed by the wrong key must yield no nodes"
);
}

#[tokio::test]
async fn stops_at_request_budget_on_a_cyclic_tree() {
// A branch that lists its own label would loop forever without the
// visited set; the budget is the backstop if a tree fans out instead.
let secret = secp256k1::SecretKey::from_slice(&[3u8; 32]).expect("valid key");
let public_key = PublicKey::from_secret_key(secp256k1::SECP256K1, &secret);
let domain = "loop.test";

let mut dns = HashMap::new();
// Self-referential branch: label(record) is inside record's own child list.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Request-budget test ends naturally

The stored record is an empty enrtree-branch: with no child labels, so this test ends after the initial lookups without exercising max_requests. It therefore continues passing when request-budget enforcement regresses, leaving the protection against adversarial high-fanout trees untested.

Knowledge Base Used: Networking / P2P (devp2p)

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/networking/p2p/discovery/dns.rs
Line: 815-833

Comment:
**Request-budget test ends naturally**

The stored record is an empty `enrtree-branch:` with no child labels, so this test ends after the initial lookups without exercising `max_requests`. It therefore continues passing when request-budget enforcement regresses, leaving the protection against adversarial high-fanout trees untested.

**Knowledge Base Used:** [Networking / P2P (devp2p)](https://app.greptile.com/lambdaclass/-/custom-context/knowledge-base/lambdaclass/ethrex/-/docs/networking-p2p.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@github-actions

Copy link
Copy Markdown

Lines of code report

Total lines added: 753
Total lines removed: 0
Total lines changed: 753

Detailed view
+--------------------------------------------------+-------+------+
| File                                             | Lines | Diff |
+--------------------------------------------------+-------+------+
| ethrex/cmd/ethrex/cli.rs                         | 1333  | +11  |
+--------------------------------------------------+-------+------+
| ethrex/cmd/ethrex/initializers.rs                | 988   | +22  |
+--------------------------------------------------+-------+------+
| ethrex/crates/common/config/networks.rs          | 238   | +10  |
+--------------------------------------------------+-------+------+
| ethrex/crates/networking/p2p/discovery/dns.rs    | 691   | +691 |
+--------------------------------------------------+-------+------+
| ethrex/crates/networking/p2p/discovery/mod.rs    | 37    | +3   |
+--------------------------------------------------+-------+------+
| ethrex/crates/networking/p2p/discovery/server.rs | 412   | +1   |
+--------------------------------------------------+-------+------+
| ethrex/crates/networking/p2p/network.rs          | 742   | +15  |
+--------------------------------------------------+-------+------+

@github-actions

Copy link
Copy Markdown

🤖 Codex Code Review

  1. DNS discovery drops the ENR too early, so refreshed records never update existing contacts. In dns.rs the walker converts each leaf to Node and later submits it through new_contacts in dns.rs. But new_contacts only sets the protocol bit for an existing contact and does not refresh IP/ports or keep the record (peer_table.rs). That bypasses the existing ENR-aware update path, which does handle higher seq, endpoint changes, and fork-id evaluation (peer_table.rs). Result: if a DNS-published node rotates address or republishes a newer ENR, this code can pin the stale endpoint indefinitely. I’d feed NodeRecords into new_contact_records and only derive Node after the table has applied the ENR update logic.

  2. One bad tree can starve the rest of the configured trees despite the comment claiming isolation. sync() uses a single global requests budget for all roots (dns.rs, dns.rs), and sync_tree() spends from that shared counter on every TXT lookup (dns.rs, dns.rs). A pathological but valid first tree can burn DEFAULT_MAX_REQUESTS and prevent later configured trees from being visited at all. If “one broken tree must not take down the others” is the goal, the request cap needs to be per-tree or at least partitioned.

  3. The cycle-protection test does not actually create a cycle. In dns.rs, stops_at_request_budget_on_a_cyclic_tree stores enrtree-branch: under self_label; that parses as an empty branch, so the walk terminates immediately instead of exercising the visited-set or request-budget logic. As written, this test can pass even if cycle handling regresses. It should build a real self-reference or a two-node cycle.

No EVM/consensus/RLP concerns in this diff; the meaningful risk is in DNS discovery freshness and isolation. I couldn’t run cargo test here because the local rustup toolchain wants to write under a read-only home directory.


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

@github-actions

Copy link
Copy Markdown

🤖 Claude Code Review

Review: EIP-1459 DNS Discovery (PR 7077)

Overall

This is a well-engineered addition. The signature verification (root signature over the canonical e=/l=/seq= preimage, per-label hash-commitment, per-ENR signature) is implemented correctly and matches the reference implementations' security model. The test suite pins against a real captured sepolia root, which is exactly the right way to guard the "signed preimage" invariant called out in the PR description. Bounding both node count and per-sync request count, and reusing the existing bootnode contact path (new_contacts) rather than gating startup, are sound design choices. No unsafe code, no obvious panics on attacker-controlled input (base32 decode, RLP decode, and signature parsing all fail closed).

Findings below are ordered roughly by severity; none are blocking, but the first is worth addressing.

Findings

  1. SystemResolver::lookup_txt picks the first TXT record blindly, not by prefix (crates/networking/p2p/discovery/dns.rs:993-1005)
    lookup.answers().iter().find_map(...) takes whichever TXT resource record comes back first at that name and treats it as the enrtree record. Reference implementations (go-ethereum's dnsdisc) instead look for the specific enrtree-root:/enrtree-branch:/enr:/enrtree:// prefixes among the returned TXT strings. If a queried name ever has more than one TXT record (order is not guaranteed by DNS), this can pick the wrong one and fail the whole sync for that domain — it fails safe (parse/signature error → tree skipped), so it's not a security hole, but it's a robustness gap that's more likely to bite once --discovery.dns accepts arbitrary user-supplied domains than on the dedicated *.ethdisco.net labels.

  2. Node/request budget is shared and consumed in LIFO order across all configured trees (dns.rs:776-810)
    DEFAULT_MAX_NODES/DEFAULT_MAX_REQUESTS bound the whole sync() call, and pending.pop() processes links in reverse of the order they were configured. With the shipped defaults (one tree per network) this is moot, but if an operator configures multiple --discovery.dns trees, an unusually large or slow tree processed first can starve the others of budget before they're ever queried. Consider either per-tree sub-budgets or documenting that trees are processed last-configured-first.

  3. Dependency footprint (Cargo.toml:108-357, Cargo.lock)
    hickory-resolver with system-config pulls in a fairly large transitive tree for what is otherwise a handful of TXT lookups — jni, moka, ipconfig, windows-registry, ndk-context, etc. (Cargo.lock shows ~15 new crates). This is inherent to the feature choice and the comment justifying it (default-features = false, only system-config + tokio) shows the tradeoff was considered, but for a consensus-critical client it's worth a second look at whether a lighter-weight TXT-only resolver (or hand-rolled UDP query, given the tamper-evidence is already done at the application layer) would shrink the supply-chain surface. Not a request to change, just flagging for the team's awareness.

  4. Canonical field order assumed on the signed root preimage (dns.rs:684-691)
    signed_content() reconstructs "enrtree-root:v1 e={} l={} seq={}" in a fixed order regardless of the order fields appeared in the actual TXT record. This matches every known real-world publisher (and is pinned by the sepolia test), so it's not currently a bug, but it's worth a one-line comment noting this is an assumption about publisher behavior rather than something the code independently derives from the record itself.

Minor / non-blocking

  • next_sync_delay (dns.rs:1021-1029) and its overflow-safety comment are correct and well tested (retry backoff test covers u32::MAX saturation).
  • The deliberate use of new_contacts over new_contact_records to avoid the evaluate_fork_id/pruning path is called out clearly in the PR description and matches how do_new_contacts vs do_new_contact_records actually behave (verified in peer_table.rs:1427-1456 vs 1458+) — no fork-ID filtering is lost relative to the existing discv4 contact path, so this is consistent rather than a new gap.
  • DNS itself is unauthenticated at the transport layer (TLS/DoH deliberately left off per the Cargo.toml comment), but since content is signature-verified end-to-end against a hardcoded public key, an on-path attacker without EF's private key can at most withhold/replay a stale-but-validly-signed list, not inject nodes — consistent with the EIP-1459 threat model.
  • cmd/ethrex/initializers.rs:430-450 and the CLI wiring (empty string disables, comma-separated override) are straightforward and correctly handle the empty-after-trim case.

No correctness issues found in the RLP/ENR decoding, gas/consensus logic is untouched by this PR (pure networking), and the retry/backoff and cyclic-tree tests give good confidence in the walker's termination properties.


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

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.

Pull request overview

This PR adds an EIP-1459 DNS-based discovery bootstrap path to improve resilience when hardcoded bootnodes are unreachable, by periodically syncing signed DNS-hosted ENR trees into the peer table without blocking startup.

Changes:

  • Implemented an EIP-1459 DNS ENR-tree walker with root/label/ENR signature verification and bounded node/request limits.
  • Wired DNS discovery into network startup and CLI/config defaults (mainnet/sepolia/hoodi enabled by default; overridable/disableable via --discovery.dns / ETHREX_DISCOVERY_DNS).
  • Added hickory-resolver dependency for system TXT lookups and documented the new CLI option.

Reviewed changes

Copilot reviewed 10 out of 11 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
docs/CLI.md Documents the new --discovery.dns option and env var.
crates/networking/p2p/network.rs Spawns the DNS discovery background task when enabled.
crates/networking/p2p/discovery/server.rs Updates DiscoveryConfig construction for the new field.
crates/networking/p2p/discovery/mod.rs Exposes the new dns module and extends DiscoveryConfig.
crates/networking/p2p/discovery/dns.rs Core EIP-1459 implementation + resolver abstraction + unit tests.
crates/networking/p2p/Cargo.toml Adds hickory-resolver dependency for DNS TXT queries.
crates/common/config/networks.rs Provides default ethdisco.net enrtree URLs per public network.
cmd/ethrex/initializers.rs Parses/chooses DNS discovery links from CLI/env vs network defaults.
cmd/ethrex/cli.rs Adds the --discovery.dns / ETHREX_DISCOVERY_DNS option.
Cargo.toml Adds workspace dependency configuration for hickory-resolver.
Cargo.lock Locks new transitive dependencies introduced by hickory-resolver.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +825 to +829
async fn stops_at_request_budget_on_a_cyclic_tree() {
// A branch that lists its own label would loop forever without the
// visited set; the budget is the backstop if a tree fans out instead.
let secret = secp256k1::SecretKey::from_slice(&[3u8; 32]).expect("valid key");
let public_key = PublicKey::from_secret_key(secp256k1::SECP256K1, &secret);
@ilitteri

Copy link
Copy Markdown
Collaborator Author

Stopgap that restores sepolia syncing while this is reviewed: #7079. Once this lands, the hardcoded addresses added there stop being load-bearing.

NikhilSharmaWe pushed a commit to NikhilSharmaWe/ethrex that referenced this pull request Aug 1, 2026
**Motivation**

All five of sepolia's EF execution-layer bootnodes stopped answering UDP
discovery on 2026-07-30, in a window between 12:39 and 15:44 UTC, and
have not come back. They are the only entries in
`cmd/ethrex/networks/sepolia/bootnodes.json`, so a node starting with an
empty peer table has no way into the DHT at all: it re-pings those five
addresses forever and sits at zero peers, unable to begin snap sync even
though the network itself is healthy and the consensus client peers
normally.

The impact is broad, because everything that starts from a fresh datadir
hits it:

- **Snapsync CI**: both sepolia jobs fail every run, each burning its
full 3h30m budget. Run `30594206688` is representative — `sepolia -
Lighthouse` and `sepolia - Prysm` both failed at 3h31m while both hoodi
jobs passed. That is over 7h of a 6h cron window, so runs now queue
behind each other.
- **The multisync host**: the sepolia leg has failed every cycle since
2026-07-30 23:15 UTC, having never failed before in that log (the
previous failure of any network was 2026-06-10). The currently stuck
container had downloaded **not one header or account** after eight
hours, having sent ~143k discovery packets to those five addresses and
received nothing back, while hoodi and mainnet synced normally beside
it.
- **Users**, on any cold start.

This is not a regression and not a fork mismatch: commit `2f1593f2`
passed sepolia twice before failing on the same SHA, and sepolia's
`eth_config` reports its current fork with `next: null`, matching our
genesis exactly. The bootnode hosts are alive — they answer ICMP, and
one still serves consensus-layer discv5 — so only the execution-layer
discovery service is gone. Upstream `eth-clients/sepolia` still lists
all five unchanged, so nothing has been retired on paper.

**Description**

Keeps the five EF entries and appends twelve reachable nodes taken from
sepolia's public DNS node list.

- The EF entries stay because they are the canonical bootnodes, upstream
still publishes them unchanged, and they may well come back.
- Each added node was verified **individually**, by starting a node with
that bootnode as its only entry and DNS-based discovery unavailable,
then confirming inbound discovery replies could only have come from it.
23 of the 24 candidates tested were responsive; the one that was not was
dropped.
- They are spread across distinct providers and regions deliberately.
The five EF bootnodes were all on a single provider, which is precisely
why losing them was all-or-nothing.

Verified end to end on this branch with no command-line overrides: a
cold start reaches peers within 30 seconds, and the `failed to find
target block header` loop that characterises the outage does not appear
at all (0 occurrences, against 906 on the stuck multisync container).

This buys back the ability to sync sepolia today, and it covers CI, the
multisync host and users in one change. It is explicitly **not** a
substitute for reading that node list at runtime — hardcoded addresses
are exactly what failed here, and these are community nodes rather than
dedicated infrastructure, so they will drift over time. lambdaclass#7077 is the
durable fix.

Supersedes lambdaclass#7076, which worked around the same outage in the snapsync
action only; that PR can be closed in favour of this one.

**Checklist**

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

Not applicable — no storage change, and no resync is required.
@MysticRyuujin

Copy link
Copy Markdown

I tested this PR at commit 74c0e063.

It looks good generally speaking. SystemResolver walked the live trees to completion with nothing rejected: 3000 nodes from all.mainnet.ethdisco.net, 250 from sepolia and 433 from hoodi, which are the counts go-ethereum's dnsdisc reads from the same trees. I found a few issues worth mentioning:

1. The tests could use an update, because stops_at_request_budget_on_a_cyclic_tree always passes.

The test puts a bare enrtree-branch: record in the mock DNS. That record is an empty branch. The walk therefore stops after two lookups. It never spends the request budget.

Two experiments show the gap. First, I replaced the budget check in sync_tree with if false. All 16 tests still passed. Second, I made visited_labels.insert always report a new label. All 16 tests still passed. No test covers either protection.

One way to cover the budget is a chain of 40 branches, where each branch names the next one. A resolver that counts the lookups can then assert that the count stops at the budget. A diamond, where two branches name the same leaf, would cover the visited set.

The test name may also be worth a second look. A tree cannot hold a cycle, because a cycle needs a record that contains its own label, and the label is the hash of that record. The budget protects against a large tree, not against a cycle.

2. The description of new_contacts may be worth updating, because its reason does not apply to your default trees.

The description says that the record path discards an ENR with no eth key. I read every record in the three default trees. They all carry an eth key that decodes: 3000 of 3000, 250 of 250, and 433 of 433.

new_contacts still looks like the correct choice, for a different reason. A DNS contact that answers a ping then gets a discv4 ENR request. discv4_validate_enr_fork_id reads the record and the fork verdict from the node itself. The DNS copy only has to supply an address to ping.

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

Labels

L1 Ethereum client

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

3 participants