feat(l1): add EIP-1459 DNS discovery for bootstrap resilience - #7077
feat(l1): add EIP-1459 DNS discovery for bootstrap resilience#7077ilitteri wants to merge 2 commits into
Conversation
…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.
|
🤖 Kimi Code ReviewThis 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 & Suggestions1. Multiple TXT Record HandlingFile: 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 ( // 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 HandlingFile:
"sig" => {
let decoded = ethrex_common::base64::decode(value.as_bytes())
.map_err(|_| invalid("invalid base64 in sig"))?;
signature = Some(decoded);
}3. RNG in Async ContextFile: Uses 4. Missing MetricsConsider adding Prometheus metrics for:
This helps operators diagnose why their nodes might not be finding peers. 5. Documentation ClarityFile: The help text says "Defaults to the known network list; pass an empty string to disable." This is implemented correctly in 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 VerificationThe implementation correctly handles:
CorrectnessAutomated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt |
Greptile SummaryAdds signed EIP-1459 DNS discovery as an asynchronous bootstrap source.
Confidence Score: 4/5The 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
|
| 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]
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
| 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. |
There was a problem hiding this 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)
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!
Lines of code reportTotal lines added: Detailed view |
🤖 Codex Code Review
No EVM/consensus/RLP concerns in this diff; the meaningful risk is in DNS discovery freshness and isolation. I couldn’t run Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
🤖 Claude Code ReviewReview: EIP-1459 DNS Discovery (PR 7077)OverallThis is a well-engineered addition. The signature verification (root signature over the canonical Findings below are ordered roughly by severity; none are blocking, but the first is worth addressing. Findings
Minor / non-blocking
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 |
There was a problem hiding this comment.
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-resolverdependency 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.
| 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); |
|
Stopgap that restores sepolia syncing while this is reviewed: #7079. Once this lands, the hardcoded addresses added there stop being load-bearing. |
**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.
|
I tested this PR at commit It looks good generally speaking. 1. The tests could use an update, because The test puts a bare Two experiments show the gap. First, I replaced the budget check in 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 The description says that the record path discards an ENR with no
|
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
V5Bootnodesand Nethermind'sdiscv5-bootnodes.jsonare the same 15 records in both clients, and all 15 are consensus-layer nodes: aneth2/attnetskey, noethkey, 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:sig=field; a test pins this against the real sepolia root so a change in how it is constructed fails loudly.base32(keccak256(record)[..16]), and that label is verified on fetch, which makes the whole tree tamper-evident under the one root signature.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_DNSoverrides them; passing an empty string disables the feature.Deliberately uses
new_contactsrather thannew_contact_records: the record path runsevaluate_fork_id, which returnsSome(false)for an ENR carrying noethkey, andis_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 aTxtResolvertrait, 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:
--discovery.dns "")Checklist
STORE_SCHEMA_VERSION(crates/storage/lib.rs) if the PR includes breaking changes to theStorerequiring 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.