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 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 = &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 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;