decdn_client/discovery.rs
1//! Client-side node discovery (#936): read the active node set from
2//! `CapacityBond.getRegisteredNodes` so a client can pick a node instead of being
3//! handed an explicit `--node-id`/`--addr`/`--provider-address`. Read from
4//! `fetch::discover_provider` and from the discovery branch of
5//! `bundle_pull::bundle_pull`; the ranking half is also used by
6//! `fetch::probe_and_rank`. `decdn probe` deliberately still requires an
7//! explicit target.
8//!
9//! This is the **read + select** half, plus the peer store that backs it up.
10//! Dialing the chosen node uses its iroh `NodeId` on a discovery-enabled
11//! endpoint (`presets::N0` / configured `[network.discovery]`), and the
12//! registry's `NodeInfo.multiaddrs` ride along as iroh direct-address hints
13//! ([`NodeCandidate::dial_addrs`](crate::discovery::NodeCandidate::dial_addrs),
14//! [`with_dial_addrs`](crate::discovery::with_dial_addrs)): a reachable node then
15//! connects without a relay, while discovery and the relay path stay as the
16//! fallback for a node behind NAT (ADR 001 § Node Discovery). The hints are
17//! additive — a stale or malformed address loses the path race but never fails
18//! a dial.
19//!
20//! `bootstrap_nodes` is the entry point: it wraps the registry read in ADR
21//! 012's retry schedule and refreshes the [`crate::PeerStore`] identity
22//! records under the client data dir on success, falling back to the store's
23//! surviving identities when the registry cannot be read. The store — one
24//! JSON file per peer under `<data_dir>/peers` — is the only filesystem state
25//! this module owns.
26
27use std::collections::HashSet;
28use std::future::Future;
29use std::path::Path;
30use std::time::{Duration, SystemTime, UNIX_EPOCH};
31
32use alloy::primitives::{Address, Bytes, U256};
33use alloy::providers::ProviderBuilder;
34use anyhow::Context;
35use decdn_common::redact::{sanitize_err_chain, sanitize_error_sources};
36use decdn_incentive::capacity_bond::CapacityBond;
37use decdn_protocol::{Coverage, Region};
38use iroh::PublicKey;
39use rand::seq::SliceRandom;
40use serde::{Deserialize, Serialize};
41
42use crate::provider::is_permanent_contract_error;
43
44/// Page size for the paginated `getRegisteredNodes` read (ADR 019 § Step 3.3's
45/// worked-example limit). At `PoC` scale (tens of nodes) one page suffices; the
46/// loop preserves the pattern for production scale.
47const PAGE_SIZE: u64 = 100;
48
49/// Backoff before each retry of a failed `getRegisteredNodes` page call — ADR 012
50/// § Bootstrap step 3: "retry 3× exponential backoff (1 s, 5 s, 30 s)". The
51/// length of the table is the retry count, so a fully-failing page costs
52/// 1 + 5 + 30 = 36 s across four attempts.
53///
54/// The budget is **aggregate across the whole read**, not per page:
55/// [`paginate_with_retry`] carries one attempt counter across pagination, so a
56/// multi-page read still costs at most 36 s of sleeping in total rather than
57/// 36 s × N (#1349). The ADR's "retry 3×" is read as a property of the read,
58/// which is the unit the caller waits on — a per-page reset made the worst case
59/// scale with a number the caller cannot see or bound.
60///
61/// The wait is silent from the caller's point of view — nothing streams
62/// progress out of this loop — so it is time `decdn fetch` appears to hang.
63/// `--timeout-ms` bounds it from the outside.
64///
65/// Deliberately not `decdn_config_types::RetryPolicy`: that is a *doubling*
66/// policy with jitter, scoped to node-side origin fetches — its defaults are
67/// 100/200/400 ms, and even seeded with a 1 s first step it would give
68/// 1/2/4 s, not 1/5/30. Keeping the schedule literal also keeps an origin-side
69/// config vocabulary out of the client's discovery path. (Nothing new would be
70/// *linked*: `decdn-config-types` is already in this crate's graph via
71/// `decdn-common`. The objection is layering, not the build graph.)
72const REGISTRY_RETRY_BACKOFF: [Duration; 3] = [
73 Duration::from_secs(1),
74 Duration::from_secs(5),
75 Duration::from_secs(30),
76];
77
78/// The outermost context on a failed bootstrap — the wording pinned by ADR 012
79/// § Bootstrap step 4. `decdn`'s error boundary renders `{err:#}`
80/// (`sanitize_err_chain`), so the user sees this sentence followed by the
81/// underlying cause chain.
82///
83/// Applied on *any* registry read failure with no usable cache, which is wider
84/// than the ADR's "all retries exhausted": a malformed `rpc_url` fails in
85/// `active_nodes` before the retry loop is ever entered.
86pub const BOOTSTRAP_UNREACHABLE: &str =
87 "Cannot reach bootstrap sources. Check network connectivity and RPC endpoint configuration.";
88
89/// A node the client may fetch from, distilled from a registry `NodeInfo`.
90/// Dialing is by `node_id`; the registry-published `multiaddrs` ride along as
91/// iroh direct-address hints so a reachable peer connects without a relay (see
92/// module docs and [`NodeCandidate::dial_addrs`]).
93///
94/// Serializable so the resolved set can be projected into [`crate::PeerStore`]
95/// identity records and reloaded from the store when the registry is
96/// unreachable (ADR 012 § Bootstrap step 4).
97///
98/// That encoding is a private implementation detail of the peer store, **not**
99/// a stable format: the JSON keys are the field names, and the bytes are
100/// codec-dependent (iroh renders `PublicKey` as z-base-32 under a
101/// human-readable codec and as raw bytes otherwise).
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103pub struct NodeCandidate {
104 /// iroh endpoint id — dialed via discovery, never needs an explicit addr.
105 pub node_id: PublicKey,
106 /// The node's Ethereum address — the `--provider-address` discovery derives
107 /// (the channel is opened/reused against it and the `slash_sig` verified
108 /// against it).
109 pub eth_address: Address,
110 /// The node's self-attested region (ADR 030), used for locality-aware
111 /// selection. `None` when the node registered without one, or with a code
112 /// outside [`Region`]'s accepted set.
113 ///
114 /// An unrecognized code parses to `None` and sorts into the "rest" bucket
115 /// rather than dropping the candidate: `CapacityBond` permits any string up
116 /// to 16 bytes and ADR 030 § Cross-ADR Impact explicitly declines to
117 /// tighten it, so rejecting a node over a purely advisory field would be
118 /// stricter than the source of truth and would silently shrink the
119 /// fetchable set. Parsing at this boundary is what makes the derived `Eq`
120 /// above correct and keeps an unbounded operator-submitted string off the
121 /// peer-store read path (#1348).
122 pub region_hint: Option<Region>,
123 /// The node's registry-published, packed `multiaddrs` field
124 /// (`pack_multiaddrs` framing), decoded on demand by [`Self::dial_addrs`]
125 /// into iroh direct-address hints. Self-attested and additive: it can only
126 /// add a direct dial path, never gate one. Carried through the peer store,
127 /// so a candidate rebuilt on a registry outage keeps the last-seen addresses
128 /// and can dial a reachable peer directly, without iroh discovery.
129 pub multiaddrs: Bytes,
130}
131
132impl NodeCandidate {
133 /// Registry-published direct dial addresses, decoded leniently from the
134 /// packed on-chain `multiaddrs` field for use as iroh direct-address hints
135 /// (ADR 001 § Node Discovery). Empty when the node published none or the
136 /// field is malformed. A returned address is only ever one dial path among
137 /// discovery and the relay fallback, so a stale entry loses the path race
138 /// but never fails the connection.
139 #[must_use]
140 pub fn dial_addrs(&self) -> Vec<std::net::SocketAddr> {
141 decdn_incentive::node_register::decode_dial_addrs(&self.multiaddrs)
142 }
143}
144
145/// Attach a candidate's registry-published direct addresses to `target` as iroh
146/// direct-address hints (ADR 001 § Node Discovery), so a reachable peer connects
147/// without a relay. Additive: an empty or malformed `multiaddrs` field adds
148/// nothing and leaves iroh discovery and the relay fallback untouched, so this
149/// can only speed or enable a dial, never fail one.
150#[must_use]
151pub fn with_dial_addrs(mut target: iroh::EndpointAddr, cand: &NodeCandidate) -> iroh::EndpointAddr {
152 for sock in cand.dial_addrs() {
153 target = target.with_ip_addr(sock);
154 }
155 target
156}
157
158/// Distill a registry `NodeInfo` into a [`NodeCandidate`], or `None` if it is
159/// not currently active or its `nodeId` is not a valid ed25519 key.
160///
161/// `is_active` is the on-chain `isActive` predicate that `getRegisteredNodes`
162/// returns per entry (registered AND bond ≥ minBond AND no unbonding AND not
163/// ejected) — NOT the raw `NodeInfo.active` registration flag, which stays
164/// `true` for an operator mid-unbonding. Filtering on the strict predicate drops
165/// stale-active nodes up front rather than paying a probe round-trip to a node
166/// that is on its way out. The downstream probe-and-rank step is still the final
167/// arbiter of who can actually serve.
168fn candidate_from(info: &CapacityBond::NodeInfo, is_active: bool) -> Option<NodeCandidate> {
169 if !is_active {
170 return None;
171 }
172 let node_id = PublicKey::from_bytes(&info.nodeId.0).ok()?;
173 Some(NodeCandidate {
174 node_id,
175 eth_address: info.ethAddress,
176 // Normalize once, here — see `NodeCandidate::region_hint`. An
177 // unparseable hint costs the node its locality bonus, not its place in
178 // the candidate set.
179 region_hint: Region::parse(&info.regionHint),
180 // Carried raw and decoded only at dial time (`dial_addrs`); malformed
181 // bytes cost a direct-dial hint, never the candidate.
182 multiaddrs: info.multiaddrs.clone(),
183 })
184}
185
186/// Drive `fetch_page` across the paginated `getRegisteredNodes` read, retrying on
187/// the ADR 012 § Bootstrap step 3 schedule ([`REGISTRY_RETRY_BACKOFF`]) and
188/// distilling every entry through [`candidate_from`].
189///
190/// The retry budget is **aggregate**, spent across the whole read rather than
191/// reset per page (#1349): `attempt` is declared outside the pagination loop, so
192/// a page that succeeds after two retries leaves one for everything after it.
193/// That is what bounds the worst case at the schedule's own 36 s instead of
194/// 36 s × page-count — a figure the caller cannot see, since the page count
195/// depends on how many nodes are registered.
196///
197/// A successful page deliberately does NOT refund the budget. Refunding would
198/// restore the unbounded case exactly: a registry that fails every *other* call
199/// would alternate success and retry forever.
200///
201/// Split out from [`active_nodes`] so the retry and pagination control flow is
202/// drivable from a test without a live RPC — the schedule alone is a `const`
203/// that proves nothing about the loop that reads it.
204///
205/// # Errors
206///
207/// Fails when the read's retries are exhausted, or immediately when the failure
208/// is [`is_permanent_contract_error`].
209async fn paginate_with_retry<F, Fut>(fetch_page: F) -> anyhow::Result<Vec<NodeCandidate>>
210where
211 F: Fn(u64) -> Fut,
212 Fut: Future<Output = Result<(Vec<CapacityBond::NodeInfo>, Vec<bool>), alloy::contract::Error>>,
213{
214 let mut out = Vec::new();
215 let mut offset = 0u64;
216 // Outside the pagination loop: one budget for the whole read.
217 let mut attempt = 0usize;
218 loop {
219 let (page, active) = loop {
220 match fetch_page(offset).await {
221 Ok(page) => break page,
222 // A deterministic failure short-circuits the schedule. The two
223 // likeliest first-run mistakes both land here: a typo'd
224 // `blockchain.capacity_bond_address` (the call returns `0x`,
225 // decoded as `ZeroData`) and an expired or wrong RPC API key
226 // (HTTP 401/403 with a plain body; a JSON-RPC error body is
227 // judged by its code instead). Retrying either one would sit silently for
228 // 36 s and then blame network connectivity.
229 Err(e) if is_permanent_contract_error(&e) => {
230 return Err(e).with_context(|| {
231 format!(
232 "CapacityBond.getRegisteredNodes(offset={offset}) failed and will not \
233 succeed on retry — check that blockchain.capacity_bond_address \
234 holds the registry contract and that rpc_url points at the same \
235 chain and accepts this key"
236 )
237 });
238 }
239 Err(e) => {
240 let Some(backoff) = REGISTRY_RETRY_BACKOFF.get(attempt).copied() else {
241 return Err(e).with_context(|| {
242 format!(
243 "CapacityBond.getRegisteredNodes(offset={offset}) failed after {} \
244 retries across the whole registry read",
245 REGISTRY_RETRY_BACKOFF.len()
246 )
247 });
248 };
249 // Sanitized, not `%e`: an alloy transport error's Display
250 // forwards reqwest's ` for url (<url>)` tail, and an
251 // `rpc_url` commonly carries an API key in its path/query
252 // (issue #954). The `main()` boundary only sanitizes the
253 // error chain, never a `tracing` field. The helper walks
254 // `source()`, where the transport class (timeout, refused,
255 // TLS) sits beneath the top-level Display.
256 tracing::warn!(
257 offset,
258 attempt,
259 backoff_ms = backoff.as_millis(),
260 error = %sanitize_error_sources(&e),
261 "CapacityBond.getRegisteredNodes page failed; retrying"
262 );
263 tokio::time::sleep(backoff).await;
264 attempt = attempt.saturating_add(1);
265 }
266 }
267 };
268 // `page` and `active` are equal-length by construction (the contract
269 // fills both in one loop). A divergence is an ABI/decoder fault; the
270 // `zip` below would silently truncate and drop candidates, so fail loudly.
271 anyhow::ensure!(
272 page.len() == active.len(),
273 "CapacityBond.getRegisteredNodes(offset={offset}) returned mismatched \
274 page/active lengths ({} vs {}) — ABI or decoder fault",
275 page.len(),
276 active.len()
277 );
278 let page_len = page.len() as u64;
279 // `active[i]` is the on-chain `isActive(page[i])`, index-aligned with the
280 // page. Zip so the strict predicate — not the raw `NodeInfo.active` flag —
281 // gates each candidate.
282 out.extend(
283 page.iter()
284 .zip(active.iter())
285 .filter_map(|(info, &is_active)| candidate_from(info, is_active)),
286 );
287 // A short page is the last page (Kademlia-style termination).
288 if page_len < PAGE_SIZE {
289 break;
290 }
291 offset = offset.saturating_add(PAGE_SIZE);
292 }
293 Ok(out)
294}
295
296/// Read the active node set from `CapacityBond.getRegisteredNodes` at
297/// `capacity_bond_addr` over `rpc_url` (a read-only HTTP provider — no signer
298/// needed for a view call). Paginated; inactive / undecodable entries are
299/// skipped.
300///
301/// Uncached: this is the un-cached half, and it can sleep for the full retry
302/// schedule. For the fetch/bundle-pull bootstrap path, reach it only through
303/// [`bootstrap_nodes`] (the ADR 012 entry point) — calling this directly would
304/// silently opt out of the peer-cache fallback. It is also, deliberately, the
305/// direct read `decdn node lookup` (#1481) uses: that command is an unpaid,
306/// one-shot listing with no client data dir to cache into, so the peer-cache
307/// layer above would have nothing to read from or write to.
308///
309/// # Errors
310///
311/// Fails if `rpc_url` is not a valid URL (before any retry is attempted) or a
312/// `getRegisteredNodes` page call fails permanently or past its retries.
313#[doc(hidden)]
314pub async fn active_nodes(
315 rpc_url: &str,
316 capacity_bond_addr: Address,
317) -> anyhow::Result<Vec<NodeCandidate>> {
318 let provider = ProviderBuilder::new().connect_http(rpc_url.parse().with_context(|| {
319 // An rpc_url secret commonly lives in the path/query (Infura/Alchemy
320 // keys), which userinfo redaction wouldn't scrub — so hide the value
321 // entirely (the policy `config validate` follows of never echoing
322 // rpc_url), like the sibling parse sites in `chain_ctx` / `setup`
323 // (issue #954).
324 format!(
325 "rpc_url is not a valid URL (<redacted>, {} chars)",
326 rpc_url.len()
327 )
328 })?);
329 let registry = CapacityBond::new(capacity_bond_addr, provider);
330
331 paginate_with_retry(|offset| {
332 let registry = ®istry;
333 async move {
334 registry
335 .getRegisteredNodes(U256::from(offset), U256::from(PAGE_SIZE))
336 .call()
337 .await
338 .map(|resp| (resp.page, resp.active))
339 }
340 })
341 .await
342}
343
344/// Wall-clock seconds since the Unix epoch, or 0 if the clock predates it.
345fn now_secs() -> u64 {
346 SystemTime::now()
347 .duration_since(UNIX_EPOCH)
348 .map(|d| d.as_secs())
349 .unwrap_or_default()
350}
351
352/// Turn a registry read outcome into the bootstrap peer set (ADR 012
353/// § Bootstrap steps 4 and 7 — step 6, building the peer table, is
354/// `select_candidates` in the callers): on a live read, refresh every
355/// candidate's identity into the [`crate::PeerStore`] under `data_dir` and
356/// prune/cap the store, or on failure fall back to the store's
357/// non-prunable identities, or — with the store empty too — surface
358/// [`BOOTSTRAP_UNREACHABLE`] with the registry failure as its cause.
359///
360/// An empty successful read is returned as-is but does **not** touch the
361/// store: an emptied registry is not a reason to discard the last known-good
362/// identities.
363fn resolve_bootstrap(
364 registry: anyhow::Result<Vec<NodeCandidate>>,
365 data_dir: &Path,
366) -> anyhow::Result<Bootstrap> {
367 let store = crate::PeerStore::open(data_dir);
368 let cfg = crate::StoreConfig::default();
369 let now = now_secs();
370 let err = match registry {
371 Ok(peers) => {
372 // Best-effort identity refresh; a write failure must not fail a
373 // fetch that already succeeded — the store is a fallback, not the
374 // source of truth for a live read. An empty read touches nothing:
375 // an emptied registry is not a reason to prune or discard the
376 // last known-good identities. The first write failure is still
377 // carried on the return value so a degraded store is visible to
378 // the caller instead of failing silently.
379 let mut store_warning = None;
380 if !peers.is_empty() {
381 for cand in &peers {
382 if let Err(e) = store.upsert_identity(cand, now) {
383 store_warning.get_or_insert_with(|| e.to_string());
384 }
385 }
386 if let Err(e) = store.prune_and_cap(now, &cfg) {
387 store_warning.get_or_insert_with(|| e.to_string());
388 }
389 }
390 return Ok(Bootstrap::Live {
391 peers,
392 store_warning,
393 });
394 }
395 Err(e) => e,
396 };
397 let records = store.load_all();
398 // The staleness of the fallback identities, computed from the store
399 // records before they are projected down to `NodeCandidate`s below —
400 // `as_candidate` drops `identity_seen_at_secs`.
401 let oldest_identity_secs = records
402 .iter()
403 .filter(|r| !r.identity_prunable(now, &cfg))
404 .map(|r| r.identity_seen_at_secs)
405 .min();
406 let cached: Vec<NodeCandidate> = records
407 .into_iter()
408 .filter(|r| !r.identity_prunable(now, &cfg))
409 .map(|r| r.as_candidate())
410 .collect();
411 let Some(oldest_identity_secs) = oldest_identity_secs else {
412 return Err(err.context(BOOTSTRAP_UNREACHABLE));
413 };
414 Ok(Bootstrap::Cached {
415 peers: cached,
416 // Sanitized `{err:#}`, not `%err`: plain Display on an
417 // `anyhow::Error` renders only the outermost context and drops the
418 // reason the registry read actually failed.
419 registry_error: sanitize_err_chain(&err),
420 oldest_identity_secs,
421 })
422}
423
424/// Where a bootstrap peer set came from.
425///
426/// The provenance is returned rather than logged from here so the caller
427/// decides how to surface "the registry is down and this list may be months
428/// old" — exactly what a user must not miss. Callers log [`Self::warning`] and
429/// then take [`Self::into_peers`].
430#[derive(Debug)]
431#[non_exhaustive]
432pub enum Bootstrap {
433 /// Read live from the on-chain registry.
434 Live {
435 /// The peers the registry returned.
436 peers: Vec<NodeCandidate>,
437 /// The first peer-store write error hit while refreshing identities
438 /// or pruning, if any. The fetch itself never fails on this — the
439 /// store is best-effort — but a persistent write failure degrades
440 /// the next run's fallback and selection, so it is surfaced here
441 /// rather than swallowed.
442 store_warning: Option<String>,
443 },
444 /// The registry could not be read; these peers came from the peer store.
445 Cached {
446 /// The peers the peer store held.
447 peers: Vec<NodeCandidate>,
448 /// Why the registry read failed, sanitized for display.
449 registry_error: String,
450 /// Seconds since the Unix epoch when the stalest identity among
451 /// `peers` was last confirmed against the registry — the minimum
452 /// `identity_seen_at_secs` across the fallback set, so it bounds how
453 /// old the least-fresh record in the set may be.
454 oldest_identity_secs: u64,
455 },
456}
457
458/// Render a duration in seconds as a coarse, human-readable age: seconds
459/// under a minute, minutes under an hour, hours under a day, else days.
460fn format_age_secs(age_secs: u64) -> String {
461 if age_secs < 60 {
462 format!("{age_secs}s")
463 } else if age_secs < 3_600 {
464 format!("{}m", age_secs / 60)
465 } else if age_secs < 86_400 {
466 format!("{}h", age_secs / 3_600)
467 } else {
468 format!("{}d", age_secs / 86_400)
469 }
470}
471
472impl Bootstrap {
473 /// A degraded-bootstrap message for the caller to log at `WARN`, or `None`
474 /// when the bootstrap was wholly healthy. Carries no severity prefix — the
475 /// caller's log level supplies it.
476 #[must_use]
477 pub fn warning(&self) -> Option<String> {
478 match self {
479 Self::Live { store_warning, .. } => store_warning.as_ref().map(|e| {
480 format!("peer store write failed: {e} (selection may be degraded next run)")
481 }),
482 Self::Cached {
483 peers,
484 registry_error,
485 oldest_identity_secs,
486 } => {
487 let age = format_age_secs(now_secs().saturating_sub(*oldest_identity_secs));
488 Some(format!(
489 "could not reach the node registry ({registry_error}); using {} \
490 previously known node(s) from the local peer store, identity up to {age} \
491 old. These nodes may have been deactivated or slashed since.",
492 peers.len()
493 ))
494 }
495 }
496 }
497
498 /// The resolved peer set, whatever its provenance.
499 #[must_use]
500 pub fn into_peers(self) -> Vec<NodeCandidate> {
501 match self {
502 Self::Live { peers, .. } | Self::Cached { peers, .. } => peers,
503 }
504 }
505}
506
507/// Bootstrap the client's peer set (ADR 012 § Bootstrap): read the active node
508/// set from `CapacityBond` with the ADR's retry schedule, refreshing the
509/// [`crate::PeerStore`] under `data_dir` on success and falling back to the
510/// store's surviving identities when the registry cannot be read.
511///
512/// `data_dir` is the resolved client data dir, so an explicit `--data-dir`
513/// moves the peer store with the rest of the client's state.
514///
515/// Callers must log [`Bootstrap::warning`] — the degraded paths are invisible
516/// otherwise.
517///
518/// # Errors
519///
520/// Fails with [`BOOTSTRAP_UNREACHABLE`] when the registry read fails and the
521/// peer store holds no usable identity.
522pub async fn bootstrap_nodes(
523 rpc_url: &str,
524 capacity_bond_addr: Address,
525 data_dir: &Path,
526 registry_cap: Duration,
527) -> anyhow::Result<Bootstrap> {
528 // The deadline bounds the REGISTRY READ ONLY, not the whole bootstrap
529 // (#1349). Wrapping `bootstrap_nodes` from outside would drop
530 // `resolve_bootstrap`'s result along with it, and that is where the ADR
531 // 012 § Bootstrap step 4 peer-store fallback lives — so a client with a
532 // perfectly good peer store and a flaky RPC would get a hard failure
533 // instead of a degraded-but-working fetch. Timing out is just another way
534 // for the registry read to fail, so it is fed in as one and the existing
535 // `Bootstrap::Cached` arm handles it, warning and all.
536 let registry =
537 match tokio::time::timeout(registry_cap, active_nodes(rpc_url, capacity_bond_addr)).await {
538 Ok(result) => result,
539 Err(_) => Err(anyhow::anyhow!(
540 "the CapacityBond registry read did not finish within {} ms (--timeout-ms); \
541 the ADR 012 retry schedule alone can take {} s",
542 registry_cap.as_millis(),
543 REGISTRY_RETRY_BACKOFF.iter().sum::<Duration>().as_secs(),
544 )),
545 };
546 // Off the runtime: a live read writes and fsyncs one peer record per
547 // registered node, and a caller's lanes share this task (#2211). A panic
548 // in the store pass fails the bootstrap, and discards a registry read
549 // that succeeded along with it.
550 let data_dir = data_dir.to_path_buf();
551 tokio::task::spawn_blocking(move || resolve_bootstrap(registry, &data_dir))
552 .await
553 .map_err(|e| {
554 anyhow::Error::new(e).context("the peer-store bootstrap task panicked or was cancelled")
555 })?
556}
557
558/// Candidates probed before ranking (decision 3): shuffle, order region-first,
559/// take K, then probe those K for liveness + blob-holding. At `PoC` scale a
560/// small K keeps the probe fan-out cheap while still giving the ranker a choice.
561#[doc(hidden)]
562pub const SELECT_K: usize = 5;
563
564/// Pick `candidates` for probing (decision 3): shuffle, then same-region
565/// candidates first (equality on `region_hint` — locality, not geo distance),
566/// then the rest, capped at `k`. When `client_region` is `None`, empty, or not
567/// an accepted region code the region-first ordering is skipped and the result
568/// is a uniform random sample of `k`.
569///
570/// The shuffle is the load-bearing step. The input order is never one the
571/// ranker chose. A live registry read arrives in the `CapacityBond` registry
572/// array order — push on `registerNode`, swap-and-pop on deregister — so an
573/// operator that registers or leaves decides who takes the slot it frees,
574/// and every client reading the same block sees the same order. A
575/// peer-store fallback or the store's identity-fresh set arrives in
576/// whatever order the filesystem returns the `<node-id>.json` entries in,
577/// stable across runs for that client. Truncating any of them as-is hands
578/// out the probe slots by registration timing or filename. The shuffle is the same requester-side
579/// defense the node's DHT lookup applies (ADR 022 § `FIND_VALUE` Flow,
580/// *Lookup integrity*). It runs on the client, the party it protects, so no
581/// node can patch it out.
582///
583/// Both sides are [`Region`]s, so this is a plain equality: the trim +
584/// case-fold runs at the parse boundary, not on every comparison (#1348).
585#[must_use]
586pub fn select_candidates(
587 mut candidates: Vec<NodeCandidate>,
588 client_region: Option<&str>,
589 k: usize,
590) -> Vec<NodeCandidate> {
591 // Shuffle FIRST: the sort below is stable, so a shuffle before it leaves
592 // region-first in place and randomizes the order within each group. A
593 // shuffle after the sort would undo the region preference; a shuffle
594 // after the truncate would only permute the deterministic prefix.
595 candidates.shuffle(&mut rand::rng());
596 // `client_region` stays a `&str` and is parsed HERE, not assumed valid.
597 // `decdn-common`'s `normalize_region` validates the node daemon's config
598 // region, but the client path does not go through it: `decdn fetch`'s
599 // `--region` / `[identity] region` reach `ResolvedChain::region` as a raw
600 // `String`. So this is the validating boundary for the client, and an
601 // unrecognized value means "no locality information" — the ordering is
602 // skipped rather than applied against a value that means nothing.
603 if let Some(region) = client_region.and_then(Region::parse) {
604 // Stable sort by a bool key: same-region (`false`) before the rest (`true`).
605 candidates.sort_by_key(|c| c.region_hint != Some(region));
606 }
607 candidates.truncate(k);
608 candidates
609}
610
611/// Pre-filter `candidates` by an optional region allowlist, then sample via
612/// [`select_candidates`].
613///
614/// A candidate whose `region_hint` is `Some(r)` with `r` not in `allow` is
615/// dropped; a candidate with `region_hint == None` is kept. An empty `allow`
616/// is a no-op. Ranking is unchanged (region is a self-attested hint, never a
617/// ranking key) — this only narrows which candidates are eligible to be
618/// probed/discovered at all, independent of the peer store.
619#[must_use]
620#[doc(hidden)]
621pub fn select_candidates_filtered(
622 candidates: Vec<NodeCandidate>,
623 client_region: Option<&str>,
624 k: usize,
625 allow: &[Region],
626) -> Vec<NodeCandidate> {
627 let filtered = if allow.is_empty() {
628 candidates
629 } else {
630 candidates
631 .into_iter()
632 .filter(|c| match c.region_hint {
633 Some(r) => allow.contains(&r),
634 None => true,
635 })
636 .collect()
637 };
638 select_candidates(filtered, client_region, k)
639}
640
641/// Pick up to `max_sources` candidates from an already-ranked `ordered` list,
642/// admitting at most ONE per `eth_address` (operator), without disturbing rank
643/// order. Used by the multi-source scheduler's engagement gate to pick the
644/// source set for a parallel fetch — `select_candidates`/`rank` pick a single
645/// best node, this picks a *set*.
646///
647/// One greedy pass, walking `ordered` in rank order: admit a candidate the first
648/// time its `eth_address` is seen. A candidate is skipped only when it adds no
649/// new operator, so two equally-diversifying candidates are never reordered —
650/// the pass only skips ahead over already-represented operators.
651///
652/// # Why the set never repeats an operator
653///
654/// A voucher is scoped to one `(signer, provider)` lane, and `provider` IS the
655/// operator's `eth_address`. Two admitted nodes of one operator therefore become
656/// two payment lanes on ONE watermark: their concurrent voucher streams regress
657/// each other, and their persisted watermarks collide under one `LaneKey`, last
658/// write winning at the LOWER value. Filling spare slots with a repeat operator
659/// buys parallelism the payment model cannot express, so the set shrinks
660/// instead: with one operator present this returns ONE candidate, and the
661/// caller's two-holder engagement gate then declines multi-source entirely.
662///
663/// Region is not a separate tiebreaking pass: the pass already walks candidates
664/// in rank order, so among several unseen operators the one ranked first (which
665/// is also, incidentally, the first with a given region) is the one admitted —
666/// there is nothing left for a region check to change without reordering by
667/// something other than rank, which the contract forbids.
668///
669/// The result holds `min(max_sources, distinct operators in ordered)`
670/// candidates, in rank order.
671#[must_use]
672pub fn admit_sources(ordered: Vec<NodeCandidate>, max_sources: usize) -> Vec<NodeCandidate> {
673 if max_sources == 0 {
674 return Vec::new();
675 }
676 let mut seen_operators = HashSet::with_capacity(max_sources);
677 let mut out = Vec::with_capacity(max_sources.min(ordered.len()));
678 for candidate in ordered {
679 if out.len() >= max_sources {
680 break;
681 }
682 if seen_operators.insert(candidate.eth_address) {
683 out.push(candidate);
684 }
685 }
686 out
687}
688
689/// A probed candidate that holds the blob, with its measured RTT.
690#[derive(Debug, Clone)]
691pub struct Probed {
692 /// The node that answered the probe with `has_blob = true`.
693 pub candidate: NodeCandidate,
694 /// Round-trip time measured by the probe, in milliseconds.
695 pub rtt_ms: f64,
696 /// The blob size the node reported, when it knew it. Unsigned and
697 /// outside `slash_sig` (ADR 005 §`cdn/probe/v1`), so it is a hint: a fetch
698 /// takes it as its first size claim and grows or shrinks it as verified
699 /// bytes land ([`crate::acquire`]).
700 pub total_bytes: Option<u64>,
701 /// Which discovery blocks this holder answered `has_blob:true` for
702 /// (`decdn_protocol::coverage`), taken from the probe's `ProbeResponseExt`.
703 /// Unsigned and outside `slash_sig`: a hint for the scheduler's segment
704 /// assignment, never a commitment. `has_blob:true` means
705 /// "will serve at least one block", so this may be a proper subset of the
706 /// blob rather than the whole thing — a **partial holder** is admitted here
707 /// exactly like a full one; nothing in this module treats the two
708 /// differently. The requester-side `has_blob`/`coverage.is_empty()`
709 /// consistency check (ADR 013 §Tier 1, `ProbeResponseExt::consistent_with`)
710 /// runs before a `Probed` is ever constructed, so a malformed pairing never
711 /// reaches this field.
712 pub coverage: Coverage,
713}
714
715/// A bonded non-holder the client has a measured RTT for, and could route a
716/// warming request through (ADR 037 § Candidate pool). Distinct from
717/// [`Probed`], which is a confirmed blob-holder.
718#[derive(Debug, Clone)]
719#[doc(hidden)]
720pub struct WarmingCandidate {
721 /// The candidate proxy's iroh id.
722 pub node_id: PublicKey,
723 /// Its Ethereum address — the `--provider-address` a warming channel opens
724 /// and the `slash_sig` is verified against.
725 pub eth_address: Address,
726 /// Measured round-trip time in milliseconds, from the **live `cdn/probe/v1`
727 /// probe issued for this request**.
728 ///
729 /// The persisted peer knowledge base (ADR 037 § RTT source,
730 /// `decdn_client::peer_store`) does not track which peers hold which
731 /// blobs, only identity, latency, and price — it cannot tell a non-holder
732 /// from a holder for this hash. So this warming candidate pool still comes
733 /// only from nodes this request actually probed (`has_blob: false`
734 /// responses), not from the full peer store minus holders.
735 pub rtt_ms: f64,
736 /// The candidate's registry-published, packed `multiaddrs` field, carried
737 /// from its [`NodeCandidate`] so a warming lane dials the proxy directly
738 /// (see [`NodeCandidate::multiaddrs`]). Never read by ranking.
739 pub multiaddrs: Bytes,
740}
741
742/// ADR 037 § Client selection policy: the ordered list of nearby non-holders to
743/// route a warming request through, nearest-RTT first, or empty when proxy
744/// warming does not engage.
745///
746/// Proxy warming engages **only** when the best holder's RTT exceeds
747/// `rtt_threshold_ms` (the holders are all distant) **and** a candidate beats
748/// the best holder's RTT by at least `margin_ms`. Ranking is measured RTT only
749/// — self-attested region is never consulted, so a region-spoofing node simply
750/// exhibits a high measured RTT and is never selected. An empty result means
751/// "route directly to the best holder"; proxy warming is a no-op, never a
752/// gamble.
753///
754/// `candidates` must already exclude the holders — the caller does this.
755///
756/// The full ordered list is returned so the caller falls back from a proxy that
757/// declines to the next candidate and finally to the direct holder, per ADR 037
758/// § Fallback. The CLI `fetch` loop iterates this whole list: each provider is
759/// its own lane on the same shared payment pool, so a fallback escrows no new
760/// on-chain deposit and resumes the partial it already has. Proxy warming is
761/// therefore default-on, and a declining proxy never regresses a fetch.
762#[must_use]
763#[doc(hidden)]
764pub fn proxy_warming_order(
765 best_holder_rtt_ms: f64,
766 rtt_threshold_ms: f64,
767 margin_ms: f64,
768 candidates: &[WarmingCandidate],
769) -> Vec<&WarmingCandidate> {
770 // Trigger 1: the best holder must be distant enough to warrant warming. The
771 // `is_finite()` guard also makes a NaN best-holder RTT fail closed (route
772 // direct) rather than sneak past a bare `>` comparison.
773 if !(best_holder_rtt_ms.is_finite() && best_holder_rtt_ms > rtt_threshold_ms) {
774 return Vec::new();
775 }
776 // Trigger 2 + ranking: keep only candidates that beat the best holder by the
777 // margin, nearest first. If none clear the margin the list is empty and the
778 // caller routes direct.
779 let mut qualifying: Vec<&WarmingCandidate> = candidates
780 .iter()
781 .filter(|c| c.rtt_ms.is_finite() && best_holder_rtt_ms - c.rtt_ms >= margin_ms)
782 .collect();
783 qualifying.sort_by(|a, b| a.rtt_ms.total_cmp(&b.rtt_ms));
784 qualifying
785}
786
787#[cfg(test)]
788#[allow(
789 clippy::unwrap_used,
790 clippy::expect_used,
791 clippy::indexing_slicing,
792 clippy::panic
793)]
794mod tests;