Skip to main content

reth_cli_commands/p2p/
bootnode.rs

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