Skip to main content

reth_cli_commands/p2p/
bootnode.rs

1//! Standalone bootnode command
2
3use clap::Parser;
4use futures::FutureExt;
5use reth_cli_util::{get_secret_key, load_secret_key::rng_secret_key};
6use reth_discv4::{DiscoveryUpdate, Discv4, Discv4Config};
7use reth_discv5::{
8    discv5::{self, Event, ListenConfig},
9    enr::EnrCombinedKeyWrapper,
10    Config, Discv5, IpMode,
11};
12use reth_net_nat::NatResolver;
13use reth_network_peers::{id2pk, pk2id, AnyNode, Enr, NodeRecord};
14use secp256k1::SecretKey;
15use socket2::{Domain, Protocol, Socket, Type};
16use std::{
17    net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
18    path::PathBuf,
19    sync::Arc,
20};
21use tokio::{net::UdpSocket, select};
22use tokio_stream::StreamExt;
23use tracing::{info, warn};
24
25/// Start a discovery only bootnode.
26#[derive(Parser, Debug)]
27pub struct Command {
28    /// Listen address for the bootnode (default: "0.0.0.0:30301").
29    #[arg(long, default_value = "0.0.0.0:30301")]
30    pub addr: SocketAddr,
31
32    /// Secret key to use for the bootnode.
33    ///
34    /// This will also deterministically set the peer ID.
35    /// If a path is provided but no key exists at that path,
36    /// a new random secret will be generated and stored there.
37    /// If no path is specified, a new ephemeral random secret will be used.
38    #[arg(long, value_name = "PATH")]
39    pub p2p_secret_key: Option<PathBuf>,
40
41    /// NAT resolution method (any|none|upnp|publicip|extip:\<IP\>).
42    ///
43    /// Can be repeated with one IPv4 and one IPv6 `extip:<IP>` to advertise a dual-stack node.
44    /// The enode advertises only the `--addr` family; the discv5 and EIP-868 ENRs carry both
45    /// families, and with `--v5` discv4 also answers the opposite family on the shared port.
46    #[arg(long, default_value = "any")]
47    pub nat: Vec<NatResolver>,
48
49    /// Also run discv5, sharing the discv4 UDP port (`--addr`).
50    ///
51    /// The opposite-family sibling socket is only bound when an `extip:<IP>` of that family is
52    /// advertised via `--nat`; the default `--nat any` serves the `--addr` family only.
53    #[arg(long)]
54    pub v5: bool,
55
56    /// Comma-separated nodes to seed discovery: `enode://` URLs with an IP host and/or signed
57    /// `enr:` records. Each entry seeds discv4 and (with `--v5`) discv5.
58    #[arg(long, value_delimiter = ',')]
59    pub bootnodes: Vec<AnyNode>,
60}
61
62impl Command {
63    /// Execute the bootnode command.
64    pub async fn execute(self) -> eyre::Result<()> {
65        info!("Bootnode started with config: {self:?}");
66
67        let sk = self.network_secret()?;
68        // A discovery-only bootnode serves no RLPx, so advertise TCP port 0
69        // (`enode://…@<ip>:0?discport=<udp>`).
70        let local_enr = NodeRecord::from_secret_key(self.addr, &sk).with_tcp_port(0);
71        let nat = self.resolved_nat()?;
72        let seeds = self.seed_nodes(&nat)?;
73
74        let discv4_config = self.discv4_config(&nat, &seeds);
75
76        // In v5 mode discv4 and discv5 share a single UDP socket: discv5 owns the read loop and
77        // discv4 packets surface as `UnrecognizedFrame`s forwarded to its ingress. The discv5
78        // service must be kept alive for the event loop lifetime.
79        let (_discv4, mut discv4_service, _discv5, mut discv5_forwarder) = if self.v5 {
80            let shared_socket = bind_socket(self.addr).await?;
81            // Actual port, so an ephemeral `--addr` port (`:0`) still yields one shared port.
82            let shared_port = shared_socket.local_addr()?.port();
83
84            // Hand discv5 the shared socket and bind the opposite family (if advertised) on the
85            // same port. That sibling socket doubles as discv4's secondary-family egress, so
86            // discv4 answers both families on the shared port.
87            let mut discv5_config = self.discv5_config(&nat, &seeds);
88            let discv5_cfg = discv5_config.discv5_config_mut();
89            let secondary_addr = reth_discv5::config::sibling_addr(
90                &discv5_cfg.listen_config,
91                self.addr,
92                shared_port,
93            );
94            let secondary_socket = match secondary_addr {
95                Some(addr) => Some(bind_socket(addr).await?),
96                None => None,
97            };
98            let (ipv4, ipv6) = if self.addr.is_ipv4() {
99                (Some(shared_socket.clone()), secondary_socket.clone())
100            } else {
101                (secondary_socket.clone(), Some(shared_socket.clone()))
102            };
103            discv5_cfg.listen_config = ListenConfig::FromSockets { ipv4, ipv6 };
104
105            let (discv4, discv4_service, mut ingress) =
106                Discv4::bind_shared(shared_socket, secondary_socket, local_enr, sk, discv4_config)?;
107            info!("Started discv4 (shared port) at address: {local_enr:?}");
108
109            info!("Starting discv5 (shared port)");
110            let (discv5, mut updates) = Discv5::start(&sk, discv5_config).await?;
111            log_discv5_enr(&discv5);
112
113            // A dedicated task forwards discv4 frames to their ingress and logs discv5 sessions.
114            let forwarder = tokio::spawn(
115                async move {
116                    while let Some(event) = updates.recv().await {
117                        match event {
118                            Event::UnrecognizedFrame(frame) => {
119                                ingress.handle_packet(&frame.packet, frame.src_address).await
120                            }
121                            Event::SessionEstablished(enr, _) => {
122                                info!("(Discv5) new peer added, peer_id={:?}", enr.id())
123                            }
124                            _ => {}
125                        }
126                    }
127                    info!("(Discv5) update stream ended.");
128                }
129                .boxed(),
130            );
131
132            (discv4, discv4_service, Some(discv5), Some(forwarder))
133        } else {
134            let (discv4, discv4_service) =
135                Discv4::bind(self.addr, local_enr, sk, discv4_config).await?;
136            info!("Started discv4 at address: {local_enr:?}");
137            (discv4, discv4_service, None, None)
138        };
139
140        let mut discv4_updates = discv4_service.update_stream();
141        discv4_service.spawn();
142
143        // event info loop for logging
144        loop {
145            select! {
146                update = discv4_updates.next() => {
147                    if let Some(update) = update {
148                        match update {
149                            DiscoveryUpdate::Added(record) => {
150                                info!("(Discv4) new peer added, peer_id={:?}", record.id);
151                            }
152                            DiscoveryUpdate::Removed(peer_id) => {
153                                info!("(Discv4) peer with peer-id={:?} removed", peer_id);
154                            }
155                            _ => {}
156                        }
157                    } else {
158                        info!("(Discv4) update stream ended.");
159                        break;
160                    }
161                }
162                // discv5 logging lives in the forwarder task; end the loop when it exits.
163                _ = async {
164                    if let Some(forwarder) = &mut discv5_forwarder {
165                        let _ = forwarder.await;
166                    } else {
167                        futures::future::pending::<()>().await
168                    }
169                } => break,
170            }
171        }
172
173        Ok(())
174    }
175
176    fn network_secret(&self) -> eyre::Result<SecretKey> {
177        match &self.p2p_secret_key {
178            Some(path) => Ok(get_secret_key(path)?),
179            None => Ok(rng_secret_key()),
180        }
181    }
182
183    /// Resolves the configured `--nat` values into the addresses to advertise.
184    ///
185    /// A single value behaves like the rest of the node: the resolver drives discv4's external IP
186    /// resolution, and any fixed IP is also advertised in the discv5 ENR. Two `extip:<IP>` values
187    /// (one per family) advertise a dual-stack record; the enode advertises only the family
188    /// matching `--addr`, since discv4's own record carries a single address.
189    fn resolved_nat(&self) -> eyre::Result<BootnodeNat> {
190        match self.nat.as_slice() {
191            [] => Ok(BootnodeNat::single(NatResolver::Any, self.addr.port())),
192            [nat] => {
193                let mut single = BootnodeNat::single(nat.clone(), self.addr.port());
194                // A static IP (`extip`/`extaddr`) of the opposite family to `--addr` can't drive
195                // discv4 (it binds only the `--addr` family): advertise it via discv5 only, and
196                // use `None` rather than `Any` so discv4 doesn't silently auto-resolve an address
197                // the operator never provided.
198                if let [ip] = single.advertised_ips[..] &&
199                    ip.is_ipv4() != self.addr.is_ipv4()
200                {
201                    single.resolver = NatResolver::None;
202                }
203                Ok(single)
204            }
205            [first, second] => {
206                let first_ip = fixed_external_ip(first)?;
207                let second_ip = fixed_external_ip(second)?;
208
209                if first_ip.is_ipv4() == second_ip.is_ipv4() {
210                    eyre::bail!("repeated --nat requires one IPv4 and one IPv6 extip:<IP> value");
211                }
212
213                // discv4 binds a single socket, so its resolver must use the `--addr` family.
214                let primary_ip =
215                    if first_ip.is_ipv4() == self.addr.is_ipv4() { first_ip } else { second_ip };
216
217                Ok(BootnodeNat {
218                    resolver: NatResolver::ExternalIp(primary_ip),
219                    advertised_ips: vec![first_ip, second_ip],
220                })
221            }
222            _ => {
223                eyre::bail!("--nat can be provided at most twice");
224            }
225        }
226    }
227
228    fn discv4_config(&self, nat: &BootnodeNat, seeds: &BootnodeSeeds) -> Discv4Config {
229        let mut builder = Discv4Config::builder();
230        builder.external_ip_resolver(Some(nat.resolver.clone())).add_boot_nodes(
231            seeds.discv4_records().filter(|record| {
232                let served = self.serves_family(record.address, nat);
233                if !served {
234                    warn!(
235                        "No socket will be bound for the IP family of --bootnodes entry {record}; \
236                         it will not be contacted (advertise a matching --nat extip with --v5)"
237                    );
238                }
239                served
240            }),
241        );
242        // the opposite-family address is only served with the `--v5` sibling socket
243        if self.v5 &&
244            let Some(ip) =
245                nat.advertised_ips.iter().find(|ip| ip.is_ipv4() != self.addr.is_ipv4())
246        {
247            builder.secondary_advertised_ip(*ip);
248        }
249        builder.build()
250    }
251
252    /// Parses `--bootnodes`, keeping enode-sourced records separate from signed ENRs so an ENR
253    /// entry is not seeded into discv5 twice (signed and again as an unsigned enode).
254    fn seed_nodes(&self, nat: &BootnodeNat) -> eyre::Result<BootnodeSeeds> {
255        let mut seeds = BootnodeSeeds::default();
256        for node in &self.bootnodes {
257            let record = match node {
258                AnyNode::NodeRecord(record) => {
259                    // v4-mapped hosts parse as V6; normalize them so they route as IPv4
260                    let record = record.into_ipv4_mapped();
261                    id2pk(record.id).map_err(|err| {
262                        eyre::eyre!("--bootnodes entry has an invalid node id ({err}): {node}")
263                    })?;
264                    seeds.enodes.push(record);
265                    record
266                }
267                AnyNode::Enr(enr) => {
268                    let record = self.enr_endpoint(enr, nat)?;
269                    seeds.enr_records.push(record);
270                    let EnrCombinedKeyWrapper(enr) = enr.clone().try_into().map_err(|err| {
271                        eyre::eyre!("--bootnodes entry has an invalid ENR ({err}): {node}")
272                    })?;
273                    seeds.enrs.push(enr);
274                    record
275                }
276                node => eyre::bail!(
277                    "--bootnodes entries must be enode URLs with an IP host or signed ENRs: {node}"
278                ),
279            };
280            if record.udp_port == 0 {
281                eyre::bail!("--bootnodes entry has no discovery port: {node}");
282            }
283        }
284        Ok(seeds)
285    }
286
287    /// Derives an ENR seed's discv4 endpoint, preferring a family this bootnode serves over the
288    /// default order (IPv4 first).
289    ///
290    /// Endpoints are derived per IP family so a partial dual-stack ENR (e.g. `ip4` without
291    /// `udp4`, plus `ip6`/`udp6`) cannot yield a mixed-family socket.
292    ///
293    /// Intentionally not `NodeRecord::try_from(&Enr)`: this needs to prefer the family this
294    /// bootnode serves instead of that conversion's IPv4-first order.
295    fn enr_endpoint(&self, enr: &Enr<SecretKey>, nat: &BootnodeNat) -> eyre::Result<NodeRecord> {
296        let id = pk2id(&enr.public_key());
297        let v4 = enr.ip4().zip(enr.udp4()).map(|(ip, udp_port)| NodeRecord {
298            address: ip.into(),
299            udp_port,
300            tcp_port: enr.tcp4().unwrap_or(0),
301            id,
302        });
303        // per EIP-778, generic `udp`/`tcp` keys also apply to IPv6 when the v6-specific
304        // counterparts are absent
305        let v6 = enr.ip6().zip(enr.udp6().or_else(|| enr.udp4())).map(|(ip, udp_port)| {
306            NodeRecord {
307                address: ip.into(),
308                udp_port,
309                tcp_port: enr.tcp6().or_else(|| enr.tcp4()).unwrap_or(0),
310                id,
311            }
312            .into_ipv4_mapped()
313        });
314
315        // an unservable endpoint is kept as a last resort (the startup warning covers it), and a
316        // zero-udp endpoint only after that, so `seed_nodes` reports the missing port
317        let nonzero = |record: &NodeRecord| record.udp_port != 0;
318        v4.into_iter()
319            .chain(v6)
320            .filter(nonzero)
321            .find(|record| self.serves_family(record.address, nat))
322            .or_else(|| v4.filter(nonzero))
323            .or_else(|| v6.filter(nonzero))
324            .or(v4)
325            .or(v6)
326            .ok_or_else(|| {
327                eyre::eyre!("unusable --bootnodes ENR (no ip+udp endpoint for one family): {enr}")
328            })
329    }
330
331    /// Whether a socket of `ip`'s family will be bound: the `--addr` family always, the opposite
332    /// family only when `--v5` binds its sibling socket (an address of that family is advertised).
333    fn serves_family(&self, ip: IpAddr, nat: &BootnodeNat) -> bool {
334        ip.is_ipv4() == self.addr.is_ipv4() ||
335            (self.v5 && nat.advertised_ips.iter().any(|a| a.is_ipv4() == ip.is_ipv4()))
336    }
337
338    fn discv5_config(&self, nat: &BootnodeNat, seeds: &BootnodeSeeds) -> Config {
339        // Discovery-only bootnode: no RLPx, so the ENR carries `tcp=0`; the discovery listen
340        // port is set from `--addr` below.
341        let mut builder = Config::builder(SocketAddr::new(self.addr.ip(), 0));
342
343        // discv5 shares discv4's UDP port.
344        let port = self.addr.port();
345
346        // Bind every family we advertise (plus the `--addr` family). Without a listen socket of a
347        // given family, `build_local_enr` drops that family's advertised IP even though setting it
348        // disables `enr_update`, which would leave discv5 with no usable record.
349        let bind_ipv4 = self.addr.is_ipv4() || nat.advertised_ips.iter().any(IpAddr::is_ipv4);
350        let bind_ipv6 = self.addr.is_ipv6() || nat.advertised_ips.iter().any(IpAddr::is_ipv6);
351        let listen = if bind_ipv4 && bind_ipv6 {
352            ListenConfig::DualStack {
353                ipv4: Ipv4Addr::UNSPECIFIED,
354                ipv4_port: port,
355                ipv6: Ipv6Addr::UNSPECIFIED,
356                ipv6_port: port,
357            }
358        } else if bind_ipv6 {
359            ListenConfig::Ipv6 { ip: Ipv6Addr::UNSPECIFIED, port }
360        } else {
361            ListenConfig::Ipv4 { ip: Ipv4Addr::UNSPECIFIED, port }
362        };
363        builder = builder.discv5_config(discv5::ConfigBuilder::new(listen).build());
364
365        for ip in &nat.advertised_ips {
366            builder = builder.advertised_ip(*ip);
367        }
368
369        // discv5's bootstrap aborts on an ENR it cannot contact (AddNodeFailed) and wastes a
370        // request_enr round-trip on an unreachable enode. Seed only what the listen config serves
371        let enrs = seeds.enrs.iter().filter(|enr| {
372            let contactable = discv5_contactable(enr, bind_ipv4, bind_ipv6);
373            if !contactable {
374                warn!("--bootnodes ENR has no endpoint for a served IP family; discv5 will not dial it: {enr}");
375            }
376            contactable
377        });
378        let enodes = seeds.enodes.iter().filter(|record| {
379            if record.address.is_ipv4() {
380                bind_ipv4
381            } else {
382                bind_ipv6
383            }
384        });
385
386        builder =
387            builder.add_signed_boot_nodes(enrs.cloned()).add_unsigned_boot_nodes(enodes.copied());
388
389        builder.build()
390    }
391}
392
393/// Resolved `--nat` configuration for the bootnode.
394#[derive(Debug)]
395struct BootnodeNat {
396    /// Resolver driving discv4's external IP resolution.
397    resolver: NatResolver,
398    /// Fixed IPs to advertise in the discv5 ENR (one per family).
399    advertised_ips: Vec<IpAddr>,
400}
401
402impl BootnodeNat {
403    fn single(resolver: NatResolver, port: u16) -> Self {
404        let advertised_ips = resolver.clone().as_external_ip(port).into_iter().collect();
405        Self { resolver, advertised_ips }
406    }
407}
408
409/// Parsed `--bootnodes`, split by record kind.
410#[derive(Debug, Default)]
411struct BootnodeSeeds {
412    /// `enode://` entries: seed discv4 directly and discv5 as unsigned boot nodes.
413    enodes: Vec<NodeRecord>,
414    /// Signed `enr:` entries: seed discv5.
415    enrs: Vec<discv5::Enr>,
416    /// Endpoints derived from `enrs`: seed discv4.
417    enr_records: Vec<NodeRecord>,
418}
419
420impl BootnodeSeeds {
421    /// All discv4 bootstrap records (enode entries + ENR-derived endpoints).
422    fn discv4_records(&self) -> impl Iterator<Item = NodeRecord> + '_ {
423        self.enodes.iter().chain(&self.enr_records).copied()
424    }
425}
426
427/// Binds a UDP socket for shared discv4/discv5 use.
428///
429/// IPv6 sockets are bound with `IPV6_V6ONLY=true` so an IPv4 sibling socket on the same port
430/// doesn't clash, matching how discv5 binds its `DualStack` sockets.
431async fn bind_socket(addr: SocketAddr) -> eyre::Result<Arc<UdpSocket>> {
432    let socket = match addr {
433        SocketAddr::V4(_) => UdpSocket::bind(addr).await?,
434        SocketAddr::V6(_) => {
435            let socket = Socket::new(Domain::IPV6, Type::DGRAM, Some(Protocol::UDP))?;
436            socket.set_only_v6(true)?;
437            socket.set_nonblocking(true)?;
438            socket.bind(&addr.into())?;
439            UdpSocket::from_std(socket.into())?
440        }
441    };
442    Ok(Arc::new(socket))
443}
444
445fn fixed_external_ip(nat: &NatResolver) -> eyre::Result<IpAddr> {
446    match nat {
447        NatResolver::ExternalIp(ip) => Ok(*ip),
448        _ => {
449            eyre::bail!("--nat can only be repeated with extip:<IP> values");
450        }
451    }
452}
453
454/// Whether discv5 can dial `enr` with the sockets it binds, delegating to
455/// [`discv5::IpMode::get_contactable_addr`]. A zero-port endpoint passes the ip mode check but is
456/// not dialable, so it is rejected too.
457fn discv5_contactable(enr: &discv5::Enr, bind_ipv4: bool, bind_ipv6: bool) -> bool {
458    match (bind_ipv4, bind_ipv6) {
459        (true, true) => IpMode::DualStack,
460        (false, true) => IpMode::Ip6,
461        _ => IpMode::Ip4,
462    }
463    .get_contactable_addr(enr)
464    .is_some_and(|addr| addr.port() != 0)
465}
466
467fn log_discv5_enr(discv5: &Discv5) {
468    let enr = discv5.local_enr();
469    info!(
470        id = ?enr.node_id(),
471        ip4 = ?enr.ip4(),
472        ip6 = ?enr.ip6(),
473        udp4 = ?enr.udp4(),
474        udp6 = ?enr.udp6(),
475        tcp4 = ?enr.tcp4(),
476        tcp6 = ?enr.tcp6(),
477        "(Discv5) advertised endpoints",
478    );
479    info!("(Discv5) enr: {enr}");
480    if let Some(record) = discv5.node_record() {
481        info!("(Discv5) enode: {record}");
482    }
483}
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488    use reth_discv5::build_local_enr;
489
490    #[test]
491    fn repeated_nat_uses_matching_addr_family_as_primary() {
492        let command = Command::parse_from([
493            "reth",
494            "--addr",
495            "[::]:30303",
496            "--nat",
497            "extip:1.2.3.4",
498            "--nat",
499            "extip:2001:db8::1",
500        ]);
501
502        let nat = command.resolved_nat().unwrap();
503
504        // discv4 binds the `--addr` (IPv6) family, so its resolver uses the IPv6 extip; both
505        // families are still advertised by discv5.
506        assert_eq!(nat.resolver, NatResolver::ExternalIp("2001:db8::1".parse().unwrap()));
507        assert_eq!(nat.advertised_ips.len(), 2);
508    }
509
510    #[test]
511    fn repeated_nat_requires_extip_values() {
512        let command = Command::parse_from(["reth", "--nat", "any", "--nat", "extip:2001:db8::1"]);
513
514        assert!(command.resolved_nat().is_err());
515    }
516
517    #[test]
518    fn repeated_nat_requires_distinct_ip_families() {
519        let command =
520            Command::parse_from(["reth", "--nat", "extip:1.2.3.4", "--nat", "extip:5.6.7.8"]);
521
522        assert!(command.resolved_nat().is_err());
523    }
524
525    #[test]
526    fn single_extip_is_advertised() {
527        let command =
528            Command::parse_from(["reth", "--addr", "0.0.0.0:30301", "--nat", "extip:1.2.3.4"]);
529
530        let nat = command.resolved_nat().unwrap();
531
532        assert_eq!(nat.advertised_ips, vec!["1.2.3.4".parse::<IpAddr>().unwrap()]);
533    }
534
535    #[test]
536    fn discv5_advertises_single_extip_of_other_family_than_addr() {
537        // A v6 extip under a v4 `--addr` must still be bound and advertised, not silently dropped.
538        let command = Command::parse_from([
539            "reth",
540            "--addr",
541            "0.0.0.0:45678",
542            "--v5",
543            "--nat",
544            "extip:2001:db8::1",
545        ]);
546        let nat = command.resolved_nat().unwrap();
547        let config = command.discv5_config(&nat, &command.seed_nodes(&nat).unwrap());
548        let sk = SecretKey::from_secret_bytes([1u8; 32]).unwrap();
549
550        let (enr, _, _, _) = build_local_enr(&sk, &config);
551
552        // discv5 shares discv4's port, so it advertises the `--addr` port.
553        assert_eq!(enr.ip6(), Some("2001:db8::1".parse().unwrap()));
554        assert_eq!(enr.udp6(), Some(45678));
555    }
556
557    #[test]
558    fn discv5_config_single_stack_ipv4_uses_addr_port() {
559        // A single-family (v4-only) bootnode: discv5 listens/advertises the `--addr` port, v4 only.
560        let command = Command::parse_from([
561            "reth",
562            "--addr",
563            "0.0.0.0:45678",
564            "--v5",
565            "--nat",
566            "extip:1.2.3.4",
567        ]);
568        let nat = command.resolved_nat().unwrap();
569        let config = command.discv5_config(&nat, &command.seed_nodes(&nat).unwrap());
570        let sk = SecretKey::from_secret_bytes([1u8; 32]).unwrap();
571
572        let (enr, _, _, _) = build_local_enr(&sk, &config);
573
574        assert_eq!(enr.ip4(), Some("1.2.3.4".parse().unwrap()));
575        assert_eq!(enr.udp4(), Some(45678));
576        assert_eq!(enr.udp6(), None);
577    }
578
579    #[test]
580    fn discv5_config_single_stack_ipv6_uses_addr_port() {
581        // A single-family (v6-only) bootnode: discv5 listens/advertises the `--addr` port, v6 only.
582        let command = Command::parse_from([
583            "reth",
584            "--addr",
585            "[::]:45678",
586            "--v5",
587            "--nat",
588            "extip:2001:db8::1",
589        ]);
590        let nat = command.resolved_nat().unwrap();
591        let config = command.discv5_config(&nat, &command.seed_nodes(&nat).unwrap());
592        let sk = SecretKey::from_secret_bytes([1u8; 32]).unwrap();
593
594        let (enr, _, _, _) = build_local_enr(&sk, &config);
595
596        assert_eq!(enr.ip6(), Some("2001:db8::1".parse().unwrap()));
597        assert_eq!(enr.udp6(), Some(45678));
598        assert_eq!(enr.udp4(), None);
599    }
600
601    #[test]
602    fn single_opposite_family_extip_does_not_drive_discv4() {
603        // A single v6 extip under a v4 `--addr` must not become discv4's external IP (discv4 binds
604        // only the v4 shared socket); it is still advertised via discv5.
605        let command = Command::parse_from([
606            "reth",
607            "--addr",
608            "0.0.0.0:45678",
609            "--v5",
610            "--nat",
611            "extip:2001:db8::1",
612        ]);
613        let nat = command.resolved_nat().unwrap();
614
615        assert_eq!(nat.resolver, NatResolver::None);
616        assert_eq!(nat.advertised_ips, vec!["2001:db8::1".parse::<IpAddr>().unwrap()]);
617    }
618
619    #[tokio::test]
620    async fn shared_socket_binds_both_families_on_one_port() {
621        // A v4 and a v6 socket must bind the SAME port (V6ONLY prevents the clash). The probes
622        // skip single-stack hosts; past them, a regressed V6ONLY must fail loudly.
623        let (Ok(probe4), Ok(probe6)) = (
624            bind_socket("0.0.0.0:0".parse().unwrap()).await,
625            bind_socket("[::]:0".parse().unwrap()).await,
626        ) else {
627            return;
628        };
629        drop((probe4, probe6));
630
631        let v4 = bind_socket("0.0.0.0:0".parse().unwrap()).await.unwrap();
632        let port = v4.local_addr().unwrap().port();
633        let v6 = bind_socket(format!("[::]:{port}").parse().unwrap()).await.unwrap();
634        assert_eq!(v6.local_addr().unwrap().port(), port);
635    }
636
637    #[test]
638    fn discv4_config_serves_secondary_family_only_with_v5() {
639        // The IPv4 resolver drives discv4's own record either way; the v6 address is only
640        // advertised (EIP-868 ENR) when the `--v5` sibling socket exists to serve it.
641        let args = [
642            "reth",
643            "--addr",
644            "0.0.0.0:30303",
645            "--nat",
646            "extip:1.2.3.4",
647            "--nat",
648            "extip:2001:db8::1",
649        ];
650
651        let command = Command::parse_from(args);
652        let nat = command.resolved_nat().unwrap();
653        let config = command.discv4_config(&nat, &command.seed_nodes(&nat).unwrap());
654        assert_eq!(nat.resolver, NatResolver::ExternalIp("1.2.3.4".parse().unwrap()));
655        assert_eq!(config.secondary_advertised_ip, None);
656
657        let command = Command::parse_from(args.iter().chain(&["--v5"]));
658        let nat = command.resolved_nat().unwrap();
659        let config = command.discv4_config(&nat, &command.seed_nodes(&nat).unwrap());
660        assert_eq!(nat.resolver, NatResolver::ExternalIp("1.2.3.4".parse().unwrap()));
661        assert_eq!(config.secondary_advertised_ip, Some("2001:db8::1".parse().unwrap()));
662    }
663
664    #[test]
665    fn bootnodes_seed_discv4_and_discv5() {
666        // The EIP-778 example record: ip 127.0.0.1, udp 30303, no tcp key (discovery-only).
667        let enr = "enr:-IS4QHCYrYZbAKWCBRlAy5zzaDZXJBGkcnh4MHcBFZntXNFrdvJjX04jRzjzCBOonrkTfj499SZuOh8R33Ls8RRcy5wBgmlkgnY0gmlwhH8AAAGJc2VjcDI1NmsxoQPKY0yuDUmstAHYpMa2_oxVtw0RW_QAdpzBQA8yWM0xOIN1ZHCCdl8";
668        let enode = "enode://d860a01f9722d78051619d1e2351aba3f43f943f6f00718d1b9baa4101932a1f5011f16bb2b1bb35db20d6fe28fa0bf09636d26a87d31de9ec6203eeedb1f666@1.2.3.4:0?discport=30303";
669
670        let command = Command::parse_from(["reth", "--bootnodes", &format!("{enode},{enr}")]);
671        let nat = command.resolved_nat().unwrap();
672        let seeds = command.seed_nodes(&nat).unwrap();
673
674        assert_eq!(seeds.enodes.len(), 1);
675        assert_eq!(seeds.enodes[0].address, "1.2.3.4".parse::<IpAddr>().unwrap());
676        assert_eq!(seeds.enodes[0].udp_port, 30303);
677        assert_eq!(seeds.enodes[0].tcp_port, 0);
678        // the ENR seeds discv4 too, with an absent tcp key meaning no RLPx, but stays out of
679        // `enodes` so discv5 doesn't bootstrap it twice (signed + unsigned)
680        assert_eq!(seeds.enr_records.len(), 1);
681        assert_eq!(seeds.enr_records[0].address, "127.0.0.1".parse::<IpAddr>().unwrap());
682        assert_eq!(seeds.enr_records[0].udp_port, 30303);
683        assert_eq!(seeds.enr_records[0].tcp_port, 0);
684        assert_eq!(seeds.enrs.len(), 1);
685        assert_eq!(seeds.enrs[0].udp4(), Some(30303));
686
687        let config = command.discv4_config(&command.resolved_nat().unwrap(), &seeds);
688        assert_eq!(config.bootstrap_nodes.len(), 2);
689    }
690
691    #[test]
692    fn discv4_config_filters_unserved_bootnodes() {
693        let peer_id = "d860a01f9722d78051619d1e2351aba3f43f943f6f00718d1b9baa4101932a1f5011f16bb2b1bb35db20d6fe28fa0bf09636d26a87d31de9ec6203eeedb1f666";
694        let command = Command::parse_from([
695            "reth",
696            "--addr",
697            "0.0.0.0:30303",
698            "--bootnodes",
699            &format!("enode://{peer_id}@[2001:db8::1]:0?discport=30303"),
700        ]);
701        let nat = command.resolved_nat().unwrap();
702        let seeds = command.seed_nodes(&nat).unwrap();
703
704        let config = command.discv4_config(&nat, &seeds);
705
706        assert!(config.bootstrap_nodes.is_empty());
707    }
708
709    #[test]
710    fn bootnodes_normalize_and_validate_enodes() {
711        let peer_id = "d860a01f9722d78051619d1e2351aba3f43f943f6f00718d1b9baa4101932a1f5011f16bb2b1bb35db20d6fe28fa0bf09636d26a87d31de9ec6203eeedb1f666";
712
713        // a v4-mapped host normalizes to IPv4
714        let command = Command::parse_from([
715            "reth",
716            "--bootnodes",
717            &format!("enode://{peer_id}@[::ffff:9.9.9.9]:0?discport=30303"),
718        ]);
719        let seeds = command.seed_nodes(&command.resolved_nat().unwrap()).unwrap();
720        assert_eq!(seeds.enodes[0].address, "9.9.9.9".parse::<IpAddr>().unwrap());
721
722        // a zero discovery port can never be pinged
723        let command =
724            Command::parse_from(["reth", "--bootnodes", &format!("enode://{peer_id}@1.2.3.4:0")]);
725        assert!(command.seed_nodes(&command.resolved_nat().unwrap()).is_err());
726
727        // an id off the secp256k1 curve would be silently dropped by discv5's bootstrap
728        let bad_id = "ff".repeat(64);
729        let command = Command::parse_from([
730            "reth",
731            "--bootnodes",
732            &format!("enode://{bad_id}@1.2.3.4:30303"),
733        ]);
734        assert!(command.seed_nodes(&command.resolved_nat().unwrap()).is_err());
735    }
736
737    #[test]
738    fn enr_seed_prefers_served_family() {
739        let dual = Enr::builder()
740            .ip4("9.9.9.9".parse().unwrap())
741            .udp4(30303)
742            .ip6("2001:db8::9".parse().unwrap())
743            .udp6(30304)
744            .build(&rng_secret_key())
745            .unwrap()
746            .to_base64();
747
748        // v6-primary: the discv4 seed must be the ENR's IPv6 endpoint, not the ip4-first default
749        let command = Command::parse_from([
750            "reth",
751            "--addr",
752            "[::]:30303",
753            "--nat",
754            "extip:2001:db8::1",
755            "--bootnodes",
756            &dual,
757        ]);
758        let seeds = command.seed_nodes(&command.resolved_nat().unwrap()).unwrap();
759        assert_eq!(seeds.enr_records[0].address, "2001:db8::9".parse::<IpAddr>().unwrap());
760        assert_eq!(seeds.enr_records[0].udp_port, 30304);
761
762        // v4-primary keeps the IPv4 endpoint
763        let command = Command::parse_from([
764            "reth",
765            "--addr",
766            "0.0.0.0:30303",
767            "--nat",
768            "extip:1.2.3.4",
769            "--bootnodes",
770            &dual,
771        ]);
772        let seeds = command.seed_nodes(&command.resolved_nat().unwrap()).unwrap();
773        assert_eq!(seeds.enr_records[0].address, "9.9.9.9".parse::<IpAddr>().unwrap());
774        assert_eq!(seeds.enr_records[0].udp_port, 30303);
775    }
776
777    #[test]
778    fn enr_seed_never_mixes_families() {
779        let sk = rng_secret_key();
780        let enr = Enr::builder()
781            .ip4("9.9.9.9".parse().unwrap())
782            .ip6("2001:db8::9".parse().unwrap())
783            .udp6(30304)
784            .build(&sk)
785            .unwrap();
786
787        // v4-primary bootnode: the lone ip4 key is not an endpoint; the discv4 seed must be the
788        // complete v6 endpoint, not a fabricated 9.9.9.9:30304
789        let command = Command::parse_from([
790            "reth",
791            "--addr",
792            "0.0.0.0:30303",
793            "--nat",
794            "extip:1.2.3.4",
795            "--bootnodes",
796            &enr.to_base64(),
797        ]);
798        let seeds = command.seed_nodes(&command.resolved_nat().unwrap()).unwrap();
799        assert_eq!(seeds.enr_records[0].address, "2001:db8::9".parse::<IpAddr>().unwrap());
800        assert_eq!(seeds.enr_records[0].udp_port, 30304);
801
802        // no complete endpoint for any family is an error
803        let enr = Enr::builder().ip4("9.9.9.9".parse().unwrap()).build(&sk).unwrap();
804        let command = Command::parse_from([
805            "reth",
806            "--addr",
807            "0.0.0.0:30303",
808            "--nat",
809            "extip:1.2.3.4",
810            "--bootnodes",
811            &enr.to_base64(),
812        ]);
813        let err = command.seed_nodes(&command.resolved_nat().unwrap()).unwrap_err();
814        assert_eq!(
815            err.to_string(),
816            format!("unusable --bootnodes ENR (no ip+udp endpoint for one family): {enr}")
817        );
818    }
819
820    #[test]
821    fn enr_seed_uses_generic_ports_for_ip6() {
822        let sk = rng_secret_key();
823        let enr = Enr::builder()
824            .ip6("2001:db8::9".parse().unwrap())
825            .udp4(30305)
826            .tcp4(30306)
827            .build(&sk)
828            .unwrap();
829
830        let command =
831            Command::parse_from(["reth", "--addr", "[::]:30303", "--bootnodes", &enr.to_base64()]);
832        let seeds = command.seed_nodes(&command.resolved_nat().unwrap()).unwrap();
833        assert_eq!(seeds.enr_records[0].address, "2001:db8::9".parse::<IpAddr>().unwrap());
834        assert_eq!(seeds.enr_records[0].udp_port, 30305);
835        assert_eq!(seeds.enr_records[0].tcp_port, 30306);
836    }
837
838    #[test]
839    fn enr_seed_skips_zero_udp_family() {
840        let sk = rng_secret_key();
841        let enr = Enr::builder()
842            .ip4("9.9.9.9".parse().unwrap())
843            .udp4(0)
844            .ip6("2001:db8::9".parse().unwrap())
845            .udp6(30304)
846            .build(&sk)
847            .unwrap();
848
849        // v4-primary: the zero-udp v4 endpoint must not win over the usable v6 endpoint
850        let command = Command::parse_from([
851            "reth",
852            "--addr",
853            "0.0.0.0:30303",
854            "--nat",
855            "extip:1.2.3.4",
856            "--bootnodes",
857            &enr.to_base64(),
858        ]);
859        let seeds = command.seed_nodes(&command.resolved_nat().unwrap()).unwrap();
860        assert_eq!(seeds.enr_records[0].address, "2001:db8::9".parse::<IpAddr>().unwrap());
861        assert_eq!(seeds.enr_records[0].udp_port, 30304);
862
863        // discv5 must not seed the zero-port v4 endpoint either
864        assert!(!discv5_contactable(&seeds.enrs[0], true, false));
865        assert!(discv5_contactable(&seeds.enrs[0], false, true));
866
867        // zero udp on every family is still an unusable entry
868        let enr = Enr::builder().ip4("9.9.9.9".parse().unwrap()).udp4(0).build(&sk).unwrap();
869        let command = Command::parse_from([
870            "reth",
871            "--addr",
872            "0.0.0.0:30303",
873            "--bootnodes",
874            &enr.to_base64(),
875        ]);
876        let err = command.seed_nodes(&command.resolved_nat().unwrap()).unwrap_err();
877        assert_eq!(err.to_string(), format!("--bootnodes entry has no discovery port: {enr}"));
878    }
879
880    #[test]
881    fn enr_seed_normalizes_v4_mapped_ip6() {
882        let sk = rng_secret_key();
883        let enr =
884            Enr::builder().ip6("::ffff:1.2.3.4".parse().unwrap()).udp6(30303).build(&sk).unwrap();
885
886        let command = Command::parse_from([
887            "reth",
888            "--addr",
889            "0.0.0.0:30303",
890            "--nat",
891            "extip:9.9.9.9",
892            "--bootnodes",
893            &enr.to_base64(),
894        ]);
895        let nat = command.resolved_nat().unwrap();
896        let seeds = command.seed_nodes(&nat).unwrap();
897        assert_eq!(seeds.enr_records[0].address, "1.2.3.4".parse::<IpAddr>().unwrap());
898        assert_eq!(seeds.enr_records[0].udp_port, 30303);
899        assert_eq!(command.discv4_config(&nat, &seeds).bootstrap_nodes.len(), 1);
900
901        assert!(!discv5_contactable(&seeds.enrs[0], true, true));
902        assert!(!discv5_contactable(&seeds.enrs[0], false, true));
903
904        let canonical =
905            Enr::builder().ip6("2001:db8::9".parse().unwrap()).udp6(30303).build(&sk).unwrap();
906        let canonical = EnrCombinedKeyWrapper::try_from(canonical).unwrap().0;
907        assert!(discv5_contactable(&canonical, false, true));
908    }
909
910    #[test]
911    fn bootnodes_reject_endpointless_entries() {
912        let peer_id = "d860a01f9722d78051619d1e2351aba3f43f943f6f00718d1b9baa4101932a1f5011f16bb2b1bb35db20d6fe28fa0bf09636d26a87d31de9ec6203eeedb1f666";
913
914        let command = Command::parse_from(["reth", "--bootnodes", &format!("enode://{peer_id}")]);
915        assert!(command.seed_nodes(&command.resolved_nat().unwrap()).is_err());
916    }
917
918    #[test]
919    fn discv5_config_advertises_dual_stack_nat_endpoints() {
920        let command = Command::parse_from([
921            "reth",
922            "--addr",
923            "0.0.0.0:45678",
924            "--v5",
925            "--nat",
926            "extip:1.2.3.4",
927            "--nat",
928            "extip:2001:db8::1",
929        ]);
930        let nat = command.resolved_nat().unwrap();
931        let config = command.discv5_config(&nat, &command.seed_nodes(&nat).unwrap());
932        let sk = SecretKey::from_secret_bytes([1u8; 32]).unwrap();
933
934        let (enr, _, _, _) = build_local_enr(&sk, &config);
935
936        // discv5 shares discv4's port, so both families advertise the `--addr` port.
937        assert_eq!(enr.ip4(), Some("1.2.3.4".parse().unwrap()));
938        assert_eq!(enr.ip6(), Some("2001:db8::1".parse().unwrap()));
939        assert_eq!(enr.udp4(), Some(45678));
940        assert_eq!(enr.udp6(), Some(45678));
941        // No RLPx: tcp=0.
942        assert_eq!(enr.tcp4(), Some(0));
943        assert_eq!(enr.tcp6(), None);
944
945        // The derived enode renders as `…@<ip>:0?discport=<udp>`.
946        let record = NodeRecord::try_from(&enr).unwrap();
947        assert_eq!(record.tcp_port, 0);
948        assert_eq!(record.udp_port, 45678);
949    }
950}