Skip to main content

decdn_node/dht/
bootstrap.rs

1//! DHT bootstrap (ADR 022 §Bootstrap).
2//!
3//! On node startup the routing table is empty. ADR 022 specifies:
4//!
5//!  1. Build initial routing table from the on-chain registry peer list
6//!     (same source as the peer table bootstrap in ADR 019).
7//!  2. Issue `FindNode(self.node_id)` to those initial peers —
8//!     standard Kademlia self-lookup populates k-buckets with peers
9//!     close to the local id.
10//!
11//! Step 1 here reads from a [`StakerSet`]. The daemon supplies the
12//! chain-backed [`ChainStakerSet`](crate::dht::chain_staker_set::ChainStakerSet),
13//! projected from `CapacityBond`; `ConfigStakerSet` is the in-memory
14//! implementation of the same trait that tests seed directly.
15//!
16//! Step 2 runs in parallel against up to a small fan-out of the seed
17//! set, with a generous timeout. Failures are non-fatal — a node that
18//! can't reach any seed at boot still has a populated routing table
19//! from step 1; subsequent inbound traffic refreshes it via the
20//! handler's per-request `note_peer_seen` path, and the bucket-refresh
21//! task ([`super::bucket_refresh`]) eventually fills the K-closest
22//! buckets via `FindNode` probes of random per-bucket targets.
23
24use std::sync::{Arc, Mutex};
25
26use iroh::{Endpoint, EndpointAddr, PublicKey};
27use rand::seq::SliceRandom;
28
29use crate::dht::chain_projection::with_lock;
30use crate::dht::client;
31use crate::dht::routing::{NodeId, RoutingTable};
32use crate::dht::staker_set::StakerSet;
33
34/// Cap on the number of seed peers we probe in parallel during the
35/// initial self-lookup. The full staker set may be hundreds of nodes;
36/// fanning out to all of them at boot would generate a synchronised
37/// connection storm against the responding subset. The first few
38/// successful responses are enough to seed the k-buckets — iterative
39/// lookup (and the bucket-refresh task) will reach the rest at the
40/// rate ADR 022 §Routing Table prescribes (one refresh per bucket
41/// per hour).
42pub const BOOTSTRAP_FANOUT: usize = 8;
43
44/// Summary of a bootstrap run, returned for logs + tests.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub struct BootstrapOutcome {
47    /// Number of distinct seed `NodeId`s the staker set provided.
48    pub seeds_seen: usize,
49    /// Number of seeds inserted into the routing table.
50    pub seeds_inserted: usize,
51    /// Number of seeds we successfully `FindNode`'d (any response).
52    pub find_node_ok: usize,
53    /// Number of seeds whose `FindNode` failed (timeout, transport, etc).
54    pub find_node_err: usize,
55    /// Number of additional peers learned from `closer_nodes` responses
56    /// and inserted into the routing table.
57    pub closer_peers_inserted: usize,
58}
59
60/// Seed the routing table and run the initial self-lookup. Returns a
61/// summary so the runtime can log a single high-signal startup line.
62///
63/// Bootstrap is *best-effort* — a node that fails every seed connection
64/// still has a populated routing table from the staker-set seed step.
65/// The runtime SHOULD NOT abort startup on bootstrap failure.
66//
67// Linear "seed → fanout → join" sequence; splitting would scatter the
68// step-1/step-2 ADR-022 mapping across helpers and lose the visible
69// ordering that the seed table is populated BEFORE we go to network.
70#[allow(clippy::cognitive_complexity)]
71pub async fn bootstrap(
72    endpoint: &Endpoint,
73    self_id: PublicKey,
74    routing: &Arc<Mutex<RoutingTable>>,
75    staker_set: &Arc<dyn StakerSet>,
76) -> BootstrapOutcome {
77    let self_node_id = NodeId::from_bytes(*self_id.as_bytes());
78    let seeds = staker_set.active_nodes();
79    let mut outcome = BootstrapOutcome {
80        seeds_seen: seeds.len(),
81        seeds_inserted: 0,
82        find_node_ok: 0,
83        find_node_err: 0,
84        closer_peers_inserted: 0,
85    };
86
87    // Step 1: seed the routing table directly. This is the part that
88    // works even when every seed is unreachable: the table now knows
89    // *who* the network's active stakers are, even if it can't talk to
90    // them yet. Per ADR 022 §Bootstrap "no separate bootstrap window
91    // during which content discovery is unavailable" — inbound DHT
92    // traffic (probes, FindNode queries from other nodes) immediately
93    // routes through this seed set.
94    outcome.seeds_inserted = with_lock(routing, "dht routing table", |table| {
95        let mut inserted = 0usize;
96        for &peer in &seeds {
97            if peer == self_node_id {
98                // Self-id is rejected by `RoutingTable::insert` anyway,
99                // but skip it here so the seeds_inserted count is honest.
100                continue;
101            }
102            if table.insert(peer) {
103                inserted += 1;
104            }
105        }
106        inserted
107    });
108
109    // Step 2: parallel self-lookup against a fan-out of the seed set.
110    // The `StakerSet` trait contract (line 30 of staker_set.rs) says:
111    // "Order is unspecified; the caller MUST randomize before any
112    // selection step that an attacker could influence (e.g. bootstrap
113    // target picking)." A future chain-backed `StakerSet` may return
114    // entries in on-chain insertion order, which an attacker who
115    // submits transactions can influence — so we shuffle here to keep
116    // the picks unpredictable from outside the node.
117    let mut seeds_shuffled: Vec<NodeId> =
118        seeds.into_iter().filter(|n| *n != self_node_id).collect();
119    seeds_shuffled.shuffle(&mut rand::rng());
120    let fanout: Vec<NodeId> = seeds_shuffled.into_iter().take(BOOTSTRAP_FANOUT).collect();
121    let mut handles = Vec::with_capacity(fanout.len());
122    for peer in fanout {
123        let endpoint_cloned = endpoint.clone();
124        let target_id = peer;
125        handles.push(tokio::spawn(async move {
126            let target_pk = match PublicKey::from_bytes(target_id.as_bytes()) {
127                Ok(k) => k,
128                Err(e) => {
129                    // A staker-set entry that doesn't decode as an
130                    // Ed25519 public key is operator config rot; log
131                    // and skip rather than aborting bootstrap.
132                    tracing::warn!(
133                        peer = %target_id,
134                        error = %e,
135                        "dht bootstrap: staker_set entry is not a valid Ed25519 public key"
136                    );
137                    return (target_id, Err(anyhow::anyhow!("invalid pubkey: {e}")));
138                }
139            };
140            let addr = EndpointAddr::new(target_pk);
141            let result =
142                client::find_node(&endpoint_cloned, addr, self_node_id, self_node_id).await;
143            (target_id, result)
144        }));
145    }
146
147    for handle in handles {
148        match handle.await {
149            Ok((_, Ok(resp))) => {
150                outcome.find_node_ok += 1;
151                // Insert every peer the responder named — these are
152                // the seed's K-closest to self.node_id, exactly what
153                // we want in our k-buckets. Response-learned ids inserted
154                // as probe candidates by design (not authenticated peers);
155                // the `AuthenticatedNodeId` boundary covers the handler's
156                // recency refresh, not this discovery path.
157                outcome.closer_peers_inserted += with_lock(routing, "dht routing table", |table| {
158                    let mut inserted = 0usize;
159                    for peer in resp.closer_nodes.into_inner() {
160                        if peer == self_node_id {
161                            continue;
162                        }
163                        if table.insert(peer) {
164                            inserted += 1;
165                        }
166                    }
167                    inserted
168                });
169            }
170            Ok((peer, Err(e))) => {
171                outcome.find_node_err += 1;
172                tracing::debug!(
173                    peer = %peer,
174                    error = %e,
175                    "dht bootstrap: FindNode failed against seed"
176                );
177            }
178            Err(e) => {
179                outcome.find_node_err += 1;
180                tracing::warn!(error = %e, "dht bootstrap: spawned task panicked");
181            }
182        }
183    }
184
185    outcome
186}
187
188#[cfg(test)]
189#[allow(
190    clippy::unwrap_used,
191    clippy::expect_used,
192    clippy::panic,
193    clippy::indexing_slicing
194)]
195mod tests;