Skip to main content

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 get` 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 lane pays it and the `slash_sig` is verified against it).
108    pub eth_address: Address,
109    /// The node's self-attested region (ADR 030), used for locality-aware
110    /// selection. `None` when the node registered without one, or with a code
111    /// outside [`Region`]'s accepted set.
112    ///
113    /// An unrecognized code parses to `None` and sorts into the "rest" bucket
114    /// rather than dropping the candidate: `CapacityBond` permits any string up
115    /// to 16 bytes and ADR 030 § Cross-ADR Impact explicitly declines to
116    /// tighten it, so rejecting a node over a purely advisory field would be
117    /// stricter than the source of truth and would silently shrink the
118    /// fetchable set. Parsing at this boundary is what makes the derived `Eq`
119    /// above correct and keeps an unbounded operator-submitted string off the
120    /// peer-store read path (#1348).
121    pub region_hint: Option<Region>,
122    /// The node's registry-published, packed `multiaddrs` field
123    /// (`pack_multiaddrs` framing), decoded on demand by [`Self::dial_addrs`]
124    /// into iroh direct-address hints. Self-attested and additive: it can only
125    /// add a direct dial path, never gate one. Carried through the peer store,
126    /// so a candidate rebuilt on a registry outage keeps the last-seen addresses
127    /// and can dial a reachable peer directly, without iroh discovery.
128    pub multiaddrs: Bytes,
129}
130
131impl NodeCandidate {
132    /// Registry-published direct dial addresses, decoded leniently from the
133    /// packed on-chain `multiaddrs` field for use as iroh direct-address hints
134    /// (ADR 001 § Node Discovery). Empty when the node published none or the
135    /// field is malformed. A returned address is only ever one dial path among
136    /// discovery and the relay fallback, so a stale entry loses the path race
137    /// but never fails the connection.
138    #[must_use]
139    pub fn dial_addrs(&self) -> Vec<std::net::SocketAddr> {
140        decdn_incentive::node_register::decode_dial_addrs(&self.multiaddrs)
141    }
142}
143
144/// Attach a candidate's registry-published direct addresses to `target` as iroh
145/// direct-address hints (ADR 001 § Node Discovery), so a reachable peer connects
146/// without a relay. Additive: an empty or malformed `multiaddrs` field adds
147/// nothing and leaves iroh discovery and the relay fallback untouched, so this
148/// can only speed or enable a dial, never fail one.
149#[must_use]
150pub fn with_dial_addrs(mut target: iroh::EndpointAddr, cand: &NodeCandidate) -> iroh::EndpointAddr {
151    for sock in cand.dial_addrs() {
152        target = target.with_ip_addr(sock);
153    }
154    target
155}
156
157/// Distill a registry `NodeInfo` into a [`NodeCandidate`], or `None` if it is
158/// not currently active or its `nodeId` is not a valid ed25519 key.
159///
160/// `is_active` is the on-chain `isActive` predicate that `getRegisteredNodes`
161/// returns per entry (registered AND bond ≥ minBond AND no unbonding AND not
162/// ejected) — NOT the raw `NodeInfo.active` registration flag, which stays
163/// `true` for an operator mid-unbonding. Filtering on the strict predicate drops
164/// stale-active nodes up front rather than paying a probe round-trip to a node
165/// that is on its way out. The downstream probe-and-rank step is still the final
166/// arbiter of who can actually serve.
167fn candidate_from(info: &CapacityBond::NodeInfo, is_active: bool) -> Option<NodeCandidate> {
168    if !is_active {
169        return None;
170    }
171    let node_id = PublicKey::from_bytes(&info.nodeId.0).ok()?;
172    Some(NodeCandidate {
173        node_id,
174        eth_address: info.ethAddress,
175        // Normalize once, here — see `NodeCandidate::region_hint`. An
176        // unparseable hint costs the node its locality bonus, not its place in
177        // the candidate set.
178        region_hint: Region::parse(&info.regionHint),
179        // Carried raw and decoded only at dial time (`dial_addrs`); malformed
180        // bytes cost a direct-dial hint, never the candidate.
181        multiaddrs: info.multiaddrs.clone(),
182    })
183}
184
185/// Drive `fetch_page` across the paginated `getRegisteredNodes` read, retrying on
186/// the ADR 012 § Bootstrap step 3 schedule ([`REGISTRY_RETRY_BACKOFF`]) and
187/// distilling every entry through [`candidate_from`].
188///
189/// The retry budget is **aggregate**, spent across the whole read rather than
190/// reset per page (#1349): `attempt` is declared outside the pagination loop, so
191/// a page that succeeds after two retries leaves one for everything after it.
192/// That is what bounds the worst case at the schedule's own 36 s instead of
193/// 36 s × page-count — a figure the caller cannot see, since the page count
194/// depends on how many nodes are registered.
195///
196/// A successful page deliberately does NOT refund the budget. Refunding would
197/// restore the unbounded case exactly: a registry that fails every *other* call
198/// would alternate success and retry forever.
199///
200/// Split out from [`active_nodes`] so the retry and pagination control flow is
201/// drivable from a test without a live RPC — the schedule alone is a `const`
202/// that proves nothing about the loop that reads it.
203///
204/// # Errors
205///
206/// Fails when the read's retries are exhausted, or immediately when the failure
207/// is [`is_permanent_contract_error`].
208async fn paginate_with_retry<F, Fut>(fetch_page: F) -> anyhow::Result<Vec<NodeCandidate>>
209where
210    F: Fn(u64) -> Fut,
211    Fut: Future<Output = Result<(Vec<CapacityBond::NodeInfo>, Vec<bool>), alloy::contract::Error>>,
212{
213    let mut out = Vec::new();
214    let mut offset = 0u64;
215    // Outside the pagination loop: one budget for the whole read.
216    let mut attempt = 0usize;
217    loop {
218        let (page, active) = loop {
219            match fetch_page(offset).await {
220                Ok(page) => break page,
221                // A deterministic failure short-circuits the schedule. The two
222                // likeliest first-run mistakes both land here: a typo'd
223                // `blockchain.capacity_bond_address` (the call returns `0x`,
224                // decoded as `ZeroData`) and an expired or wrong RPC API key
225                // (HTTP 401/403 with a plain body; a JSON-RPC error body is
226                // judged by its code instead). Retrying either one would sit silently for
227                // 36 s and then blame network connectivity.
228                Err(e) if is_permanent_contract_error(&e) => {
229                    return Err(e).with_context(|| {
230                        format!(
231                            "CapacityBond.getRegisteredNodes(offset={offset}) failed and will not \
232                             succeed on retry — check that blockchain.capacity_bond_address \
233                             holds the registry contract and that rpc_url points at the same \
234                             chain and accepts this key"
235                        )
236                    });
237                }
238                Err(e) => {
239                    let Some(backoff) = REGISTRY_RETRY_BACKOFF.get(attempt).copied() else {
240                        return Err(e).with_context(|| {
241                            format!(
242                                "CapacityBond.getRegisteredNodes(offset={offset}) failed after {} \
243                                 retries across the whole registry read",
244                                REGISTRY_RETRY_BACKOFF.len()
245                            )
246                        });
247                    };
248                    // Sanitized, not `%e`: an alloy transport error's Display
249                    // forwards reqwest's ` for url (<url>)` tail, and an
250                    // `rpc_url` commonly carries an API key in its path/query
251                    // (issue #954). The `main()` boundary only sanitizes the
252                    // error chain, never a `tracing` field. The helper walks
253                    // `source()`, where the transport class (timeout, refused,
254                    // TLS) sits beneath the top-level Display.
255                    tracing::warn!(
256                        offset,
257                        attempt,
258                        backoff_ms = backoff.as_millis(),
259                        error = %sanitize_error_sources(&e),
260                        "CapacityBond.getRegisteredNodes page failed; retrying"
261                    );
262                    tokio::time::sleep(backoff).await;
263                    attempt = attempt.saturating_add(1);
264                }
265            }
266        };
267        // `page` and `active` are equal-length by construction (the contract
268        // fills both in one loop). A divergence is an ABI/decoder fault; the
269        // `zip` below would silently truncate and drop candidates, so fail loudly.
270        anyhow::ensure!(
271            page.len() == active.len(),
272            "CapacityBond.getRegisteredNodes(offset={offset}) returned mismatched \
273             page/active lengths ({} vs {}) — ABI or decoder fault",
274            page.len(),
275            active.len()
276        );
277        let page_len = page.len() as u64;
278        // `active[i]` is the on-chain `isActive(page[i])`, index-aligned with the
279        // page. Zip so the strict predicate — not the raw `NodeInfo.active` flag —
280        // gates each candidate.
281        out.extend(
282            page.iter()
283                .zip(active.iter())
284                .filter_map(|(info, &is_active)| candidate_from(info, is_active)),
285        );
286        // A short page is the last page (Kademlia-style termination).
287        if page_len < PAGE_SIZE {
288            break;
289        }
290        offset = offset.saturating_add(PAGE_SIZE);
291    }
292    Ok(out)
293}
294
295/// Read the active node set from `CapacityBond.getRegisteredNodes` at
296/// `capacity_bond_addr` over `rpc_url` (a read-only HTTP provider — no signer
297/// needed for a view call). Paginated; inactive / undecodable entries are
298/// skipped.
299///
300/// Uncached: this is the un-cached half, and it can sleep for the full retry
301/// schedule. For the fetch/bundle-pull bootstrap path, reach it only through
302/// [`bootstrap_nodes`] (the ADR 012 entry point) — calling this directly would
303/// silently opt out of the peer-cache fallback. It is also, deliberately, the
304/// direct read `decdn node lookup` (#1481) uses: that command is an unpaid,
305/// one-shot listing with no client data dir to cache into, so the peer-cache
306/// layer above would have nothing to read from or write to.
307///
308/// # Errors
309///
310/// Fails if `rpc_url` is not a valid URL (before any retry is attempted) or a
311/// `getRegisteredNodes` page call fails permanently or past its retries.
312#[doc(hidden)]
313pub async fn active_nodes(
314    rpc_url: &str,
315    capacity_bond_addr: Address,
316) -> anyhow::Result<Vec<NodeCandidate>> {
317    decdn_common::tls::install_crypto_provider();
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 = &registry;
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 get`'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 "will serve at
705    /// least one block" (for every blob with a block), so this may be a
706    /// proper subset of the blob rather than the whole thing — a **partial
707    /// holder** is admitted here
708    /// exactly like a full one; nothing in this module treats the two
709    /// differently. The requester-side `has_blob`/`coverage.is_empty()`
710    /// consistency check (ADR 013 §Tier 1, `ProbeResponseExt::consistent_with`)
711    /// runs before a `Probed` is ever constructed, so a malformed pairing never
712    /// reaches this field. Empty here only for the empty root, which spans no
713    /// block (#2471).
714    pub coverage: Coverage,
715}
716
717/// A bonded non-holder the client has a measured RTT for, and could route a
718/// warming request through (ADR 037 § Candidate pool). Distinct from
719/// [`Probed`], which is a confirmed blob-holder.
720#[derive(Debug, Clone)]
721#[doc(hidden)]
722pub struct WarmingCandidate {
723    /// The candidate proxy's iroh id.
724    pub node_id: PublicKey,
725    /// Its Ethereum address — the `--provider-address` a warming lane pays
726    /// and the `slash_sig` is verified against.
727    pub eth_address: Address,
728    /// Measured round-trip time in milliseconds, from the **live `cdn/probe/v1`
729    /// probe issued for this request**.
730    ///
731    /// The persisted peer knowledge base (ADR 037 § RTT source,
732    /// `decdn_client::peer_store`) does not track which peers hold which
733    /// blobs, only identity, latency, and price — it cannot tell a non-holder
734    /// from a holder for this hash. So this warming candidate pool still comes
735    /// only from nodes this request actually probed (`has_blob: false`
736    /// responses), not from the full peer store minus holders.
737    pub rtt_ms: f64,
738    /// The candidate's registry-published, packed `multiaddrs` field, carried
739    /// from its [`NodeCandidate`] so a warming lane dials the proxy directly
740    /// (see [`NodeCandidate::multiaddrs`]). Never read by ranking.
741    pub multiaddrs: Bytes,
742}
743
744/// ADR 037 § Client selection policy: the ordered list of nearby non-holders to
745/// route a warming request through, nearest-RTT first, or empty when proxy
746/// warming does not engage.
747///
748/// Proxy warming engages **only** when the best holder's RTT exceeds
749/// `rtt_threshold_ms` (the holders are all distant) **and** a candidate beats
750/// the best holder's RTT by at least `margin_ms`. Ranking is measured RTT only
751/// — self-attested region is never consulted, so a region-spoofing node simply
752/// exhibits a high measured RTT and is never selected. An empty result means
753/// "route directly to the best holder"; proxy warming is a no-op, never a
754/// gamble.
755///
756/// `candidates` must already exclude the holders — the caller does this.
757///
758/// The full ordered list is returned so the caller falls back from a proxy that
759/// declines to the next candidate and finally to the direct holder, per ADR 037
760/// § Fallback. The CLI `fetch` loop iterates this whole list: each provider is
761/// its own lane on the same shared payment pool, so a fallback escrows no new
762/// on-chain deposit and resumes the partial it already has. Proxy warming is
763/// therefore default-on, and a declining proxy never regresses a fetch.
764#[must_use]
765#[doc(hidden)]
766pub fn proxy_warming_order(
767    best_holder_rtt_ms: f64,
768    rtt_threshold_ms: f64,
769    margin_ms: f64,
770    candidates: &[WarmingCandidate],
771) -> Vec<&WarmingCandidate> {
772    // Trigger 1: the best holder must be distant enough to warrant warming. The
773    // `is_finite()` guard also makes a NaN best-holder RTT fail closed (route
774    // direct) rather than sneak past a bare `>` comparison.
775    if !(best_holder_rtt_ms.is_finite() && best_holder_rtt_ms > rtt_threshold_ms) {
776        return Vec::new();
777    }
778    // Trigger 2 + ranking: keep only candidates that beat the best holder by the
779    // margin, nearest first. If none clear the margin the list is empty and the
780    // caller routes direct.
781    let mut qualifying: Vec<&WarmingCandidate> = candidates
782        .iter()
783        .filter(|c| c.rtt_ms.is_finite() && best_holder_rtt_ms - c.rtt_ms >= margin_ms)
784        .collect();
785    qualifying.sort_by(|a, b| a.rtt_ms.total_cmp(&b.rtt_ms));
786    qualifying
787}
788
789#[cfg(test)]
790#[allow(
791    clippy::unwrap_used,
792    clippy::expect_used,
793    clippy::indexing_slicing,
794    clippy::panic
795)]
796mod tests;