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;