Skip to main content

reth_discv5/
lib.rs

1//! Wrapper around [`discv5::Discv5`].
2
3#![doc(
4    html_logo_url = "https://raw.githubusercontent.com/paradigmxyz/reth/main/assets/reth-docs.png",
5    html_favicon_url = "https://avatars0.githubusercontent.com/u/97369466?s=256",
6    issue_tracker_base_url = "https://github.com/paradigmxyz/reth/issues/"
7)]
8#![cfg_attr(not(test), warn(unused_crate_dependencies))]
9#![cfg_attr(docsrs, feature(doc_cfg))]
10
11use std::{
12    collections::HashSet,
13    fmt,
14    net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
15    sync::Arc,
16    time::Duration,
17};
18
19use alloy_primitives::bytes::Bytes;
20use enr::{discv4_id_to_discv5_id, EnrCombinedKeyWrapper};
21use futures::future::join_all;
22use itertools::Itertools;
23use rand::{Rng, RngCore};
24use reth_ethereum_forks::{EnrForkIdEntry, ForkId};
25use reth_network_peers::{NodeRecord, PeerId};
26use secp256k1::SecretKey;
27use tokio::{sync::mpsc, task};
28use tracing::{debug, error, trace};
29
30pub mod config;
31pub mod enr;
32pub mod error;
33pub mod filter;
34pub mod metrics;
35pub mod network_stack_id;
36
37pub use ::enr::{CombinedKey, Enr, EnrKey, NodeId};
38pub use discv5::{self, IpMode};
39pub use libp2p_identity;
40
41pub use config::{
42    BootNode, Config, ConfigBuilder, DEFAULT_COUNT_BOOTSTRAP_LOOKUPS, DEFAULT_DISCOVERY_V5_ADDR,
43    DEFAULT_DISCOVERY_V5_ADDR_IPV6, DEFAULT_DISCOVERY_V5_LISTEN_CONFIG, DEFAULT_DISCOVERY_V5_PORT,
44    DEFAULT_SECONDS_BOOTSTRAP_LOOKUP_INTERVAL, DEFAULT_SECONDS_LOOKUP_INTERVAL,
45};
46pub use enr::enr_to_discv4_id;
47pub use error::Error;
48pub use filter::{FilterOutcome, MustNotIncludeKeys};
49pub use network_stack_id::NetworkStackId;
50
51use metrics::{DiscoveredPeersMetrics, Discv5Metrics};
52
53/// Max kbucket index is 255.
54///
55/// This is the max log2distance for 32 byte [`NodeId`] - 1. See <https://github.com/sigp/discv5/blob/e9e0d4f93ec35591832a9a8d937b4161127da87b/src/kbucket.rs#L586-L587>.
56pub const MAX_KBUCKET_INDEX: usize = 255;
57
58/// Default lowest kbucket index to attempt filling, in periodic look up query to populate kbuckets.
59///
60/// The peer at the 0th kbucket index is at log2distance 1 from the local node ID. See <https://github.com/sigp/discv5/blob/e9e0d4f93ec35591832a9a8d937b4161127da87b/src/kbucket.rs#L586-L587>.
61///
62/// Default is 0th index.
63pub const DEFAULT_MIN_TARGET_KBUCKET_INDEX: usize = 0;
64
65/// Transparent wrapper around [`discv5::Discv5`].
66#[derive(Clone)]
67pub struct Discv5 {
68    /// sigp/discv5 node.
69    discv5: Arc<discv5::Discv5>,
70    /// [`IpMode`] of the `RLPx` network.
71    rlpx_ip_mode: IpMode,
72    /// Key used in kv-pair to ID chain, e.g. 'opstack' or 'eth'.
73    fork_key: Option<&'static [u8]>,
74    /// Filter applied to a discovered peers before passing it up to app.
75    discovered_peer_filter: MustNotIncludeKeys,
76    /// Metrics for underlying [`discv5::Discv5`] node and filtered discovered peers.
77    metrics: Discv5Metrics,
78    /// Returns the _local_ [`NodeRecord`] this service was started with.
79    // Note: we must track this separately because the `discv5::Discv5` does not necessarily
80    // provide this via its [`local_enr`](discv5::Discv5::local_enr()). This is intended for
81    // obtaining the port this service was launched at
82    local_node_record: NodeRecord,
83}
84
85impl Discv5 {
86    ////////////////////////////////////////////////////////////////////////////////////////////////
87    // Minimal interface with `reth_network::discovery`
88    ////////////////////////////////////////////////////////////////////////////////////////////////
89
90    /// Adds the node to the table, if it is not already present.
91    pub fn add_node(&self, node_record: Enr<SecretKey>) -> Result<(), Error> {
92        let EnrCombinedKeyWrapper(enr) = node_record.try_into().map_err(Error::InvalidEnr)?;
93        self.discv5.add_enr(enr).map_err(Error::AddNodeFailed)
94    }
95
96    /// Sets the pair in the EIP-868 [`Enr`] of the node.
97    ///
98    /// If the key already exists, this will update it.
99    ///
100    /// CAUTION: The value **must** be rlp encoded
101    pub fn set_eip868_in_local_enr(&self, key: Vec<u8>, rlp: Bytes) {
102        let Ok(key_str) = std::str::from_utf8(&key) else {
103            error!(target: "net::discv5",
104                err="key not utf-8",
105                "failed to update local enr"
106            );
107            return
108        };
109        if let Err(err) = self.discv5.enr_insert(key_str, &rlp) {
110            error!(target: "net::discv5",
111                %err,
112                "failed to update local enr"
113            );
114        }
115    }
116
117    /// Sets the pair in the EIP-868 [`Enr`] of the node.
118    ///
119    /// If the key already exists, this will update it.
120    pub fn encode_and_set_eip868_in_local_enr(
121        &self,
122        key: Vec<u8>,
123        value: impl alloy_rlp::Encodable,
124    ) {
125        let mut buf = Vec::new();
126        value.encode(&mut buf);
127        self.set_eip868_in_local_enr(key, buf.into())
128    }
129
130    /// Adds the peer and id to the ban list.
131    ///
132    /// This will prevent any future inclusion in the table
133    pub fn ban(&self, peer_id: PeerId, ip: IpAddr) {
134        match discv4_id_to_discv5_id(peer_id) {
135            Ok(node_id) => {
136                self.discv5.ban_node(&node_id, None);
137                self.ban_ip(ip);
138            }
139            Err(err) => error!(target: "net::discv5",
140                %err,
141                "failed to ban peer"
142            ),
143        }
144    }
145
146    /// Adds the ip to the ban list.
147    ///
148    /// This will prevent any future inclusion in the table
149    pub fn ban_ip(&self, ip: IpAddr) {
150        self.discv5.ban_ip(ip, None);
151    }
152
153    /// Returns the [`NodeRecord`] of the local node.
154    ///
155    /// This includes the currently tracked external IP address of the node.
156    ///
157    /// Returns `None` if the local ENR does not contain the required fields.
158    pub fn node_record(&self) -> Option<NodeRecord> {
159        let enr: Enr<_> = EnrCombinedKeyWrapper(self.discv5.local_enr()).into();
160        enr.try_into().ok()
161    }
162
163    /// Returns the local [`Enr`] of the service.
164    pub fn local_enr(&self) -> Enr<CombinedKey> {
165        self.discv5.local_enr()
166    }
167
168    /// The port the discv5 service is listening on.
169    pub const fn local_port(&self) -> u16 {
170        self.local_node_record.udp_port
171    }
172
173    /// Spawns [`discv5::Discv5`]. Returns [`discv5::Discv5`] handle in reth compatible wrapper type
174    /// [`Discv5`], a receiver of [`discv5::Event`]s from the underlying node, and the local
175    /// [`Enr`](discv5::Enr) converted into the reth compatible [`NodeRecord`] type.
176    pub async fn start(
177        sk: &SecretKey,
178        discv5_config: Config,
179    ) -> Result<(Self, mpsc::Receiver<discv5::Event>), Error> {
180        //
181        // 1. make local enr from listen config
182        //
183        let (enr, local_node_record, fork_key, rlpx_ip_mode) = build_local_enr(sk, &discv5_config);
184
185        trace!(target: "net::discv5", ?enr, "local ENR");
186
187        //
188        // 2. start discv5
189        //
190        let Config {
191            discv5_config,
192            bootstrap_nodes,
193            lookup_interval,
194            bootstrap_lookup_interval,
195            bootstrap_lookup_countdown,
196            discovered_peer_filter,
197            ..
198        } = discv5_config;
199
200        let EnrCombinedKeyWrapper(enr) = enr.try_into().map_err(Error::InvalidEnr)?;
201        let sk = CombinedKey::secp256k1_from_bytes(&mut sk.to_secret_bytes()).unwrap();
202        let mut discv5 = match discv5::Discv5::new(enr, sk, discv5_config) {
203            Ok(discv5) => discv5,
204            Err(err) => return Err(Error::InitFailure(err)),
205        };
206        discv5.start().await.map_err(Error::Discv5Error)?;
207
208        // start discv5 updates stream
209        let discv5_updates = discv5.event_stream().await.map_err(Error::Discv5Error)?;
210
211        let discv5 = Arc::new(discv5);
212
213        //
214        // 3. add boot nodes
215        //
216        bootstrap(bootstrap_nodes, &discv5).await?;
217
218        let metrics = Discv5Metrics::default();
219
220        //
221        // 4. start bg kbuckets maintenance
222        //
223        spawn_populate_kbuckets_bg(
224            lookup_interval,
225            bootstrap_lookup_interval,
226            bootstrap_lookup_countdown,
227            metrics.clone(),
228            Arc::downgrade(&discv5),
229        );
230
231        Ok((
232            Self {
233                discv5,
234                rlpx_ip_mode,
235                fork_key,
236                discovered_peer_filter,
237                metrics,
238                local_node_record,
239            },
240            discv5_updates,
241        ))
242    }
243
244    /// Process an event from the underlying [`discv5::Discv5`] node.
245    pub fn on_discv5_update(&self, update: discv5::Event) -> Option<DiscoveredPeer> {
246        #[expect(clippy::match_same_arms)]
247        match update {
248            discv5::Event::SocketUpdated(_) | discv5::Event::TalkRequest(_) |
249            // `Discovered` not unique discovered peers
250            discv5::Event::Discovered(_) |
251            // Unrecognized frames are handled separately by the discovery layer
252            discv5::Event::UnrecognizedFrame(_) => None,
253            discv5::Event::NodeInserted { .. } => {
254
255                // node has been inserted into kbuckets
256
257                // `replaced` partly covers `reth_discv4::DiscoveryUpdate::Removed(_)`
258
259                self.metrics.discovered_peers.increment_kbucket_insertions(1);
260
261                None
262            }
263            discv5::Event::SessionEstablished(enr, remote_socket) => {
264                // this branch is semantically similar to branches of
265                // `reth_discv4::DiscoveryUpdate`: `DiscoveryUpdate::Added(_)` and
266                // `DiscoveryUpdate::DiscoveredAtCapacity(_)
267
268                // peer has been discovered as part of query, or, by incoming session (peer has
269                // discovered us)
270
271                self.metrics.discovered_peers.increment_established_sessions_raw(1);
272
273                self.on_discovered_peer(&enr, remote_socket)
274            }
275            discv5::Event::UnverifiableEnr {
276                enr,
277                socket,
278                node_id: _,
279            } => {
280                // this branch is semantically similar to branches of
281                // `reth_discv4::DiscoveryUpdate`: `DiscoveryUpdate::Added(_)` and
282                // `DiscoveryUpdate::DiscoveredAtCapacity(_)
283
284                // peer has been discovered as part of query, or, by an outgoing session (but peer
285                // is behind NAT and responds from a different socket)
286
287                // NOTE: `discv5::Discv5` won't initiate a session with any peer with an
288                // unverifiable node record, for example one that advertises a reserved LAN IP
289                // address on a WAN network. This is in order to prevent DoS attacks, where some
290                // malicious peers may advertise a victim's socket. We will still try and connect
291                // to them over RLPx, to be compatible with EL discv5 implementations that don't
292                // enforce this security measure.
293
294                trace!(target: "net::discv5",
295                    ?enr,
296                    %socket,
297                    "discovered unverifiable enr, source socket doesn't match socket advertised in ENR"
298                );
299
300                self.metrics.discovered_peers.increment_unverifiable_enrs_raw_total(1);
301
302                self.on_discovered_peer(&enr, socket)
303            }
304            _ => None
305        }
306    }
307
308    /// Processes a discovered peer. Returns `true` if peer is added to
309    pub fn on_discovered_peer(
310        &self,
311        enr: &discv5::Enr,
312        socket: SocketAddr,
313    ) -> Option<DiscoveredPeer> {
314        self.metrics.discovered_peers_advertised_networks.increment_once_by_network_type(enr);
315
316        let node_record = match self.try_into_reachable(enr, socket) {
317            Ok(enr_bc) => enr_bc,
318            Err(err) => {
319                trace!(target: "net::discv5",
320                    %err,
321                    ?enr,
322                    "discovered peer is unreachable"
323                );
324
325                self.metrics.discovered_peers.increment_established_sessions_unreachable_enr(1);
326
327                return None
328            }
329        };
330        if let FilterOutcome::Ignore { reason } = self.filter_discovered_peer(enr) {
331            trace!(target: "net::discv5",
332                ?enr,
333                reason,
334                "filtered out discovered peer"
335            );
336
337            self.metrics.discovered_peers.increment_established_sessions_filtered(1);
338
339            return None
340        }
341
342        let fork_id = self.get_fork_id(enr).ok();
343
344        trace!(target: "net::discv5",
345            ?fork_id,
346            ?enr,
347            "discovered peer"
348        );
349
350        Some(DiscoveredPeer { node_record, fork_id })
351    }
352
353    /// Tries to recover an unreachable [`Enr`](discv5::Enr) received via
354    /// [`discv5::Event::UnverifiableEnr`], into a [`NodeRecord`] usable by `RLPx`.
355    ///
356    /// NOTE: Fallback solution to be compatible with Geth which includes peers into the discv5
357    /// WAN topology which, for example, advertise in their ENR that localhost is their UDP IP
358    /// address. These peers are only discovered if they initiate a connection attempt, and we by
359    /// such means learn their reachable IP address. If we receive their ENR from any other peer
360    /// as part of a lookup query, we won't find a reachable IP address on which to dial them by
361    /// reading their ENR.
362    pub fn try_into_reachable(
363        &self,
364        enr: &discv5::Enr,
365        socket: SocketAddr,
366    ) -> Result<NodeRecord, Error> {
367        // Use the sender's address as received so discovery IP bans match source sockets.
368        let address = socket.ip();
369        let udp_port = socket.port();
370
371        let id = enr_to_discv4_id(enr).ok_or(Error::IncompatibleKeyType)?;
372
373        // The port must pair with the session socket's address, so select the
374        // ENR field by the socket's address family, not by the local node's
375        // RLPx listen family: an IPv4 session dials the `tcp` port, an IPv6
376        // session the `tcp6` port (falling back to `tcp`).
377        // Mapped IPv4 sessions use `tcp`, while the record retains the original socket address.
378        let tcp_port = (match address.to_canonical() {
379            IpAddr::V4(_) => enr.tcp4(),
380            IpAddr::V6(_) => enr.tcp6().or_else(|| enr.tcp4()),
381        })
382        .unwrap_or(
383            // tcp socket is missing from ENR for the session's address family.
384            //
385            // by default geth runs discv5 and discv4 behind the same udp port (the discv4 default
386            // port 30303), so rlpx has a chance of successfully dialing the peer on its discv5
387            // udp port if its running geth's p2p code.
388            udp_port,
389        );
390
391        Ok(NodeRecord { address, tcp_port, udp_port, id })
392    }
393
394    /// Applies filtering rules on an ENR. Returns [`Ok`](FilterOutcome::Ok) if peer should be
395    /// passed up to app, and [`Ignore`](FilterOutcome::Ignore) if peer should instead be dropped.
396    pub fn filter_discovered_peer(&self, enr: &discv5::Enr) -> FilterOutcome {
397        self.discovered_peer_filter.filter(enr)
398    }
399
400    /// Returns the [`ForkId`] of the given [`Enr`](discv5::Enr) w.r.t. the local node's network
401    /// stack, if field is set.
402    pub fn get_fork_id<K: EnrKey>(&self, enr: &Enr<K>) -> Result<ForkId, Error> {
403        let Some(key) = self.fork_key else { return Err(Error::NetworkStackIdNotConfigured) };
404        let fork_id = enr
405            .get_decodable::<EnrForkIdEntry>(key)
406            .or_else(|| {
407                (key != NetworkStackId::ETH)
408                    .then(|| {
409                        // Fallback: trying to get fork id from Enr with 'eth' as network stack id
410                        trace!(target: "net::discv5",
411                            key = %String::from_utf8_lossy(key),
412                            "Fork id not found for key, trying 'eth'..."
413                        );
414                        enr.get_decodable::<EnrForkIdEntry>(NetworkStackId::ETH)
415                    })
416                    .flatten()
417            })
418            .ok_or({
419                trace!(target: "net::discv5", "Fork id not found for 'eth' network stack id");
420                Error::ForkMissing(key)
421            })?
422            .map(Into::into)?;
423
424        Ok(fork_id)
425    }
426
427    ////////////////////////////////////////////////////////////////////////////////////////////////
428    // Interface with sigp/discv5
429    ////////////////////////////////////////////////////////////////////////////////////////////////
430
431    /// Exposes API of [`discv5::Discv5`].
432    pub fn with_discv5<F, R>(&self, f: F) -> R
433    where
434        F: FnOnce(&discv5::Discv5) -> R,
435    {
436        f(&self.discv5)
437    }
438
439    ////////////////////////////////////////////////////////////////////////////////////////////////
440    // Complementary
441    ////////////////////////////////////////////////////////////////////////////////////////////////
442
443    /// Returns the `RLPx` [`IpMode`] of the local node.
444    pub const fn ip_mode(&self) -> IpMode {
445        self.rlpx_ip_mode
446    }
447
448    /// Returns the key to use to identify the [`ForkId`] kv-pair on the [`Enr`](discv5::Enr).
449    pub const fn fork_key(&self) -> Option<&[u8]> {
450        self.fork_key
451    }
452}
453
454impl fmt::Debug for Discv5 {
455    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
456        "{ .. }".fmt(f)
457    }
458}
459
460/// Result of successfully processing a peer discovered by [`discv5::Discv5`].
461#[derive(Debug)]
462pub struct DiscoveredPeer {
463    /// A discovery v4 backwards compatible ENR.
464    pub node_record: NodeRecord,
465    /// [`ForkId`] extracted from ENR w.r.t. configured
466    pub fork_id: Option<ForkId>,
467}
468
469/// Builds the local ENR with the supplied key.
470pub fn build_local_enr(
471    sk: &SecretKey,
472    config: &Config,
473) -> (Enr<SecretKey>, NodeRecord, Option<&'static [u8]>, IpMode) {
474    let mut builder = Enr::builder();
475
476    let Config {
477        discv5_config,
478        fork,
479        tcp_socket,
480        advertised_ipv4,
481        advertised_ipv6,
482        other_enr_kv_pairs,
483        ..
484    } = config;
485
486    let socket = {
487        let v4 = crate::config::ipv4(&discv5_config.listen_config);
488        let v6 = crate::config::ipv6(&discv5_config.listen_config);
489
490        // Prefer an explicit advertised IP for ENR IP fields. Listen sockets still supply UDP
491        // ports and determine which address-family fields are emitted.
492        if let Some(addr) = v4 {
493            if let Some(ip) = advertised_ipv4 {
494                builder.ip4(*ip);
495            } else if *addr.ip() != Ipv4Addr::UNSPECIFIED {
496                builder.ip4(*addr.ip());
497            }
498            builder.udp4(addr.port());
499        }
500        if let Some(addr) = v6 {
501            if let Some(ip) = advertised_ipv6 {
502                builder.ip6(*ip);
503            } else if *addr.ip() != Ipv6Addr::UNSPECIFIED {
504                builder.ip6(*addr.ip());
505            }
506            builder.udp6(addr.port());
507        }
508        // Advertise tcp4 when v4 is configured, else tcp6.
509        if v4.is_some() {
510            builder.tcp4(tcp_socket.port());
511        } else if v6.is_some() {
512            builder.tcp6(tcp_socket.port());
513        }
514
515        // Prefer v6 when both are configured
516        v6.map(SocketAddr::V6)
517            .or_else(|| v4.map(SocketAddr::V4))
518            .unwrap_or_else(|| SocketAddr::from((Ipv4Addr::UNSPECIFIED, 0)))
519    };
520
521    let rlpx_ip_mode = if tcp_socket.is_ipv4() { IpMode::Ip4 } else { IpMode::Ip6 };
522
523    // identifies which network node is on
524    let network_stack_id = fork.as_ref().map(|(network_stack_id, fork_value)| {
525        builder.add_value_rlp(network_stack_id, alloy_rlp::encode(fork_value).into());
526        *network_stack_id
527    });
528
529    // add other data
530    for (key, value) in other_enr_kv_pairs {
531        builder.add_value_rlp(key, value.clone().into());
532    }
533
534    // enr v4 not to get confused with discv4, independent versioning enr and
535    // discovery
536    let enr = builder.build(sk).expect("should build enr v4");
537
538    // backwards compatible enr
539    let bc_enr = NodeRecord::from_secret_key(socket, sk);
540
541    (enr, bc_enr, network_stack_id, rlpx_ip_mode)
542}
543
544/// Bootstraps underlying [`discv5::Discv5`] node with configured peers.
545///
546/// Boot nodes given as an ENR are added to the kbuckets right away. Boot nodes given as an enode
547/// need their ENR requested over the wire first, which is driven in the background: an unreachable
548/// boot node only answers after the discv5 request timeout, and waiting for that would stall node
549/// startup.
550///
551/// Stays `async` because spawning those requests requires a tokio runtime context.
552pub async fn bootstrap(
553    bootstrap_nodes: HashSet<BootNode>,
554    discv5: &Arc<discv5::Discv5>,
555) -> Result<(), Error> {
556    trace!(target: "net::discv5",
557        ?bootstrap_nodes,
558        "adding bootstrap nodes .."
559    );
560
561    let mut enr_requests = vec![];
562    for node in bootstrap_nodes {
563        match node {
564            BootNode::Enr(node) => {
565                if let Err(err) = discv5.add_enr(node) {
566                    return Err(Error::AddNodeFailed(err))
567                }
568            }
569            BootNode::Enode(enode) => {
570                let discv5 = discv5.clone();
571                enr_requests.push(async move {
572                    if let Err(err) = discv5.request_enr(enode.to_string()).await {
573                        debug!(target: "net::discv5",
574                            ?enode,
575                            %err,
576                            "failed adding boot node"
577                        );
578                    }
579                })
580            }
581        }
582    }
583
584    // If a session is established, the ENR is added straight away to discv5 kbuckets
585    if !enr_requests.is_empty() {
586        task::spawn(join_all(enr_requests));
587    }
588
589    Ok(())
590}
591
592/// Backgrounds regular look up queries, in order to keep kbuckets populated.
593pub fn spawn_populate_kbuckets_bg(
594    lookup_interval: u64,
595    bootstrap_lookup_interval: u64,
596    bootstrap_lookup_countdown: u64,
597    metrics: Discv5Metrics,
598    discv5: std::sync::Weak<discv5::Discv5>,
599) {
600    let lookup_interval = Duration::from_secs(lookup_interval);
601    let metrics = metrics.discovered_peers;
602    let mut kbucket_index = MAX_KBUCKET_INDEX;
603    let pulse_lookup_interval = Duration::from_secs(bootstrap_lookup_interval);
604    task::spawn(async move {
605        let Some(discv5_handle) = discv5.upgrade() else {
606            return;
607        };
608        let local_node_id = discv5_handle.local_enr().node_id();
609        drop(discv5_handle);
610
611        // make many fast lookup queries at bootstrap, trying to fill kbuckets at furthest
612        // log2distance from local node
613        for i in (0..bootstrap_lookup_countdown).rev() {
614            let target = NodeId::random();
615
616            trace!(target: "net::discv5",
617                %target,
618                bootstrap_boost_runs_countdown=i,
619                lookup_interval=format!("{:#?}", pulse_lookup_interval),
620                "starting bootstrap boost lookup query"
621            );
622
623            {
624                let Some(discv5_handle) = discv5.upgrade() else {
625                    return;
626                };
627                lookup(target, &discv5_handle, &metrics).await;
628            }
629
630            tokio::time::sleep(pulse_lookup_interval).await;
631        }
632
633        // initiate regular lookups to populate kbuckets
634        loop {
635            // make sure node is connected to each subtree in the network by target
636            // selection (ref kademlia)
637            let target = get_lookup_target(kbucket_index, local_node_id);
638
639            trace!(target: "net::discv5",
640                %target,
641                lookup_interval=format!("{:#?}", lookup_interval),
642                "starting periodic lookup query"
643            );
644
645            {
646                let Some(discv5_handle) = discv5.upgrade() else {
647                    return;
648                };
649                lookup(target, &discv5_handle, &metrics).await;
650            }
651
652            if kbucket_index > DEFAULT_MIN_TARGET_KBUCKET_INDEX {
653                // try to populate bucket one step closer
654                kbucket_index -= 1
655            } else {
656                // start over with bucket furthest away
657                kbucket_index = MAX_KBUCKET_INDEX
658            }
659
660            tokio::time::sleep(lookup_interval).await;
661        }
662    });
663}
664
665/// Gets the next lookup target, based on which bucket is currently being targeted.
666pub fn get_lookup_target(kbucket_index: usize, local_node_id: NodeId) -> NodeId {
667    // init target
668    let mut target = local_node_id.raw();
669
670    // make sure target has a 'log2distance'-long suffix that differs from local node id
671    let bit_offset = MAX_KBUCKET_INDEX.saturating_sub(kbucket_index);
672    let (byte, bit) = (bit_offset / 8, bit_offset % 8);
673    // Flip the target bit.
674    target[byte] ^= 1 << (7 - bit);
675
676    // Randomize the bits after the target.
677    let mut rng = rand::rng();
678    // Randomize remaining bits in the byte we modified.
679    if bit < 7 {
680        // Compute the mask of the bits that need to be randomized.
681        let bits_to_randomize = 0xff >> (bit + 1);
682        // Clear.
683        target[byte] &= !bits_to_randomize;
684        // Randomize.
685        target[byte] |= rng.random::<u8>() & bits_to_randomize;
686    }
687    // Randomize remaining bytes.
688    rng.fill_bytes(&mut target[byte + 1..]);
689
690    target.into()
691}
692
693/// Runs a [`discv5::Discv5`] lookup query.
694pub async fn lookup(target: NodeId, discv5: &discv5::Discv5, metrics: &DiscoveredPeersMetrics) {
695    metrics.set_total_sessions(discv5::Discv5::metrics().active_sessions);
696    metrics.set_total_kbucket_peers(
697        discv5.with_kbuckets(|kbuckets| kbuckets.read().iter_ref().count()),
698    );
699
700    match discv5.find_node(target).await {
701        Err(err) => trace!(target: "net::discv5",
702            %err,
703            "lookup query failed"
704        ),
705        Ok(peers) => trace!(target: "net::discv5",
706            target=format!("{:#?}", target),
707            peers_count=peers.len(),
708            peers=format!("[{:#}]", peers.iter()
709                .map(|enr| enr.node_id()
710            ).format(", ")),
711            "peers returned by lookup query"
712        ),
713    }
714
715    // `Discv5::connected_peers` can be subset of sessions, not all peers make it
716    // into kbuckets, e.g. incoming sessions from peers with
717    // unreachable enrs
718    debug!(target: "net::discv5",
719        connected_peers=discv5.connected_peers(),
720        "connected peers in routing table"
721    );
722}
723
724#[cfg(test)]
725mod test {
726    #![allow(deprecated)]
727    use super::*;
728    use ::enr::{CombinedKey, EnrKey};
729    use discv5::ListenConfig;
730    use rand::rng;
731    use reth_chainspec::MAINNET;
732    use std::{
733        net::UdpSocket,
734        time::{Duration, Instant},
735    };
736    use tracing::trace;
737
738    fn discv5_noop() -> Discv5 {
739        let sk = CombinedKey::generate_secp256k1();
740        Discv5 {
741            discv5: Arc::new(
742                discv5::Discv5::new(
743                    Enr::empty(&sk).unwrap(),
744                    sk,
745                    discv5::ConfigBuilder::new(DEFAULT_DISCOVERY_V5_LISTEN_CONFIG).build(),
746                )
747                .unwrap(),
748            ),
749            rlpx_ip_mode: IpMode::Ip4,
750            fork_key: None,
751            discovered_peer_filter: MustNotIncludeKeys::default(),
752            metrics: Discv5Metrics::default(),
753            local_node_record: NodeRecord::new(
754                (Ipv4Addr::LOCALHOST, 30303).into(),
755                PeerId::random(),
756            ),
757        }
758    }
759
760    async fn start_discovery_node(udp_port_discv5: u16) -> (Discv5, mpsc::Receiver<discv5::Event>) {
761        let secret_key = SecretKey::new(&mut rng());
762
763        let discv5_addr: SocketAddr = format!("127.0.0.1:{udp_port_discv5}").parse().unwrap();
764        let rlpx_addr: SocketAddr = "127.0.0.1:30303".parse().unwrap();
765
766        let discv5_listen_config = ListenConfig::from(discv5_addr);
767        let discv5_config = Config::builder(rlpx_addr)
768            .discv5_config(discv5::ConfigBuilder::new(discv5_listen_config).build())
769            .build();
770
771        Discv5::start(&secret_key, discv5_config).await.expect("should build discv5")
772    }
773
774    async fn start_discovery_node_with_key(
775        secret_key: &SecretKey,
776        udp_port_discv5: u16,
777    ) -> Result<(Discv5, mpsc::Receiver<discv5::Event>), Error> {
778        let discv5_addr: SocketAddr = format!("127.0.0.1:{udp_port_discv5}").parse().unwrap();
779        let rlpx_addr: SocketAddr = "127.0.0.1:30303".parse().unwrap();
780
781        let discv5_listen_config = ListenConfig::from(discv5_addr);
782        let discv5_config = Config::builder(rlpx_addr)
783            .discv5_config(discv5::ConfigBuilder::new(discv5_listen_config).build())
784            .build();
785
786        Discv5::start(secret_key, discv5_config).await
787    }
788
789    fn unused_udp_port() -> u16 {
790        UdpSocket::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port()
791    }
792
793    async fn wait_for_udp_port_release(port: u16, timeout: Duration) {
794        let deadline = Instant::now() + timeout;
795
796        loop {
797            match UdpSocket::bind(("127.0.0.1", port)) {
798                Ok(socket) => {
799                    drop(socket);
800                    return;
801                }
802                Err(err) if Instant::now() < deadline => {
803                    trace!(target: "net::discv5::test", %port, %err, "waiting for discv5 port release");
804                    tokio::time::sleep(Duration::from_millis(10)).await;
805                }
806                Err(err) => panic!("discv5 did not release port {port} before timeout: {err}"),
807            }
808        }
809    }
810
811    #[tokio::test(flavor = "multi_thread")]
812    async fn discv5_releases_port_on_drop() {
813        reth_tracing::init_test_tracing();
814
815        let secret_key = SecretKey::new(&mut rng());
816        let port = unused_udp_port();
817
818        let (node, updates) =
819            start_discovery_node_with_key(&secret_key, port).await.expect("should start discv5");
820        drop(updates);
821        drop(node);
822
823        wait_for_udp_port_release(port, Duration::from_secs(1)).await;
824
825        let restarted = start_discovery_node_with_key(&secret_key, port).await;
826        assert!(restarted.is_ok(), "discv5 failed to rebind dropped port: {restarted:?}");
827    }
828
829    #[tokio::test(flavor = "multi_thread")]
830    async fn discv5() {
831        reth_tracing::init_test_tracing();
832
833        // rig test
834
835        // rig node_1
836        let (node_1, mut stream_1) = start_discovery_node(30344).await;
837        let node_1_enr = node_1.with_discv5(|discv5| discv5.local_enr());
838
839        // rig node_2
840        let (node_2, mut stream_2) = start_discovery_node(30355).await;
841        let node_2_enr = node_2.with_discv5(|discv5| discv5.local_enr());
842
843        trace!(target: "net::discv5::test",
844            node_1_node_id=format!("{:#}", node_1_enr.node_id()),
845            node_2_node_id=format!("{:#}", node_2_enr.node_id()),
846            "started nodes"
847        );
848
849        // test
850
851        // add node_2 to discovery handle of node_1 (should add node to discv5 kbuckets)
852        let node_2_enr_reth_compatible_ty: Enr<SecretKey> =
853            EnrCombinedKeyWrapper(node_2_enr.clone()).into();
854        node_1.add_node(node_2_enr_reth_compatible_ty).unwrap();
855
856        // verify node_2 is in KBuckets of node_1:discv5
857        assert!(
858            node_1.with_discv5(|discv5| discv5.table_entries_id().contains(&node_2_enr.node_id()))
859        );
860
861        // manually trigger connection from node_1 to node_2
862        node_1.with_discv5(|discv5| discv5.send_ping(node_2_enr.clone())).await.unwrap();
863
864        // verify node_1:discv5 is connected to node_2:discv5 and vv
865        let event_1_v5 = stream_1.recv().await.unwrap();
866
867        assert!(matches!(
868            event_1_v5,
869            discv5::Event::SessionEstablished(node, socket) if node == node_2_enr && socket == node_2_enr.udp4_socket().unwrap().into()
870        ));
871
872        // verify node_1 is in KBuckets of node_2:discv5
873        let event_2_v5 = stream_2.recv().await.unwrap();
874        assert!(matches!(
875            event_2_v5,
876            discv5::Event::NodeInserted { node_id, replaced } if node_id == node_1_enr.node_id() && replaced.is_none()
877        ));
878    }
879
880    #[test]
881    fn discovered_enr_disc_socket_missing() {
882        reth_tracing::init_test_tracing();
883
884        // rig test
885        const REMOTE_RLPX_PORT: u16 = 30303;
886        let remote_socket = "104.28.44.25:9000".parse().unwrap();
887        let remote_key = CombinedKey::generate_secp256k1();
888        let remote_enr = Enr::builder().tcp4(REMOTE_RLPX_PORT).build(&remote_key).unwrap();
889
890        let discv5 = discv5_noop();
891
892        // test
893        let filtered_peer = discv5.on_discovered_peer(&remote_enr, remote_socket);
894
895        assert_eq!(
896            NodeRecord {
897                address: remote_socket.ip(),
898                udp_port: remote_socket.port(),
899                tcp_port: REMOTE_RLPX_PORT,
900                id: enr_to_discv4_id(&remote_enr).unwrap(),
901            },
902            filtered_peer.unwrap().node_record
903        )
904    }
905
906    #[test]
907    fn reachable_tcp_port_follows_session_address_family() {
908        // Regression test for https://github.com/paradigmxyz/reth/issues/27734:
909        // the TCP port paired with the session socket's IP must come from the
910        // ENR field of the *session's* address family, not the local node's
911        // RLPx listen family.
912        const TCP4_PORT: u16 = 30304;
913        const TCP6_PORT: u16 = 30306;
914        let remote_key = CombinedKey::generate_secp256k1();
915        let remote_enr = Enr::builder().tcp4(TCP4_PORT).tcp6(TCP6_PORT).build(&remote_key).unwrap();
916
917        let v4_socket: SocketAddr = "127.0.0.1:30305".parse().unwrap();
918        let v6_socket: SocketAddr = "[::1]:30307".parse().unwrap();
919
920        for local_mode in [IpMode::Ip4, IpMode::Ip6] {
921            let mut discv5 = discv5_noop();
922            discv5.rlpx_ip_mode = local_mode;
923
924            let record = discv5.try_into_reachable(&remote_enr, v4_socket).unwrap();
925            assert_eq!(
926                record.tcp_port, TCP4_PORT,
927                "v4 session with local mode {local_mode:?} must dial the tcp4 port"
928            );
929            assert_eq!(record.address, v4_socket.ip());
930            assert_eq!(record.udp_port, v4_socket.port());
931
932            let record = discv5.try_into_reachable(&remote_enr, v6_socket).unwrap();
933            assert_eq!(
934                record.tcp_port, TCP6_PORT,
935                "v6 session with local mode {local_mode:?} must dial the tcp6 port"
936            );
937            assert_eq!(record.address, v6_socket.ip());
938            assert_eq!(record.udp_port, v6_socket.port());
939        }
940    }
941
942    #[test]
943    fn reachable_ipv4_mapped_session_uses_tcp4() {
944        const TCP4_PORT: u16 = 30304;
945        const TCP6_PORT: u16 = 30306;
946        let remote_key = CombinedKey::generate_secp256k1();
947        let remote_enr = Enr::builder().tcp4(TCP4_PORT).tcp6(TCP6_PORT).build(&remote_key).unwrap();
948        let mapped_socket: SocketAddr = "[::ffff:104.28.44.25]:9000".parse().unwrap();
949
950        for local_mode in [IpMode::Ip4, IpMode::Ip6] {
951            let mut discv5 = discv5_noop();
952            discv5.rlpx_ip_mode = local_mode;
953
954            let record = discv5.try_into_reachable(&remote_enr, mapped_socket).unwrap();
955            assert_eq!(record.tcp_port, TCP4_PORT);
956            assert_eq!(record.address, mapped_socket.ip());
957            assert_eq!(record.udp_port, mapped_socket.port());
958
959            let peer = discv5.on_discovered_peer(&remote_enr, mapped_socket).unwrap();
960            assert_eq!(peer.node_record, record);
961        }
962    }
963
964    #[test]
965    fn reachable_tcp_port_v6_session_falls_back_to_tcp4() {
966        // A v6 session whose ENR carries only a `tcp` (v4) port uses that port
967        // rather than the session's UDP port.
968        const TCP4_PORT: u16 = 30304;
969        let remote_key = CombinedKey::generate_secp256k1();
970        let remote_enr = Enr::builder().tcp4(TCP4_PORT).build(&remote_key).unwrap();
971
972        let v6_socket: SocketAddr = "[::1]:30307".parse().unwrap();
973
974        let mut discv5 = discv5_noop();
975        discv5.rlpx_ip_mode = IpMode::Ip6;
976        let record = discv5.try_into_reachable(&remote_enr, v6_socket).unwrap();
977        assert_eq!(record.tcp_port, TCP4_PORT);
978    }
979
980    #[test]
981    fn reachable_tcp_port_missing_falls_back_to_udp_port() {
982        // No TCP port in the ENR at all: keep the long-standing fallback to
983        // the session's UDP port, for either session family.
984        let remote_key = CombinedKey::generate_secp256k1();
985        let remote_enr = Enr::builder().build(&remote_key).unwrap();
986
987        let discv5 = discv5_noop();
988        let v4_socket: SocketAddr = "127.0.0.1:30305".parse().unwrap();
989        assert_eq!(discv5.try_into_reachable(&remote_enr, v4_socket).unwrap().tcp_port, 30305);
990        let v6_socket: SocketAddr = "[::1]:30307".parse().unwrap();
991        assert_eq!(discv5.try_into_reachable(&remote_enr, v6_socket).unwrap().tcp_port, 30307);
992    }
993
994    // Copied from sigp/discv5 with slight modification (U256 type)
995    // <https://github.com/sigp/discv5/blob/master/src/kbucket/key.rs#L89-L101>
996    #[expect(unreachable_pub)]
997    #[expect(unused)]
998    mod sigp {
999        use alloy_primitives::U256;
1000        use enr::NodeId;
1001
1002        /// A `Key` is a cryptographic hash, identifying both the nodes participating in
1003        /// the Kademlia DHT, as well as records stored in the DHT.
1004        ///
1005        /// The set of all `Key`s defines the Kademlia keyspace.
1006        ///
1007        /// `Key`s have an XOR metric as defined in the Kademlia paper, i.e. the bitwise XOR of
1008        /// the hash digests, interpreted as an integer. See [`Key::distance`].
1009        ///
1010        /// A `Key` preserves the preimage of type `T` of the hash function. See [`Key::preimage`].
1011        #[derive(Clone, Debug)]
1012        pub struct Key<T> {
1013            preimage: T,
1014            hash: [u8; 32],
1015        }
1016
1017        impl<T> PartialEq for Key<T> {
1018            fn eq(&self, other: &Self) -> bool {
1019                self.hash == other.hash
1020            }
1021        }
1022
1023        impl<T> Eq for Key<T> {}
1024
1025        impl<TPeerId> AsRef<Self> for Key<TPeerId> {
1026            fn as_ref(&self) -> &Self {
1027                self
1028            }
1029        }
1030
1031        impl<T> Key<T> {
1032            /// Construct a new `Key` by providing the raw 32 byte hash.
1033            pub const fn new_raw(preimage: T, hash: [u8; 32]) -> Self {
1034                Self { preimage, hash }
1035            }
1036
1037            /// Borrows the preimage of the key.
1038            pub const fn preimage(&self) -> &T {
1039                &self.preimage
1040            }
1041
1042            /// Converts the key into its preimage.
1043            pub fn into_preimage(self) -> T {
1044                self.preimage
1045            }
1046
1047            /// Computes the distance of the keys according to the XOR metric.
1048            pub fn distance<U>(&self, other: &Key<U>) -> Distance {
1049                let a = U256::from_be_slice(self.hash.as_slice());
1050                let b = U256::from_be_slice(other.hash.as_slice());
1051                Distance(a ^ b)
1052            }
1053
1054            // Used in the FINDNODE query outside of the k-bucket implementation.
1055            /// Computes the integer log-2 distance between two keys, assuming a 256-bit
1056            /// key. The output returns None if the key's are identical. The range is 1-256.
1057            pub fn log2_distance<U>(&self, other: &Key<U>) -> Option<u64> {
1058                let xor_dist = self.distance(other);
1059                let log_dist = (256 - xor_dist.0.leading_zeros() as u64);
1060                (log_dist != 0).then_some(log_dist)
1061            }
1062        }
1063
1064        impl From<NodeId> for Key<NodeId> {
1065            fn from(node_id: NodeId) -> Self {
1066                Self { preimage: node_id, hash: node_id.raw() }
1067            }
1068        }
1069
1070        /// A distance between two `Key`s.
1071        #[derive(Copy, Clone, PartialEq, Eq, Default, PartialOrd, Ord, Debug)]
1072        pub struct Distance(pub(super) U256);
1073    }
1074
1075    #[test]
1076    fn select_lookup_target() {
1077        for bucket_index in 0..=MAX_KBUCKET_INDEX {
1078            let sk = CombinedKey::generate_secp256k1();
1079            let local_node_id = NodeId::from(sk.public());
1080            let target = get_lookup_target(bucket_index, local_node_id);
1081
1082            let local_node_id = sigp::Key::from(local_node_id);
1083            let target = sigp::Key::from(target);
1084
1085            assert_eq!(local_node_id.log2_distance(&target), Some(bucket_index as u64 + 1));
1086        }
1087    }
1088
1089    #[test]
1090    fn build_enr_from_config() {
1091        const TCP_PORT: u16 = 30303;
1092        let fork_id = MAINNET.latest_fork_id();
1093
1094        let config = Config::builder((Ipv4Addr::UNSPECIFIED, TCP_PORT).into())
1095            .fork(NetworkStackId::ETH, fork_id)
1096            .build();
1097
1098        let sk = SecretKey::new(&mut rng());
1099        let (enr, _, _, _) = build_local_enr(&sk, &config);
1100
1101        let decoded_fork_id = enr
1102            .get_decodable::<EnrForkIdEntry>(NetworkStackId::ETH)
1103            .unwrap()
1104            .map(Into::into)
1105            .unwrap();
1106
1107        assert_eq!(fork_id, decoded_fork_id);
1108        assert_eq!(TCP_PORT, enr.tcp4().unwrap()); // listen config is defaulting to ip mode ipv4
1109    }
1110
1111    #[test]
1112    fn get_fork_id_with_different_network_stack_ids() {
1113        let fork_id = MAINNET.latest_fork_id();
1114        let sk = SecretKey::new(&mut rng());
1115
1116        // Test 1: ENR with OPEL fork ID, Discv5 configured for OPEL
1117        let enr_with_opel = Enr::builder()
1118            .add_value_rlp(
1119                NetworkStackId::OPEL,
1120                alloy_rlp::encode(EnrForkIdEntry::from(fork_id)).into(),
1121            )
1122            .build(&sk)
1123            .unwrap();
1124
1125        let mut discv5 = discv5_noop();
1126        discv5.fork_key = Some(NetworkStackId::OPEL);
1127        assert_eq!(discv5.get_fork_id(&enr_with_opel).unwrap(), fork_id);
1128
1129        // Test 2: ENR with ETH fork ID, Discv5 configured for OPEL (fallback to ETH)
1130        let enr_with_eth = Enr::builder()
1131            .add_value_rlp(
1132                NetworkStackId::ETH,
1133                alloy_rlp::encode(EnrForkIdEntry::from(fork_id)).into(),
1134            )
1135            .build(&sk)
1136            .unwrap();
1137
1138        discv5.fork_key = Some(NetworkStackId::OPEL);
1139        assert_eq!(discv5.get_fork_id(&enr_with_eth).unwrap(), fork_id);
1140
1141        // Test 3: ENR with neither OPEL nor ETH fork ID (should fail)
1142        let enr_without_network_stack_id = Enr::empty(&sk).unwrap();
1143        discv5.fork_key = Some(NetworkStackId::OPEL);
1144        assert!(matches!(
1145            discv5.get_fork_id(&enr_without_network_stack_id),
1146            Err(Error::ForkMissing(NetworkStackId::OPEL))
1147        ));
1148
1149        // Test 4: discv5 without network stack id configured (should fail)
1150        let discv5 = discv5_noop();
1151        assert!(matches!(
1152            discv5.get_fork_id(&enr_without_network_stack_id),
1153            Err(Error::NetworkStackIdNotConfigured)
1154        ));
1155    }
1156}