Skip to main content

decdn_cli/commands/
fetch.rs

1//! `decdn fetch` — standalone client-side paid pull of a single
2//! content-addressed blob over `cdn/client/v1` (issues #391, #940).
3//!
4//! Turnkey paying sibling of [`super::probe`]: dial a node by explicit
5//! `--node-id`/`--addr`/`--relay-url` (or auto-discover the holders, #936),
6//! **auto-open-or-reuse** the caller's own `PaymentPool` deposit, take the
7//! first size claim (the probe hint, or a header-only open when there is
8//! none), and fetch it through the acquire loop
9//! ([`decdn_client::acquire`]) across one [`decdn_client::PeerSource`] lane
10//! per holder (signing cumulative vouchers, resuming each lane's persisted
11//! watermark, verifying the `slash_sig` recovers to the provider, ADR 014 §1).
12//! Every byte is bao-verified; each lane's new watermark is persisted, and the
13//! file is promoted atomically. A holder that faults cools and returns; the
14//! command ends on done, a fault only the user can fix, or the stop policy.
15//!
16//! Pool lifecycle: the caller's live pool in the persistent
17//! [`RedbBuyerPoolStore`] is reused (the `(signer, provider)` lane watermark is
18//! resumed) — and topped up on-chain via `topUp` (best effort) when its
19//! pool-wide remaining deposit has run low, so a sustained series of fetches
20//! isn't stranded;
21//! otherwise one is opened on-chain (USDC `approve` if needed → `openPool`) via
22//! the shared [`decdn_client::buyer_pool::open_pool`] kernel and recorded.
23//! One pool fans out to every provider the caller pays (ADR 003) — there is no
24//! per-provider open. The chain coordinates resolve flag >
25//! `[blockchain]`/`[identity]` config > default.
26//!
27//! The chain/discovery/delivery seams (`resolve_chain`, `resolve_target_node`,
28//! `probe_and_order`, `open_or_reuse_pool`, `build_multi_lane`/`DriveFetchDeps`,
29//! `temp_in_parent`) are `pub(crate)` so `decdn bundle pull` (#391) reuses the
30//! same acquire loop and funding recovery across a bundle's many entries.
31
32use std::collections::HashMap;
33use std::path::{Path, PathBuf};
34use std::str::FromStr;
35use std::sync::{Arc, Mutex, OnceLock, PoisonError};
36use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
37
38use alloy::dyn_abi::Eip712Domain;
39use alloy::primitives::{Address, B256, Bytes, U256};
40use alloy::signers::local::PrivateKeySigner;
41use decdn_client::buyer_pool::{
42    LOW_WATER_DIVISOR, PoolNotOpen, ProgressWrite, TopUpUnconfirmed, ToppedUpPool, WalletShortfall,
43    ensure_allowance, escrowed_but_untracked, grade_deposit_credit, open_pool, pool_accepts_funds,
44    refill_amount, self_owned_lane_ctx, top_up, topped_up_effect,
45};
46use decdn_client::source::{Funder, SourceFuture};
47use decdn_client::{
48    Connections, Cumulative, DownloadTarget, Downloader, Holder, LaneHandle, LaneLedgers,
49    NoAffordableSource, NoCache, NoSourceHasBlob, PeerHealth, PeerSource, PoolContext, PoolLedger,
50    PoolReplaced, ProgressClock, PullConfig, PullDeadlines, Recovery, RecoveryGate, StopPolicy,
51    Streamer, UpstreamRefused, UpstreamVoucherRejected, VoucherProgress, sign_client_binding,
52};
53
54use super::cli_sources::CliSources;
55use decdn_common::cli::{self, common::expand_tilde};
56use decdn_common::config::{DEFAULT_CHAIN_ID, FileConfig, load_file_config};
57use decdn_incentive::buyer_pool::{AdvanceOutcome, BuyerPoolState, BuyerPoolStore};
58use decdn_incentive::buyer_pool_redb::RedbBuyerPoolStore;
59use decdn_incentive::eth_identity::{self, PasswordUse, load_signer};
60use decdn_incentive::payment_pool::{PaymentPool, newest_solvent_owned_pool};
61
62use anyhow::Context as _;
63use decdn_incentive::rate::min_payment;
64use decdn_incentive::{
65    CapabilityGrant, Deployment, LaneKey, PoolId, bind_node_id_domain, slash_judge_domain,
66    voucher_domain,
67};
68use decdn_protocol::client::StreamError;
69use iroh::{Endpoint, EndpointAddr, PublicKey, RelayUrl};
70use tokio_util::task::AbortOnDropHandle;
71
72use decdn_client::UpstreamRateLimited;
73use decdn_client::discovery::{self, NodeCandidate};
74use decdn_client::endpoint as client_endpoint;
75use decdn_client::probe::probe_once;
76use decdn_client::provider;
77
78use super::buyer_store::{ChainAdoption, DataDirSource, open_client_store_for_buy};
79use super::fetch_timings::{FetchTimings, Mark};
80use super::interrupt::{Interrupt, Interrupted};
81use super::ordered_writes::OrderedWrites;
82use super::tab_progress::TabProgress;
83
84/// Per-candidate probe timeout during auto-discovery (#936). The K probes run
85/// concurrently, so this bounds selection latency rather than the overall fetch
86/// (`--timeout-ms`); a dead candidate falls out of selection after this.
87const SELECT_PROBE_TIMEOUT_MS: u64 = 5_000;
88
89/// Parse a user-supplied BLAKE3 hash: 64 hex chars, optionally `0x`- or
90/// `b3:`-prefixed (the `b3:` form is what bundle manifests carry).
91pub(crate) fn parse_hash(s: &str) -> anyhow::Result<[u8; 32]> {
92    let hex = s
93        .strip_prefix("0x")
94        .or_else(|| s.strip_prefix("b3:"))
95        .unwrap_or(s);
96    let h = blake3::Hash::from_hex(hex).map_err(|e| {
97        anyhow::anyhow!(
98            "invalid hash {s:?}: expected 64 hex chars (BLAKE3 digest), optional `0x`/`b3:` \
99             prefix: {e}"
100        )
101    })?;
102    Ok(*h.as_bytes())
103}
104
105/// Current unix time in microseconds (the requester-echoed `timestamp_us`).
106pub(crate) fn micros_now() -> u64 {
107    u64::try_from(
108        SystemTime::now()
109            .duration_since(UNIX_EPOCH)
110            .map_or(0, |d| d.as_micros()),
111    )
112    .unwrap_or(u64::MAX)
113}
114
115/// Build the `decdn fetch` delivery progress bar (#1118). Drawn to stderr and
116/// TTY-aware — `indicatif` hides it automatically when stderr is not a terminal,
117/// so a piped/redirected fetch emits no bar. A steady tick animates the spinner
118/// during the pre-byte connect/handshake so the command never looks hung. The
119/// bar counts verified **content** bytes against the blob's `total_bytes` — the
120/// unit the [`decdn_client::ProgressCallback`] reports — so length and
121/// position share one unit and the bar fills to exactly 100%. The acquire loop
122/// reports one monotonic total the lanes fold their per-leg deltas into, so
123/// the bar never jumps between lanes' divergent local positions.
124fn new_progress_bar() -> indicatif::ProgressBar {
125    let style = indicatif::ProgressStyle::with_template(
126        // Rate/ETA come from `{msg}` (see `delivery_progress`), not the built-in
127        // `{bytes_per_sec}`/`{eta}` — those swing wildly on bursty chunk arrival.
128        "{spinner:.green} {bytes}/{total_bytes} {msg}[{wide_bar:.cyan/blue}]",
129    )
130    // A bad template is a programming error, not a runtime one; fall back to the
131    // built-in bar rather than panic (clippy forbids `unwrap`/`expect`).
132    .unwrap_or_else(|_| indicatif::ProgressStyle::default_bar())
133    .progress_chars("=>-");
134    // When client logging is enabled, share stderr with the tracing subscriber
135    // through its `MultiProgress` so log lines do not corrupt the bar; a no-op
136    // (returns the bar unchanged) on the default no-subscriber path. The bar is
137    // attached before it is styled or ticked: either would draw a detached bar
138    // straight to stderr, leaving an orphan line the container never clears.
139    // `AndClear`: a bar dropped unfinished (a Ctrl-C) clears its line too.
140    let bar = crate::logging::attach_progress_bar(
141        indicatif::ProgressBar::new(0).with_finish(indicatif::ProgressFinish::AndClear),
142    );
143    bar.set_style(style);
144    bar.enable_steady_tick(Duration::from_millis(120));
145    bar
146}
147
148/// Chain coordinates resolved flag > `[blockchain]`/`[identity]` config >
149/// default. Pure (parse-only) so the precedence is unit-testable.
150#[derive(Debug)]
151pub(crate) struct ResolvedChain {
152    pub(crate) rpc_url: String,
153    pub(crate) payment_pool: Address,
154    pub(crate) slash_judge: Address,
155    /// `CapacityBond` registry for auto-discovery (no `--node-id`). `None` when
156    /// neither the flag nor `blockchain.capacity_bond_address` is set — only an
157    /// error on the discovery path, never on the explicit-node path.
158    pub(crate) capacity_bond: Option<Address>,
159    pub(crate) chain_id: u64,
160    pub(crate) keystore: PathBuf,
161    /// File holding the keystore password, consulted after the
162    /// `DECDN_KEYSTORE_PASSWORD` env var and before a prompt. CLI/env-only, like
163    /// the daemon's `ResolvedBlockchain::keystore_password_file` — passwords do
164    /// not belong in a config file even by reference.
165    pub(crate) keystore_password_file: Option<PathBuf>,
166    /// Directory holding the buyer-pool redb store and (by default) the
167    /// keystore. Client-scoped (`~/.decdn/client`) unless an explicit
168    /// `--data-dir`/`identity.data_dir` is given.
169    pub(crate) data_dir: PathBuf,
170    /// Which step of the ladder produced [`Self::data_dir`]. A node's data dir
171    /// is acceptable when the operator named it and not when it merely fell out
172    /// of the config file, so the guard needs the provenance, not just the path
173    /// (#2082).
174    pub(crate) data_dir_source: DataDirSource,
175    /// Client region for region-first discovery ordering (`--region` >
176    /// `identity.region`). `None` skips the ordering.
177    pub(crate) region: Option<String>,
178    /// Client region allowlist (`[client] region_allowlist`):
179    /// narrows which peers `discover_provider` probes/discovers, never ranks
180    /// them. Empty when the config omits `[client]` or the list, which is a
181    /// no-op in [`discovery::select_candidates_filtered`]. Invalid entries
182    /// (fail `Region::parse`) are dropped with a `tracing::warn!` in
183    /// [`resolve_chain`] rather than failing config resolution — a typo'd
184    /// region code shrinks the filter, it does not break the fetch.
185    pub(crate) region_allowlist: Vec<decdn_protocol::Region>,
186    /// Deposit to escrow when OPENING a pool, and the target a reused pool's
187    /// proactive refill restores toward once it has served verified bytes.
188    pub(crate) working_deposit: U256,
189    pub(crate) max_approve: bool,
190}
191
192impl ResolvedChain {
193    /// The `PaymentPool` deployment this buy runs against: the chain id and the
194    /// contract address. A buyer row is reused only on this deployment.
195    pub(crate) const fn deployment(&self) -> Deployment {
196        Deployment {
197            chain_id: self.chain_id,
198            payment_pool: self.payment_pool,
199        }
200    }
201}
202
203/// Parse `[client] region_allowlist` into [`decdn_protocol::Region`]s.
204/// Parsed here — not carried as raw strings — so an invalid code is reported
205/// once at config-resolution time rather than on every fetch's discovery
206/// path. An entry that fails `Region::parse` is dropped, not fatal: it costs
207/// the filter one entry, not the whole fetch. Absent `[client]` or an absent
208/// `region_allowlist` both yield an empty `Vec`, which is a no-op filter.
209fn parse_region_allowlist(file: &FileConfig) -> Vec<decdn_protocol::Region> {
210    file.client
211        .as_ref()
212        .and_then(|c| c.region_allowlist.as_ref())
213        .into_iter()
214        .flatten()
215        .filter_map(|code| {
216            let parsed = decdn_protocol::Region::parse(code);
217            if parsed.is_none() {
218                tracing::warn!(
219                    "client.region_allowlist entry {code:?} is not a recognized \
220                     region code; dropping it from the filter"
221                );
222            }
223            parsed
224        })
225        .collect()
226}
227
228pub(crate) fn resolve_chain(
229    args: &cli::ClientFetchArgs,
230    file: &FileConfig,
231) -> anyhow::Result<ResolvedChain> {
232    let bc = file.blockchain.as_ref();
233    let rpc_url = args
234        .rpc_url
235        .clone()
236        .or_else(|| bc.and_then(|b| b.rpc_url.clone()))
237        .ok_or_else(|| anyhow::anyhow!("rpc_url not set (--rpc-url or blockchain.rpc_url)"))?;
238
239    let pp_raw = args
240        .payment_pool_address
241        .clone()
242        .or_else(|| bc.and_then(|b| b.payment_pool_address.clone()))
243        .ok_or_else(|| {
244            anyhow::anyhow!(
245                "payment_pool_address not set (--payment-pool-address or \
246                 blockchain.payment_pool_address)"
247            )
248        })?;
249    let payment_pool = super::chain_ctx::parse_nonzero_address(&pp_raw, "payment_pool_address")?;
250
251    let sj_raw = args
252        .slash_judge_address
253        .clone()
254        .or_else(|| bc.and_then(|b| b.slash_judge_address.clone()))
255        .ok_or_else(|| {
256            anyhow::anyhow!(
257                "slash_judge_address not set (--slash-judge-address or \
258                 blockchain.slash_judge_address)"
259            )
260        })?;
261    let slash_judge = super::chain_ctx::parse_nonzero_address(&sj_raw, "slash_judge_address")?;
262
263    // Optional: only the auto-discovery path reads it, and it errors there if
264    // unset rather than failing every explicit-node fetch.
265    let capacity_bond = args
266        .capacity_bond_address
267        .clone()
268        .or_else(|| bc.and_then(|b| b.capacity_bond_address.clone()))
269        .map(|raw| super::chain_ctx::parse_nonzero_address(&raw, "capacity_bond_address"))
270        .transpose()?;
271
272    let region = args
273        .region
274        .clone()
275        .or_else(|| file.identity.as_ref().and_then(|i| i.region.clone()));
276
277    // The `[client] region_allowlist` pre-filters discovery/probing.
278    let region_allowlist = parse_region_allowlist(file);
279
280    let chain_id = args
281        .chain_id
282        .or_else(|| bc.and_then(|b| b.chain_id))
283        .unwrap_or(DEFAULT_CHAIN_ID);
284
285    // Client data dir: an explicit `--data-dir`/`identity.data_dir` wins,
286    // otherwise the client-scoped `~/.decdn/client` (not the node-shaped
287    // `~/.decdn`, so a pure client install doesn't masquerade as a node).
288    // Which step won is carried forward: on a node host `identity.data_dir`
289    // points at the daemon's dir, and buying from there under the node's own
290    // keystore is a refusal unless the operator asked for it by name (#2082).
291    let (data_dir, data_dir_source) = args
292        .data_dir
293        .clone()
294        .map(|p| (p, DataDirSource::Flag))
295        .or_else(|| {
296            file.identity
297                .as_ref()
298                .and_then(|i| i.data_dir.clone())
299                .map(|p| (p, DataDirSource::Config))
300        })
301        .map(|(p, source)| (expand_tilde(&p), source))
302        .or_else(|| cli::default_client_data_dir().map(|p| (p, DataDirSource::Default)))
303        .ok_or_else(|| {
304            anyhow::anyhow!("data_dir not set and no default available (pass --data-dir)")
305        })?;
306
307    // Keystore: an explicit `--keystore`/`blockchain.eth_keystore` wins; otherwise
308    // `keystore.json` under the (client-scoped) data dir.
309    let keystore = args
310        .keystore
311        .clone()
312        .or_else(|| bc.and_then(|b| b.eth_keystore.clone()))
313        .map_or_else(
314            || eth_identity::keystore_path(&data_dir),
315            |p| expand_tilde(&p),
316        );
317
318    // The deposit knob carries the same invariant the daemon resolver
319    // (`resolve_blockchain_into`) enforces, and it must be checked HERE too: this
320    // path resolves the raw `[blockchain]` table plus the CLI flags without going
321    // through that resolver, so without this the client would accept a config file
322    // `decdn config validate` rejects, and `--working-deposit-micro-usdc 0` would
323    // surface as an opaque `openPool` `ZeroAmount` revert instead of a load-time
324    // message. Keep the wording in step with `resolve_blockchain_into`.
325    let working_deposit_micro_usdc = args
326        .working_deposit_micro_usdc
327        .or_else(|| bc.and_then(|b| b.buyer_working_deposit_micro_usdc))
328        .unwrap_or(decdn_common::config::DEFAULT_BUYER_WORKING_DEPOSIT_MICRO_USDC);
329    anyhow::ensure!(
330        working_deposit_micro_usdc > 0,
331        "buyer_working_deposit_micro_usdc must be > 0 (openPool reverts ZeroAmount on a \
332         zero deposit) — set --working-deposit-micro-usdc or \
333         blockchain.buyer_working_deposit_micro_usdc"
334    );
335    let working_deposit = U256::from(working_deposit_micro_usdc);
336    // Client default: exact (deposit-sized) USDC approval, not an unlimited
337    // standing allowance. `buyer_max_approve = true` opts a power user back into
338    // the node/operator posture. (The daemon's own default stays unlimited.)
339    let max_approve = bc.and_then(|b| b.buyer_max_approve).unwrap_or(false);
340
341    Ok(ResolvedChain {
342        rpc_url,
343        payment_pool,
344        slash_judge,
345        capacity_bond,
346        chain_id,
347        keystore,
348        keystore_password_file: args.keystore_password_file.as_deref().map(expand_tilde),
349        data_dir,
350        data_dir_source,
351        region,
352        region_allowlist,
353        working_deposit,
354        max_approve,
355    })
356}
357
358/// Client-side proxy-warming knobs (#1174, ADR 037), distilled from
359/// [`cli::ClientFetchArgs`]. RTT thresholds are held as `f64` ms to compare
360/// directly against probe RTTs.
361#[derive(Debug, Clone, Copy)]
362pub(crate) struct ProxyWarmingParams {
363    pub enabled: bool,
364    pub rtt_threshold_ms: f64,
365    pub margin_ms: f64,
366}
367
368impl ProxyWarmingParams {
369    pub(crate) fn from_args(args: &cli::ClientFetchArgs) -> Self {
370        // Widen through u32 so the u64 → f64 cast is lossless (ms knobs are
371        // small); saturate the implausible overflow rather than lose precision.
372        let ms = |v: u64| f64::from(u32::try_from(v).unwrap_or(u32::MAX));
373        Self {
374            enabled: args.proxy_warming,
375            rtt_threshold_ms: ms(args.proxy_warming_rtt_threshold_ms),
376            margin_ms: ms(args.proxy_warming_margin_ms),
377        }
378    }
379}
380
381/// The terminal "nothing can serve this blob" error for [`probe_and_order`],
382/// naming the failure that actually happened (#1911). A candidate leaves the
383/// selection loop four ways: it never answered (unreachable), it kept shedding
384/// the probe with `APP_ERR_RATE_LIMITED` (rate-limited — reachable, but refusing
385/// this client's probe rate), it answered but its `slash_sig` did not recover
386/// (unverifiable), or it answered but was unusable — a `has_blob`/coverage
387/// mismatch that no honest responder produces. When any probe was unverifiable
388/// the cause is almost always local configuration rather than missing content,
389/// so that case gets its own message; otherwise the parenthetical accounts for
390/// each of the other three separately. Reached only when there is neither a
391/// cache holder nor a reachable non-holder to pull through — a `has_blob:false`
392/// answer is a serve target, not a failure, so it never lands here.
393fn no_serve_target_error(
394    probe_count: usize,
395    unreachable: usize,
396    rate_limited: usize,
397    unverifiable: usize,
398) -> anyhow::Error {
399    if unverifiable > 0 {
400        return anyhow::Error::new(ResolveConfigFault(format!(
401            "{unverifiable} of {probe_count} probed node(s) answered, but their probe \
402             signatures did not recover to the operator address each is registered \
403             under. That is usually local configuration rather than missing content: \
404             check that blockchain.slash_judge_address and blockchain.chain_id match \
405             the deployment these nodes registered against"
406        )));
407    }
408    // With no unverifiable candidate, every candidate that neither stayed silent
409    // nor shed the probe answered but was dropped as unusable (has_blob/coverage
410    // mismatch) — name each count so the message never implies a silent,
411    // wholly-unreachable set when some replied.
412    let unusable = probe_count
413        .saturating_sub(unreachable)
414        .saturating_sub(rate_limited);
415    let shed = if rate_limited > 0 {
416        format!(", {rate_limited} rate-limited this client's probes")
417    } else {
418        String::new()
419    };
420    anyhow::anyhow!(
421        "none of the {probe_count} probed node(s) could serve the blob \
422         ({unreachable} did not answer{shed}, {unusable} answered but were unusable)"
423    )
424}
425
426/// The waits between probe attempts on a candidate that sheds the probe with
427/// `APP_ERR_RATE_LIMITED`. ADR 005's per-peer limit refills one probe every
428/// 200 ms, so these clear the burst that concurrent entries of one bundle pull
429/// send at its start. A candidate that still sheds after the last wait counts
430/// as rate-limited, never as silent.
431const PROBE_SHED_BACKOFFS_MS: [u64; 3] = [250, 500, 1000];
432
433/// Probe one candidate, probing it again after each [`PROBE_SHED_BACKOFFS_MS`]
434/// wait while it sheds the probe. Any other result — an answer, a timeout, a
435/// transport failure — returns at once.
436async fn probe_candidate(
437    endpoint: &Endpoint,
438    target: EndpointAddr,
439    hash: [u8; 32],
440    timestamp_us: u64,
441) -> anyhow::Result<(
442    decdn_protocol::message::ProbeResponse,
443    decdn_protocol::ProbeResponseExt,
444    decdn_client::probe::ProbeRtt,
445)> {
446    let mut backoffs = PROBE_SHED_BACKOFFS_MS.iter();
447    loop {
448        let res = probe_once(
449            endpoint,
450            target.clone(),
451            hash,
452            timestamp_us,
453            Duration::from_millis(SELECT_PROBE_TIMEOUT_MS),
454        )
455        .await;
456        match (&res, backoffs.next()) {
457            (Err(e), Some(&ms)) if probe_shed(e) => {
458                tokio::time::sleep(Duration::from_millis(ms)).await;
459            }
460            (Err(e), None) if probe_shed(e) => {
461                tracing::info!(
462                    "{} kept rate-limiting this client's probe: {e:#}",
463                    target.id
464                );
465                return res;
466            }
467            _ => return res,
468        }
469    }
470}
471
472/// Whether a failed probe was the node shedding it with `APP_ERR_RATE_LIMITED`.
473fn probe_shed(err: &anyhow::Error) -> bool {
474    err.downcast_ref::<UpstreamRateLimited>().is_some()
475}
476
477/// How long a probe round keeps collecting answers after the first verified
478/// holder answers. A holder that answers later is about 125 ms farther away,
479/// still connecting, or still waiting for hole punching to select a direct
480/// path (`probe_once` waits up to 500 ms for one after its exchange), and would
481/// not lead the order. One slow connect would otherwise hold the whole round
482/// for up to the probe timeout.
483const PROBE_SETTLE_AFTER_HOLDER: Duration = Duration::from_millis(250);
484
485/// Run `probes` concurrently and collect their outcomes until every probe has
486/// answered or the round's deadline passes. The first outcome `is_holder`
487/// accepts sets the deadline `grace` later. With `cold_grace` set and no
488/// holder yet, the first outcome `is_answer` accepts sets it `cold_grace`
489/// later, and a holder inside that window still ends the round `grace` after
490/// it. Returns the collected outcomes, in answer order, and the probes still
491/// pending. A zero `grace` returns at the first holder with every outcome
492/// already ready: `timeout_at` polls the stream before it checks the deadline.
493/// With no holder and no `cold_grace`, the round waits for every probe and the
494/// tail is empty.
495async fn settle_probes<T, F>(
496    probes: impl IntoIterator<Item = F>,
497    grace: Duration,
498    cold_grace: Option<Duration>,
499    is_holder: impl Fn(&T) -> bool,
500    is_answer: impl Fn(&T) -> bool,
501) -> (Vec<T>, futures_util::stream::FuturesUnordered<F>)
502where
503    F: std::future::Future<Output = T>,
504{
505    use futures_util::StreamExt as _;
506    let mut pending: futures_util::stream::FuturesUnordered<F> = probes.into_iter().collect();
507    let mut settled = Vec::with_capacity(pending.len());
508    let mut deadline: Option<tokio::time::Instant> = None;
509    let mut holder_seen = false;
510    loop {
511        let next = match deadline {
512            None => pending.next().await,
513            Some(at) => match tokio::time::timeout_at(at, pending.next()).await {
514                Ok(next) => next,
515                Err(_) => break,
516            },
517        };
518        let Some(outcome) = next else { break };
519        let now = tokio::time::Instant::now();
520        if !holder_seen && is_holder(&outcome) {
521            holder_seen = true;
522            let at = now + grace;
523            deadline = Some(deadline.map_or(at, |cold| cold.min(at)));
524        } else if deadline.is_none()
525            && let Some(cold) = cold_grace
526            && is_answer(&outcome)
527        {
528            deadline = Some(now + cold);
529        }
530        settled.push(outcome);
531    }
532    if !pending.is_empty() {
533        tracing::debug!(
534            pending = pending.len(),
535            holder = holder_seen,
536            "probe round settled; the rest continue as its tail"
537        );
538    }
539    (settled, pending)
540}
541
542/// Log one probe's raw result at `debug`, with the time since the round started.
543fn log_probe_result(
544    cand: &NodeCandidate,
545    res: &anyhow::Result<(
546        decdn_protocol::message::ProbeResponse,
547        decdn_protocol::ProbeResponseExt,
548        decdn_client::probe::ProbeRtt,
549    )>,
550    started: std::time::Instant,
551) {
552    match res {
553        Ok((resp, _, rtt)) => tracing::debug!(
554            node = %cand.node_id,
555            elapsed_ms = started.elapsed().as_millis(),
556            rtt_ms = rtt.ms,
557            rtt_direct = rtt.direct,
558            has_blob = resp.body.has_blob,
559            "probe answered"
560        ),
561        Err(error) => tracing::debug!(
562            node = %cand.node_id,
563            elapsed_ms = started.elapsed().as_millis(),
564            error = %format_args!("{error:#}"),
565            "probe failed"
566        ),
567    }
568}
569
570/// One probed candidate, verified and classified ([`classify_probe`]).
571pub(crate) enum ProbeOutcome {
572    /// A verified answer that holds the blob, with its peer-store sample.
573    Holder(discovery::Probed, (PublicKey, f64, u64)),
574    /// A verified answer that does not hold the blob, with its peer-store
575    /// sample.
576    NonHolder(discovery::WarmingCandidate, (PublicKey, f64, u64)),
577    /// No answer.
578    Unreachable,
579    /// The candidate kept shedding the probe.
580    RateLimited,
581    /// The answer's `slash_sig` did not recover to the candidate's operator.
582    Unverifiable,
583    /// The answer's `has_blob` and coverage disagree.
584    Unusable,
585}
586
587impl ProbeOutcome {
588    const fn is_holder(&self) -> bool {
589        matches!(self, Self::Holder(..))
590    }
591
592    /// A verified answer, holder or not.
593    const fn is_answer(&self) -> bool {
594        matches!(self, Self::Holder(..) | Self::NonHolder(..))
595    }
596
597    /// The peer-store sample of a verified answer.
598    pub(crate) const fn sample(&self) -> Option<(PublicKey, f64, u64)> {
599        match self {
600            Self::Holder(_, sample) | Self::NonHolder(_, sample) => Some(*sample),
601            Self::Unreachable | Self::RateLimited | Self::Unverifiable | Self::Unusable => None,
602        }
603    }
604}
605
606/// How a probe round ends once a verified holder answers.
607#[derive(Debug, Clone, Copy, PartialEq, Eq)]
608pub(crate) enum ProbeRound {
609    /// Return at the first verified holder and keep the pending probes as
610    /// the round's tail ([`LateProbes`]): the first round of a fetch, whose
611    /// late holders join the running fetch.
612    Stream,
613    /// Collect for [`PROBE_SETTLE_AFTER_HOLDER`] after the first verified
614    /// holder and drop the rest: a rediscovery.
615    Settle,
616}
617
618impl ProbeRound {
619    /// How long the round collects after the first verified holder.
620    const fn grace(self) -> Duration {
621        match self {
622            Self::Stream => Duration::ZERO,
623            Self::Settle => PROBE_SETTLE_AFTER_HOLDER,
624        }
625    }
626
627    /// How long the round collects after its first verified answer while no
628    /// holder has answered. A streamed round then starts on the non-holders
629    /// in hand as pull-through targets (#1911), and a holder that answers
630    /// later joins the running fetch. A settled round waits for every probe.
631    const fn cold_grace(self) -> Option<Duration> {
632        match self {
633            Self::Stream => Some(PROBE_SETTLE_AFTER_HOLDER),
634            Self::Settle => None,
635        }
636    }
637}
638
639/// The probes a streamed round left pending, with what deciding a late
640/// answer needs.
641pub(crate) struct LateProbes {
642    /// The pending probes, each yielding its classified outcome.
643    pub(crate) tail: std::pin::Pin<Box<dyn futures_util::Stream<Item = ProbeOutcome> + Send>>,
644    /// The lowest RTT among the holders the round returned.
645    pub(crate) best_holder_rtt_ms: f64,
646    /// The fetch's proxy-warming knobs.
647    pub(crate) warming: ProxyWarmingParams,
648}
649
650/// How a resolve probes: where it records its marks, and how its probe round
651/// ends.
652#[derive(Clone, Copy)]
653pub(crate) struct ProbeOpts<'a> {
654    /// The `decdn fetch` marks; `None` elsewhere.
655    pub(crate) timings: Option<&'a FetchTimings>,
656    /// How the probe round ends.
657    pub(crate) round: ProbeRound,
658}
659
660/// A streamed round's [`LateProbes`], taken once by whoever adopts them.
661#[derive(Default)]
662pub(crate) struct LateSlot(std::sync::Mutex<Option<LateProbes>>);
663
664impl LateSlot {
665    /// A slot holding `late`.
666    pub(crate) const fn new(late: LateProbes) -> Self {
667        Self(std::sync::Mutex::new(Some(late)))
668    }
669
670    /// Take the late probes, leaving the slot empty.
671    pub(crate) fn take(&self) -> Option<LateProbes> {
672        self.0
673            .lock()
674            .unwrap_or_else(std::sync::PoisonError::into_inner)
675            .take()
676    }
677}
678
679/// A probe round's outcomes, split by what [`probe_and_order`] does with each.
680struct ProbeTally {
681    /// The verified holders.
682    holders: Vec<discovery::Probed>,
683    /// Probed bonded nodes that answered `has_blob:false` — reachable, with a
684    /// measured RTT, but not holding the blob in their cache store. They serve
685    /// two roles: the proxy-warming candidate pool (ADR 037 § Candidate pool)
686    /// when a holder exists but is distant, and — when NO holder answers — the
687    /// pull-through serve targets a cold blob's first fetch bootstraps from
688    /// (#1911), since `has_blob:false` from the cache store does not mean the
689    /// node cannot serve via its own origin. Always collected, because the
690    /// second role does not depend on warming being on.
691    non_holders: Vec<discovery::WarmingCandidate>,
692    /// `(node_id, rtt_ms, rate_per_mb)` for each verified responder, harvested
693    /// into the peer store off the critical path (`spawn_harvest`, called from
694    /// `discover_provider`).
695    probed_samples: Vec<(PublicKey, f64, u64)>,
696    /// The ways a candidate drops out, counted separately: the terminal error
697    /// has to name the one that actually happened. A wrong
698    /// `slash_judge_address` or `chain_id` makes EVERY honest node fail
699    /// verification, and reporting that as "nobody holds the blob" sends the
700    /// operator hunting for missing content instead of a local
701    /// misconfiguration; a node shedding this client's probe rate is
702    /// reachable, and reporting it as silent points at the network instead.
703    unreachable: usize,
704    rate_limited: usize,
705    unverifiable: usize,
706}
707
708impl ProbeTally {
709    fn of(outcomes: Vec<ProbeOutcome>) -> Self {
710        let mut tally = Self {
711            holders: Vec::new(),
712            non_holders: Vec::new(),
713            probed_samples: Vec::new(),
714            unreachable: 0,
715            rate_limited: 0,
716            unverifiable: 0,
717        };
718        for outcome in outcomes {
719            match outcome {
720                ProbeOutcome::Holder(holder, sample) => {
721                    tally.probed_samples.push(sample);
722                    tally.holders.push(holder);
723                }
724                ProbeOutcome::NonHolder(candidate, sample) => {
725                    tally.probed_samples.push(sample);
726                    tally.non_holders.push(candidate);
727                }
728                ProbeOutcome::Unreachable => tally.unreachable += 1,
729                ProbeOutcome::RateLimited => tally.rate_limited += 1,
730                ProbeOutcome::Unverifiable => tally.unverifiable += 1,
731                ProbeOutcome::Unusable => {}
732            }
733        }
734        tally
735    }
736}
737
738/// Verify `cand`'s probe result and classify it. Every response is verified
739/// before it can influence the order (ADR 014 §1). A failure is requester-local
740/// policy, with no reputation effect, because an unrecovered signature
741/// attributes nothing to anyone.
742fn classify_probe(
743    cand: &NodeCandidate,
744    res: anyhow::Result<(
745        decdn_protocol::message::ProbeResponse,
746        decdn_protocol::ProbeResponseExt,
747        decdn_client::probe::ProbeRtt,
748    )>,
749    hash: [u8; 32],
750    timestamp_us: u64,
751    slash_domain: &alloy::sol_types::Eip712Domain,
752) -> ProbeOutcome {
753    let (resp, resp_ext, rtt) = match res {
754        Ok(answer) => answer,
755        Err(e) if probe_shed(&e) => return ProbeOutcome::RateLimited,
756        Err(_) => return ProbeOutcome::Unreachable,
757    };
758    if let Err(e) = decdn_client::probe::verify_probe_response(
759        &resp,
760        cand.eth_address,
761        slash_domain,
762        hash,
763        timestamp_us,
764    ) {
765        // A candidate silently vanishing from selection is exactly what the
766        // operator needs told, so log why at `warn`.
767        tracing::warn!(
768            "dropping an unverifiable probe response from {}: {e}",
769            cand.node_id
770        );
771        return ProbeOutcome::Unverifiable;
772    }
773    // #1506: `has_blob` and `coverage.is_empty()` are a biconditional by
774    // construction on an honest responder. Neither field is signed, and an
775    // inconsistency has no attributable author, so drop the candidate rather
776    // than score it.
777    if !resp_ext.consistent_with(resp.body.has_blob) {
778        tracing::warn!(
779            "dropping a probe response from {} with has_blob/coverage mismatch",
780            cand.node_id
781        );
782        return ProbeOutcome::Unusable;
783    }
784    let rtt_ms = rtt.ms;
785    // Every verified responder contributes a probe sample, holder or not.
786    let sample = (cand.node_id, rtt_ms, resp.body.rate_per_mb);
787    if resp.body.has_blob {
788        ProbeOutcome::Holder(
789            discovery::Probed {
790                candidate: cand.clone(),
791                rtt_ms,
792                total_bytes: resp_ext.total_bytes,
793                coverage: resp_ext.coverage,
794            },
795            sample,
796        )
797    } else {
798        ProbeOutcome::NonHolder(
799            discovery::WarmingCandidate {
800                node_id: cand.node_id,
801                eth_address: cand.eth_address,
802                rtt_ms,
803                multiaddrs: cand.multiaddrs.clone(),
804            },
805            sample,
806        )
807    }
808}
809
810/// Probe `candidates` for `hash` over `endpoint` and return the ordered
811/// provider-failover list (#1174, ADR 037 § Fallback): the sequence `fetch`
812/// tries in turn, each entry a fallback for the one before it, until one
813/// delivers the blob. Errors only when no probed candidate is reachable to serve
814/// at all — a cache holder OR a bonded non-holder that can pull through (#1911).
815///
816/// Every response is verified before it can influence the order: value
817/// invariants, echoed-field correlation, and `slash_sig` recovery to the
818/// candidate's on-chain operator address (ADR 014 §1). Selection reads
819/// `has_blob` and coverage off the response and pairs them with the measured
820/// RTT; `rate_per_mb` is harvested into `probed_samples` for the peer store
821/// only, never ranked on — the fetch pays the stream's own signed quote, gated
822/// by `--max-rate-per-mb`. `has_blob` and the harvested rate are only
823/// meaningful once the signature attributes them to the peer (coverage is
824/// unsigned; the consistency check below ties it to `has_blob`): an unverified
825/// quote is a claim no one is accountable for, so a node could seed the store
826/// with a rate it never committed to. A response that fails is
827/// dropped and its candidate skipped, exactly as for a timeout; it is
828/// requester-local policy and never scored against the peer, since a signature
829/// that does not recover attributes nothing to anyone.
830///
831/// Every candidate is probed on equal footing: opening cost is
832/// provider-independent, because the caller has ONE pool that fans out to
833/// every provider (ADR 003), so there is no per-provider "already funded"
834/// distinction to prefer.
835///
836/// The round ends [`PROBE_SETTLE_AFTER_HOLDER`] after the first verified
837/// holder answers, or when every probe has answered. A holder the cutoff drops
838/// stays reachable through the fetch's later discovery.
839///
840/// The order is proxy-warming candidates first (nearest RTT first, ADR 037 §
841/// Client selection policy) when warming is enabled and engages, then the
842/// holders nearest RTT first. A caller that walks it therefore gets ADR 037's
843/// exact fallback shape: the chosen proxy, then the next candidate, and finally
844/// the direct holder — routing around a proxy that declines or stalls without
845/// ever surfacing an error while a holder remains.
846///
847/// When no candidate holds the blob, the order is instead the reachable
848/// non-holders, nearest RTT first, as pull-through serve targets — see
849/// [`failover_order`] for why an empty holder set bootstraps rather than fails.
850pub(crate) async fn probe_and_order(
851    endpoint: &Endpoint,
852    candidates: &[NodeCandidate],
853    relay_hint: Option<&RelayUrl>,
854    hash: [u8; 32],
855    warming: ProxyWarmingParams,
856    slash_domain: &alloy::sol_types::Eip712Domain,
857    round: ProbeRound,
858) -> anyhow::Result<ResolvedTargets> {
859    let timestamp_us = micros_now();
860    // Probe concurrently in one task. `probe_once`'s internal timeout bounds
861    // each leg; a candidate that sheds the probe is probed again after a short
862    // wait (`probe_candidate`). Each probe verifies and classifies its own
863    // answer, so the round ends only on a verified holder. Each probe owns its
864    // inputs, so a streamed round's pending probes outlive this call.
865    let started = std::time::Instant::now();
866    let probes = candidates.iter().cloned().map(|cand| {
867        let target = probe_target(&cand, relay_hint.cloned());
868        let endpoint = endpoint.clone();
869        let slash_domain = slash_domain.clone();
870        async move {
871            let res = probe_candidate(&endpoint, target, hash, timestamp_us).await;
872            log_probe_result(&cand, &res, started);
873            classify_probe(&cand, res, hash, timestamp_us, &slash_domain)
874        }
875    });
876    let (outcomes, tail) = settle_probes(
877        probes,
878        round.grace(),
879        round.cold_grace(),
880        ProbeOutcome::is_holder,
881        ProbeOutcome::is_answer,
882    )
883    .await;
884
885    let ProbeTally {
886        holders,
887        non_holders,
888        probed_samples,
889        unreachable,
890        rate_limited,
891        unverifiable,
892    } = ProbeTally::of(outcomes);
893
894    // Terminal only when NOTHING can serve — no holder AND no reachable
895    // non-holder to pull through. An empty holder set alone is not terminal: a
896    // cold blob is origin-only with zero cache holders, the normal first-fetch
897    // state (#1911), so as long as one bonded node answered it is a serve target.
898    if holders.is_empty() && non_holders.is_empty() {
899        return Err(no_serve_target_error(
900            candidates.len(),
901            unreachable,
902            rate_limited,
903            unverifiable,
904        ));
905    }
906
907    if holders.is_empty() {
908        // No cache holder, but reachable non-holders can serve via pull-through.
909        // Log why the fetch is talking to nodes that answered `has_blob:false`.
910        tracing::info!(
911            "no node that has answered holds the blob in cache; starting on {} reachable \
912             bonded non-holder(s) as pull-through serve targets — a node serves an authorized \
913             miss from its own origin (#1911), and a holder that answers later joins the fetch",
914            non_holders.len()
915        );
916    }
917
918    let size_hint = nearest_size_hint(&holders);
919    let late = late_slot(round, tail, &holders, warming);
920    let ordered = failover_order(holders, &non_holders, warming);
921    if let Some((node_id, proxy_rtt, best_holder_rtt)) = ordered.warming_lead {
922        tracing::info!(
923            "proxy-warming: routing through nearer non-holder {node_id} ({proxy_rtt:.1}ms) \
924             instead of the best holder ({best_holder_rtt:.1}ms) to seed a regional copy, \
925             falling back to the holder if it declines (ADR 037)",
926        );
927    }
928    Ok(ResolvedTargets {
929        candidates: ordered.order,
930        coverage_by_node: ordered.coverage_by_node,
931        probed_samples,
932        pinned: false,
933        size_hint,
934        late,
935    })
936}
937
938/// A streamed round's pending probes as its [`LateSlot`], with the best RTT
939/// among the `holders` it returned. Empty for a settled round or an empty
940/// tail.
941///
942/// A task drives the probes from here on, so they keep running and timing
943/// while the fetch unlocks its signer and opens its pool before anything reads
944/// the tail. Their answers wait in a channel. The task stops when the tail
945/// stream is dropped, so no probe outlives the fetch that adopted it.
946fn late_slot<F>(
947    round: ProbeRound,
948    tail: futures_util::stream::FuturesUnordered<F>,
949    holders: &[discovery::Probed],
950    warming: ProxyWarmingParams,
951) -> LateSlot
952where
953    F: std::future::Future<Output = ProbeOutcome> + Send + 'static,
954{
955    use futures_util::StreamExt as _;
956    if round != ProbeRound::Stream || tail.is_empty() {
957        return LateSlot::default();
958    }
959    let best_holder_rtt_ms = holders
960        .iter()
961        .map(|h| h.rtt_ms)
962        .fold(f64::INFINITY, f64::min);
963    let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
964    let task = AbortOnDropHandle::new(tokio::spawn(tail.for_each(move |outcome| {
965        // A send fails only once the tail stream is gone, which aborts this
966        // task too.
967        let _ = tx.send(outcome);
968        std::future::ready(())
969    })));
970    let answers = futures_util::stream::unfold((rx, task), |(mut rx, task)| async move {
971        rx.recv().await.map(|outcome| (outcome, (rx, task)))
972    });
973    LateSlot::new(LateProbes {
974        tail: Box::pin(answers),
975        best_holder_rtt_ms,
976        warming,
977    })
978}
979
980/// The size hint of the nearest holder that gave one: the fetch's first size
981/// claim ([`ResolvedTargets::size_hint`]).
982fn nearest_size_hint(holders: &[discovery::Probed]) -> Option<u64> {
983    holders
984        .iter()
985        .filter(|h| h.total_bytes.is_some())
986        .min_by(|a, b| a.rtt_ms.total_cmp(&b.rtt_ms))
987        .and_then(|h| h.total_bytes)
988}
989
990/// The ordered provider-failover list plus, when a proxy leads it, that proxy's
991/// identity for the operator log line. Split from [`probe_and_order`] as a pure
992/// function so the ordering is unit-tested without live probing.
993struct FailoverOrder {
994    /// The candidates to try in turn: proxy-warming non-holders first (nearest
995    /// RTT first) when warming engages, then the holders nearest RTT first — or,
996    /// when no holder answered, the reachable non-holders nearest RTT first as
997    /// pull-through serve targets (#1911).
998    order: Vec<NodeCandidate>,
999    /// `Some((proxy_node_id, proxy_rtt_ms, best_holder_rtt_ms))` when a warming
1000    /// proxy is prepended; `None` when the list is just the holders.
1001    warming_lead: Option<(PublicKey, f64, f64)>,
1002    /// Each probed holder's measured [`decdn_protocol::Coverage`] (#1506),
1003    /// keyed by `node_id`. Proxy-warming candidates never appear here — they
1004    /// are non-holders by definition, so a lookup miss on them (and on any
1005    /// node this map otherwise has no entry for) means "no measured
1006    /// coverage", which the multi-source lane builder treats as a full
1007    /// holder rather than as a gap in the data.
1008    coverage_by_node: HashMap<PublicKey, decdn_protocol::Coverage>,
1009}
1010
1011/// Assemble the failover order (#1174, ADR 037 § Client selection policy) from
1012/// the probed `holders` and the `non_holders` — probed bonded nodes that
1013/// answered `has_blob:false`.
1014///
1015/// The holders form the backbone, nearest RTT first — and, absent proxy warming,
1016/// the whole list. When warming is enabled and the best holder is distant, the
1017/// non-holders that beat it by the margin are PREPENDED nearest first, so the
1018/// request routes through the nearest one (it serves via window-paced
1019/// pull-through and becomes the first regional copy) and falls over through the
1020/// remaining proxies to the direct holder. RTT-only ranking; never a gamble (an
1021/// empty proxy order leaves the list as just the holders).
1022///
1023/// When `holders` is empty the blob is cache-cold — its normal first-fetch state,
1024/// since every object is born origin-only with zero cache holders (#1911). The
1025/// order is then the `non_holders`, nearest RTT first, as real pull-through serve
1026/// targets: a node serves an authorized miss from its own origin (or node-to-node)
1027/// even when its cache store answers `has_blob:false`. This fallback is
1028/// independent of proxy warming — it is how a caching network bootstraps cold
1029/// content, not a latency optimization — so it applies whether warming is on or
1030/// off. `warming_lead` and `coverage_by_node` are both empty here,
1031/// since no holder answered.
1032fn failover_order(
1033    mut holders: Vec<discovery::Probed>,
1034    non_holders: &[discovery::WarmingCandidate],
1035    warming: ProxyWarmingParams,
1036) -> FailoverOrder {
1037    if holders.is_empty() {
1038        let mut cold = non_holders.iter().collect::<Vec<_>>();
1039        cold.sort_by(|a, b| a.rtt_ms.total_cmp(&b.rtt_ms));
1040        let order = cold
1041            .iter()
1042            .map(|c| NodeCandidate {
1043                node_id: c.node_id,
1044                eth_address: c.eth_address,
1045                // As for a proxy lead: `region_hint` never rides along on a
1046                // candidate the client assembled from probe RTTs alone.
1047                region_hint: None,
1048                // Registry addresses ride along so a cold-order target dials
1049                // directly (ADR 001 § Node Discovery).
1050                multiaddrs: c.multiaddrs.clone(),
1051            })
1052            .collect();
1053        return FailoverOrder {
1054            order,
1055            warming_lead: None,
1056            coverage_by_node: HashMap::new(),
1057        };
1058    }
1059    holders.sort_by(|a, b| a.rtt_ms.total_cmp(&b.rtt_ms));
1060    // Captured before `holders` is consumed into `order` below — each probed
1061    // holder's real coverage, for the multi-source lane builder (#1506).
1062    let coverage_by_node: HashMap<PublicKey, decdn_protocol::Coverage> = holders
1063        .iter()
1064        .map(|h| (h.candidate.node_id, h.coverage.clone()))
1065        .collect();
1066    let best_holder_rtt = holders
1067        .iter()
1068        .map(|h| h.rtt_ms)
1069        .fold(f64::INFINITY, f64::min);
1070    let proxy_order = if warming.enabled {
1071        discovery::proxy_warming_order(
1072            best_holder_rtt,
1073            warming.rtt_threshold_ms,
1074            warming.margin_ms,
1075            non_holders,
1076        )
1077    } else {
1078        Vec::new()
1079    };
1080    let warming_lead = proxy_order
1081        .first()
1082        .map(|nearest| (nearest.node_id, nearest.rtt_ms, best_holder_rtt));
1083    let proxies = proxy_order.iter().map(|proxy| NodeCandidate {
1084        node_id: proxy.node_id,
1085        eth_address: proxy.eth_address,
1086        // Region deliberately dropped rather than carried over: ADR 037
1087        // §"Ranking key is measured RTT only" forbids region from influencing
1088        // warming, and `region_hint` is only ever read by `select_candidates`'
1089        // pre-probe shortlist and operator logging. Leaving it unset keeps a
1090        // spoofed region from riding along.
1091        region_hint: None,
1092        // Registry addresses ride along so a warming proxy dials directly
1093        // (ADR 001 § Node Discovery).
1094        multiaddrs: proxy.multiaddrs.clone(),
1095    });
1096    let order = proxies
1097        .chain(holders.iter().map(|h| h.candidate.clone()))
1098        .collect();
1099    FailoverOrder {
1100        order,
1101        warming_lead,
1102        coverage_by_node,
1103    }
1104}
1105
1106/// Auto-discover the failover order to fetch `hash` from (#936): read the
1107/// active set from `CapacityBond`, take a random, region-first sample of
1108/// [`discovery::SELECT_K`] candidates, and [`probe_and_order`] them. Returns the
1109/// ordered candidate list `fetch` tries in turn (#1174).
1110/// Callers must have already unwrapped `chain.capacity_bond` into the
1111/// "auto-discovery needs `capacity_bond_address`" error, which is why the
1112/// address is a separate parameter rather than read back off `chain`.
1113async fn discover_provider(
1114    endpoint: &Endpoint,
1115    chain: &ResolvedChain,
1116    capacity_bond: Address,
1117    relay_hint: Option<&RelayUrl>,
1118    hash: [u8; 32],
1119    // The warming params and the registry deadline are both derived from the
1120    // same `args`, so they travel as `args` rather than as two more positional
1121    // parameters (clippy caps this function at 7).
1122    args: &cli::ClientFetchArgs,
1123    probe: ProbeOpts<'_>,
1124) -> anyhow::Result<ResolvedTargets> {
1125    let mark_probe_start = || {
1126        if let Some(timings) = probe.timings {
1127            timings.mark(Mark::ProbeStart);
1128        }
1129    };
1130    // Probe-less fast path: when the store already holds enough fresh,
1131    // unsuppressed candidates, skip the network probe round entirely and rank
1132    // by the store's own EWMA latency. Falls through to today's bootstrap +
1133    // probe path whenever the store can't back a full candidate set — never a
1134    // new failure mode, only a possible extra probe round.
1135    let peer_store = decdn_client::PeerStore::open(&chain.data_dir);
1136    let store_cfg = decdn_client::StoreConfig::default();
1137    if !args.rediscover
1138        && let Some(targets) =
1139            store_fast_path(&peer_store, &store_cfg, args.max_sources, now_secs_cli())
1140    {
1141        return Ok(targets);
1142    }
1143    // Registry read-skip (identity-fresh, latency-stale): when the store still
1144    // holds enough recently-confirmed identities, build the candidate set from
1145    // them and re-probe for fresh RTT, issuing NO `getRegisteredNodes`. This
1146    // decouples "skip the registry read" (bounded by the 24h identity horizon)
1147    // from "skip the probe" (bounded by the 10-min latency TTL, the fast path
1148    // above). Falls through to the registry read when too few identities are
1149    // fresh. `--rediscover` forces the read (checked above alongside the fast
1150    // path).
1151    if !args.rediscover {
1152        let cached = identity_fresh_candidates(&peer_store, &store_cfg, now_secs_cli());
1153        let selected = select_with_widening(
1154            cached,
1155            chain.region.as_deref(),
1156            chain.region_allowlist.as_slice(),
1157            &store_cfg,
1158        );
1159        if selected.len() >= store_cfg.min_fresh_candidates {
1160            let warming = ProxyWarmingParams::from_args(args);
1161            let slash_dom = slash_judge_domain(chain.chain_id, chain.slash_judge);
1162            mark_probe_start();
1163            let targets = probe_and_order(
1164                endpoint,
1165                &selected,
1166                relay_hint,
1167                hash,
1168                warming,
1169                &slash_dom,
1170                probe.round,
1171            )
1172            .await?;
1173            // Stats-only harvest, never identity — identity refreshes ONLY on a
1174            // real registry read (same reasoning as the `Bootstrap::Cached`
1175            // path): re-`upsert_identity` here would "confirm" identity against
1176            // the store itself, reset `identity_seen_at_secs`, and keep the 24h
1177            // refresh horizon from ever expiring.
1178            drop(spawn_harvest(
1179                &chain.data_dir,
1180                Vec::new(),
1181                targets.probed_samples.clone(),
1182            ));
1183            return Ok(targets);
1184        }
1185    }
1186    let bootstrap = discovery::bootstrap_nodes(
1187        &chain.rpc_url,
1188        capacity_bond,
1189        &chain.data_dir,
1190        args.discovery_cap(),
1191    )
1192    .await?;
1193    // Captured before `bootstrap` is consumed: the registry-outage fallback
1194    // (`Bootstrap::Cached`, the peer store's surviving identities) must
1195    // IGNORE `chain.region_allowlist` — the client is already
1196    // degraded to whatever the store still has, and narrowing that further
1197    // by region risks starving the fetch entirely over data that is already
1198    // possibly stale.
1199    let is_live_registry = matches!(bootstrap, discovery::Bootstrap::Live { .. });
1200    // `decdn-client` returns the provenance rather than logging it itself; a
1201    // silently stale peer list is exactly what the operator needs told, so
1202    // surface it here at `warn`.
1203    if let Some(warning) = bootstrap.warning() {
1204        tracing::warn!("{warning}");
1205    }
1206    let all = bootstrap.into_peers();
1207    if all.is_empty() {
1208        anyhow::bail!("no active nodes in the CapacityBond registry at {capacity_bond}");
1209    }
1210    let allow: &[decdn_protocol::Region] = if is_live_registry {
1211        chain.region_allowlist.as_slice()
1212    } else {
1213        &[]
1214    };
1215    let selected = select_with_widening(all, chain.region.as_deref(), allow, &store_cfg);
1216    let warming = ProxyWarmingParams::from_args(args);
1217    let slash_dom = slash_judge_domain(chain.chain_id, chain.slash_judge);
1218    mark_probe_start();
1219    let targets = probe_and_order(
1220        endpoint,
1221        &selected,
1222        relay_hint,
1223        hash,
1224        warming,
1225        &slash_dom,
1226        probe.round,
1227    )
1228    .await?;
1229    // Identity is harvested ONLY against a live registry read, and
1230    // `resolve_bootstrap` already does that: on a `Bootstrap::Live` read it
1231    // upserts+prunes identity for every returned node. So the harvest here
1232    // writes stats only and never identity. Two reasons:
1233    //   - On the `Bootstrap::Cached` outage path the returned peer set (`all`) IS
1234    //     the store's own surviving identities; re-`upsert_identity`-ing them would
1235    //     "confirm" identity against the store itself and reset
1236    //     `identity_seen_at_secs` — contradicting its meaning ("last confirmed
1237    //     against the registry") and keeping a departed node from ever becoming
1238    //     `identity_prunable` while outages recur.
1239    //   - On the live path it would be a redundant second identity write over
1240    //     what `resolve_bootstrap` already wrote.
1241    // The probed stats are harvested on both paths.
1242    //
1243    // Off the fetch's critical path: the handle is intentionally dropped,
1244    // never awaited here (tests await it directly for determinism).
1245    drop(spawn_harvest(
1246        &chain.data_dir,
1247        Vec::new(),
1248        targets.probed_samples.clone(),
1249    ));
1250    Ok(targets)
1251}
1252
1253/// Build a probe-less [`ResolvedTargets`] straight from the peer store: rank
1254/// every [`decdn_client::PeerRecord::selectable`] record by its
1255/// EWMA `latency_ms` (ascending, `None` sorts last), project each back to a
1256/// [`NodeCandidate`], and admit per-operator via
1257/// [`discovery::admit_sources`]. Returns `None` when fewer than
1258/// `cfg.min_fresh_candidates` records are selectable, or when admission still
1259/// leaves the set below that floor — either way the caller falls back to the
1260/// probe path unchanged. Takes no [`Endpoint`], so it structurally issues no
1261/// network probe.
1262fn store_fast_path(
1263    store: &decdn_client::PeerStore,
1264    cfg: &decdn_client::StoreConfig,
1265    max_sources: usize,
1266    now_secs: u64,
1267) -> Option<ResolvedTargets> {
1268    // Require BOTH a fresh, unsuppressed latency sample AND a non-prunable
1269    // identity — symmetric with `resolve_bootstrap`'s outage-fallback filter. A
1270    // stats-only placeholder that `record_sample` created for an unknown peer
1271    // carries `eth_address: Address::ZERO` and `identity_seen_at_secs == 0`, so
1272    // it is always `identity_prunable` and can never project a `0x0` payment
1273    // lane into a fetch here.
1274    let mut fresh: Vec<_> = store
1275        .load_all()
1276        .into_iter()
1277        .filter(|r| r.selectable(now_secs, cfg) && !r.identity_prunable(now_secs, cfg))
1278        .collect();
1279    if fresh.len() < cfg.min_fresh_candidates {
1280        return None;
1281    }
1282    fresh.sort_by(|a, b| {
1283        a.latency_ms
1284            .unwrap_or(f64::MAX)
1285            .total_cmp(&b.latency_ms.unwrap_or(f64::MAX))
1286    });
1287    let ordered: Vec<NodeCandidate> = fresh
1288        .iter()
1289        .map(decdn_client::PeerRecord::as_candidate)
1290        .collect();
1291    let candidates = discovery::admit_sources(ordered, max_sources);
1292    if candidates.len() < cfg.min_fresh_candidates {
1293        return None;
1294    }
1295    Some(ResolvedTargets {
1296        candidates,
1297        coverage_by_node: HashMap::new(),
1298        probed_samples: Vec::new(),
1299        pinned: false,
1300        size_hint: None,
1301        late: LateSlot::default(),
1302    })
1303}
1304
1305/// Gather the cached peers whose identity is fresh enough to build a candidate
1306/// set from without re-reading the registry: identity confirmed within
1307/// [`decdn_client::StoreConfig::identity_refresh_secs`], not
1308/// failure-suppressed, and not past the prune horizon. Latency freshness is NOT
1309/// required — this is exactly the identity-fresh/latency-stale case that
1310/// [`store_fast_path`] rejects; the caller re-probes these records for a fresh
1311/// RTT. Returns them projected to [`NodeCandidate`], unranked (the probe orders
1312/// them); empty when none qualify, letting the caller fall through to the
1313/// registry read. `bundle pull` reads its run-wide candidate list from the same
1314/// set ([`super::bundle_pull`]).
1315pub(crate) fn identity_fresh_candidates(
1316    store: &decdn_client::PeerStore,
1317    cfg: &decdn_client::StoreConfig,
1318    now_secs: u64,
1319) -> Vec<NodeCandidate> {
1320    store
1321        .load_all()
1322        .into_iter()
1323        .filter(|r| {
1324            r.identity_fresh(now_secs, cfg)
1325                && !r.identity_prunable(now_secs, cfg)
1326                && !r.failure_suppressed(now_secs, cfg)
1327        })
1328        .map(|r| r.as_candidate())
1329        .collect()
1330}
1331
1332/// The persisted pool's settlement token, or `None` when the owner has no pool
1333/// row yet. The token is `PaymentPool.usdc()`, immutable per contract, so a
1334/// persisted [`BuyerPoolState::token`] equals a fresh on-chain read — letting a
1335/// repeat fetch skip the `usdc()` `eth_call`. Only a first-ever pool falls
1336/// through to the on-chain read.
1337///
1338/// "Immutable per contract" is the whole premise, so the row has to be on the
1339/// deployment being read: a row from another `PaymentPool` deployment caches
1340/// that deployment's `usdc()`, and answering with it would approve and price
1341/// against the wrong token. Such a row reads as absent and the caller pays the
1342/// round-trip.
1343fn cached_pool_token(
1344    store: &RedbBuyerPoolStore,
1345    self_address: Address,
1346    deployment: Deployment,
1347) -> anyhow::Result<Option<Address>> {
1348    Ok(store
1349        .get_by_owner(self_address)?
1350        .filter(|state| state.is_on(deployment))
1351        .map(|state| state.token))
1352}
1353
1354/// Region-filter a candidate set to at most [`discovery::SELECT_K`], then
1355/// progressively widen: a too-thin region allowlist must never starve the fetch,
1356/// so re-run unfiltered over the same set when the filtered pool falls below the
1357/// store's freshness floor. Shared by the live registry path and the
1358/// registry-read-skip path so both rank identically.
1359fn select_with_widening(
1360    candidates: Vec<NodeCandidate>,
1361    region: Option<&str>,
1362    allow: &[decdn_protocol::Region],
1363    cfg: &decdn_client::StoreConfig,
1364) -> Vec<NodeCandidate> {
1365    // Only a non-empty allowlist can trigger the widening re-run, so keep the
1366    // unfiltered copy only in that case — the common empty-allowlist path (no
1367    // filtering, no widening possible) does no extra allocation.
1368    let widen_fallback = (!allow.is_empty()).then(|| candidates.clone());
1369    let selected =
1370        discovery::select_candidates_filtered(candidates, region, discovery::SELECT_K, allow);
1371    if let Some(widened) = widen_fallback
1372        && selected.len() < cfg.min_fresh_candidates
1373    {
1374        return discovery::select_candidates_filtered(widened, region, discovery::SELECT_K, &[]);
1375    }
1376    selected
1377}
1378
1379/// Persist a discovery session's identity + probe stats off the fetch's
1380/// critical path: `upsert_identity` for every registry candidate (whether or
1381/// not it was probed), `record_sample` for each probed triple, then
1382/// `prune_and_cap` to bound store growth. Never awaited on the fetch path —
1383/// the returned handle exists so tests can await it for determinism.
1384pub(crate) fn spawn_harvest(
1385    data_dir: &Path,
1386    registry: Vec<NodeCandidate>,
1387    probed: Vec<(PublicKey, f64, u64)>,
1388) -> tokio::task::JoinHandle<()> {
1389    let dir = data_dir.to_path_buf();
1390    tokio::spawn(async move {
1391        let store = decdn_client::PeerStore::open(&dir);
1392        let cfg = decdn_client::StoreConfig::default();
1393        harvest(&store, &registry, probed, now_secs_cli(), &cfg);
1394    })
1395}
1396
1397/// How many per-peer record writes one [`harvest`] made, and how many failed.
1398#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1399struct HarvestTally {
1400    /// Identity and sample writes attempted.
1401    writes: usize,
1402    /// Of those, the writes the store refused.
1403    failed: usize,
1404}
1405
1406/// The body of [`spawn_harvest`]. Probe samples are the only input to the
1407/// peer store's latency ranking, so a refused write is logged, not dropped:
1408/// each one at `debug` with its peer, and once at `warn` when the store
1409/// refuses every record write, because the ranking then stays frozen.
1410fn harvest(
1411    store: &decdn_client::PeerStore,
1412    registry: &[NodeCandidate],
1413    probed: Vec<(PublicKey, f64, u64)>,
1414    now: u64,
1415    cfg: &decdn_client::StoreConfig,
1416) -> HarvestTally {
1417    let mut tally = HarvestTally {
1418        writes: 0,
1419        failed: 0,
1420    };
1421    let mut file = |node_id: &PublicKey, what: &str, written: anyhow::Result<()>| {
1422        tally.writes = tally.writes.saturating_add(1);
1423        if let Err(e) = written {
1424            tally.failed = tally.failed.saturating_add(1);
1425            tracing::debug!(%node_id, "could not record {what} in the peer store: {e:#}");
1426        }
1427    };
1428    for cand in registry {
1429        file(
1430            &cand.node_id,
1431            "an identity",
1432            store.upsert_identity(cand, now),
1433        );
1434    }
1435    for (node_id, rtt_ms, rate) in probed {
1436        let written = store.record_sample(&node_id, rtt_ms, rate, now, cfg);
1437        file(&node_id, "a probe sample", written);
1438    }
1439    if let Err(e) = store.prune_and_cap(now, cfg) {
1440        tracing::debug!("could not prune the peer store: {e:#}");
1441    }
1442    if tally.writes > 0 && tally.failed == tally.writes {
1443        tracing::warn!(
1444            writes = tally.writes,
1445            "the peer store refused every write of this probe round; \
1446             its latency ranking stays at its last state"
1447        );
1448    }
1449    tally
1450}
1451
1452/// Seconds since the Unix epoch, saturating to 0 on a clock before the epoch
1453/// (never on this platform in practice) rather than panicking.
1454pub(crate) fn now_secs_cli() -> u64 {
1455    SystemTime::now()
1456        .duration_since(UNIX_EPOCH)
1457        .map_or(0, |d| d.as_secs())
1458}
1459
1460/// A holder resolution that only the user's configuration can fix: a missing
1461/// `capacity_bond_address`, a malformed `--node-id` or `--provider-address`,
1462/// or probe signatures that do not recover to their operators.
1463#[derive(Debug)]
1464pub(crate) struct ResolveConfigFault(pub(crate) String);
1465
1466impl std::fmt::Display for ResolveConfigFault {
1467    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1468        f.write_str(&self.0)
1469    }
1470}
1471
1472impl std::error::Error for ResolveConfigFault {}
1473
1474/// The holders a command starts from. A resolution the configuration breaks
1475/// ([`ResolveConfigFault`]) ends the command. Any other failure, such as a
1476/// sole holder that is down at start, starts with no holder: the acquire loop
1477/// then discovers with backoff under the stop policy.
1478///
1479/// # Errors
1480///
1481/// A [`ResolveConfigFault`].
1482pub(crate) fn holders_or_none(
1483    resolved: anyhow::Result<ResolvedTargets>,
1484) -> anyhow::Result<ResolvedTargets> {
1485    match resolved {
1486        Ok(targets) => Ok(targets),
1487        Err(err) if err.downcast_ref::<ResolveConfigFault>().is_some() => Err(err),
1488        Err(err) => {
1489            tracing::warn!(
1490                error = %decdn_common::redact::sanitize_err_chain(&err),
1491                "no holder resolved yet; the fetch keeps looking"
1492            );
1493            Ok(ResolvedTargets {
1494                candidates: Vec::new(),
1495                coverage_by_node: HashMap::new(),
1496                probed_samples: Vec::new(),
1497                pinned: false,
1498                size_hint: None,
1499                late: LateSlot::default(),
1500            })
1501        }
1502    }
1503}
1504
1505/// The ordered holder list plus what discovery learned about each holder.
1506pub(crate) struct ResolvedTargets {
1507    /// The candidates, nearest first (#1174).
1508    pub(crate) candidates: Vec<NodeCandidate>,
1509    /// Each probed holder's measured [`decdn_protocol::Coverage`] (#1506),
1510    /// keyed by `node_id` — what a holder's lane is planned against instead of
1511    /// assuming every candidate is a full holder. Empty on the pinned
1512    /// `--node-id` path, where nothing was probed; a lookup miss there (as
1513    /// everywhere else) reads as "no measured coverage", a full holder.
1514    pub(crate) coverage_by_node: HashMap<PublicKey, decdn_protocol::Coverage>,
1515    /// `(node_id, rtt_ms, rate_per_mb)` for each holder that answered a probe
1516    /// this fetch — harvested into the peer store.
1517    pub(crate) probed_samples: Vec<(PublicKey, f64, u64)>,
1518    /// The one candidate is the `--node-id` the user pinned. It counts as a
1519    /// holder, as a probe-reported holder does.
1520    pub(crate) pinned: bool,
1521    /// The blob size the nearest probed holder reported, an unsigned hint
1522    /// ([`discovery::Probed::total_bytes`]). `None` when nothing was probed
1523    /// (a pinned `--node-id`, the peer-store fast path) or no holder knew it.
1524    pub(crate) size_hint: Option<u64>,
1525    /// The probes a streamed round left pending ([`ProbeRound::Stream`]).
1526    /// Empty on every other path.
1527    pub(crate) late: LateSlot,
1528}
1529
1530/// The one holder of a pinned `--node-id` at `provider`. Nothing was probed,
1531/// so it carries no size hint.
1532fn pinned_targets(node_id: PublicKey, provider: Address) -> ResolvedTargets {
1533    ResolvedTargets {
1534        candidates: vec![NodeCandidate {
1535            node_id,
1536            eth_address: provider,
1537            region_hint: None,
1538            // A `--node-id`-pinned target takes its direct address from
1539            // `--addr` at the dial site, not from the registry.
1540            multiaddrs: Bytes::new(),
1541        }],
1542        // Nothing was probed on this path, so no holder coverage was
1543        // measured: the pinned node is a full holder.
1544        coverage_by_node: HashMap::new(),
1545        // Nothing was probed on this path, so there is nothing to harvest.
1546        probed_samples: Vec::new(),
1547        pinned: true,
1548        // Nothing was probed, so the first claim comes from a header.
1549        size_hint: None,
1550        late: LateSlot::default(),
1551    }
1552}
1553
1554/// Resolve the holders to fetch from (#1174): the explicit `--node-id`
1555/// (requiring `--provider-address`) as the one holder, or auto-discovery (#936)
1556/// when `--node-id` is omitted (deriving each provider from its node's registry
1557/// entry), nearest first. The acquire loop stripes across them; a set that goes
1558/// stale is refreshed by a later discovery with `--rediscover` forced, which
1559/// reads the registry rather than the peer store. `probe` says where the probe
1560/// round's start is recorded and how the round ends.
1561pub(crate) async fn resolve_target_node(
1562    args: &cli::ClientFetchArgs,
1563    chain: &ResolvedChain,
1564    endpoint: &Endpoint,
1565    relays: &[RelayUrl],
1566    hash: [u8; 32],
1567    probe: ProbeOpts<'_>,
1568) -> anyhow::Result<ResolvedTargets> {
1569    if let Some(raw) = &args.node_id {
1570        // No reachability pre-check: the endpoint is discovery-enabled, so a
1571        // node-id resolves via `[network.discovery]` / `presets::N0` (plus its
1572        // default relays) even without `--addr` or configured relays. `clap`
1573        // guarantees `--provider-address` is present alongside `--node-id`.
1574        // A pinned node is its own only holder: a fault cools it and the fetch
1575        // waits for it to return.
1576        let config = |e: anyhow::Error| anyhow::Error::new(ResolveConfigFault(format!("{e:#}")));
1577        let node_id = PublicKey::from_str(raw)
1578            .map_err(|e| config(anyhow::anyhow!("invalid --node-id {raw:?}: {e}")))?;
1579        let provider_raw = args.provider_address.as_deref().ok_or_else(|| {
1580            config(anyhow::anyhow!(
1581                "--provider-address is required with --node-id"
1582            ))
1583        })?;
1584        let provider =
1585            super::chain_ctx::parse_address(provider_raw, "--provider-address").map_err(config)?;
1586        return Ok(pinned_targets(node_id, provider));
1587    }
1588
1589    let capacity_bond = chain.capacity_bond.ok_or_else(|| {
1590        anyhow::Error::new(ResolveConfigFault(
1591            "auto-discovery needs capacity_bond_address (--capacity-bond-address or \
1592             blockchain.capacity_bond_address), or pass --node-id to dial directly"
1593                .to_string(),
1594        ))
1595    })?;
1596    // `--timeout-ms` bounds the registry read (#1349). Nothing did before: its
1597    // retry schedule alone can burn 36 s, so `decdn fetch --timeout-ms 5000`
1598    // could sit far longer than 5 s before the transfer it caps had started.
1599    //
1600    // The bound is applied INSIDE `bootstrap_nodes` (around the read alone)
1601    // rather than wrapped around discovery from out here. Wrapping from out
1602    // here also cancels the ADR 012 § Bootstrap step 4 peer-store fallback,
1603    // so a client with a usable peer store would be handed a hard failure
1604    // instead of the degraded-but-working fetch the store exists to provide.
1605    let order = discover_provider(
1606        endpoint,
1607        chain,
1608        capacity_bond,
1609        relays.first(),
1610        hash,
1611        args,
1612        probe,
1613    )
1614    .await?;
1615    if !order.candidates.is_empty() {
1616        // A header event, then one event per candidate — the log-side shape of
1617        // the previous per-line stderr listing.
1618        tracing::info!("discovered {} node(s):", order.candidates.len());
1619        for c in &order.candidates {
1620            tracing::info!(
1621                "  * {} / {} / {}",
1622                c.region_hint.as_ref().map_or("??", |r| r.as_str()),
1623                node_id_tail(&c.node_id),
1624                short_address(&c.eth_address),
1625            );
1626        }
1627    }
1628    Ok(order)
1629}
1630
1631/// Last 4 hex characters of a node id, for a compact operator-facing listing.
1632///
1633/// The full 64-hex id is unwieldy in a per-line summary; the tail is enough to
1634/// tell candidates apart at a glance. Falls back to the full string only if it
1635/// is somehow shorter than 4 characters (it never is for a `PublicKey`).
1636fn node_id_tail(node_id: &PublicKey) -> String {
1637    let s = node_id.to_string();
1638    s.get(s.len().saturating_sub(4)..).unwrap_or(&s).to_owned()
1639}
1640
1641/// Shorten an Ethereum address to `0x` + first 4 + `...` + last 4 hex
1642/// characters (e.g. `0xa43d...fdCe`), preserving the checksummed casing.
1643///
1644/// Returns the full string unchanged if it is unexpectedly short.
1645fn short_address(addr: &Address) -> String {
1646    let s = addr.to_string();
1647    match (s.get(..6), s.get(s.len().saturating_sub(4)..)) {
1648        (Some(head), Some(tail)) if s.len() > 10 => format!("{head}...{tail}"),
1649        _ => s,
1650    }
1651}
1652
1653/// Reconnect an opaque `delivery refused: NotFound` to its likely cause(s).
1654///
1655/// Two causes are checked, and BOTH may be attached: the pool cannot cover what
1656/// the node reserves before serving, and/or the request carried no ADR 005
1657/// client binding. They are independent — an unbound fetch on a drained pool is
1658/// both, and fixing only the one we happened to name first leaves the retry
1659/// failing identically. An unbound request (no `blockchain.capacity_bond_address`,
1660/// so nothing to sign) cannot authorize the node to reactively pull a
1661/// cache-missed blob from its origin, so the node returns a bare `NotFound` —
1662/// indistinguishable, without this, from a genuinely absent/blacklisted/wrong
1663/// hash. Scoped to `NotFound`, and to [`NoSourceHasBlob`] (every source said
1664/// `NotFound`) — a size or blacklist refusal is fixed by neither cause — so
1665/// every other error is returned verbatim.
1666pub(crate) fn annotate_unbound_cache_miss(err: anyhow::Error, ctx: &PoolContext) -> anyhow::Error {
1667    let refused = err
1668        .downcast_ref::<UpstreamRefused>()
1669        .filter(|refused| matches!(refused.error(), StreamError::NotFound));
1670    if refused.is_none() && err.downcast_ref::<NoSourceHasBlob>().is_none() {
1671        return err;
1672    }
1673
1674    // Both causes are checked and BOTH may attach. They are independent — an
1675    // unbound fetch on a drained pool is both — and naming only the first
1676    // leaves the user fixing one and retrying into the other.
1677    let mut causes: Vec<String> = Vec::new();
1678
1679    // Cause (a): our pool cannot cover what the node reserves before serving.
1680    // The signed refusal carries the node's quoted `rate_per_mb`, and the
1681    // pool's own deposit and cumulative paid amount on this lane are right
1682    // here, so the comparison uses only OUR pool and data the node already
1683    // sent us — it does not weaken the deliberate collapse of seven reject
1684    // reasons onto `NotFound` (which exists so a prober cannot map other
1685    // clients' balances).
1686    //
1687    // The node's pre-flight reservation is one chunk (the ramp floor); the window
1688    // only widens as this pool pays, so one chunk is the true lower bound on what
1689    // it reserves before serving. Estimated with the fixed `CHUNK_BYTES`, since we
1690    // cannot read the node's config — and because `CHUNK_BYTES == BYTES_PER_MB`,
1691    // that estimate is exactly the quoted per-MB rate. The miss direction is "we
1692    // stay silent when we could have spoken" — never a fabricated shortfall — so
1693    // it is phrased as a possibility and as a lower bound.
1694    if let Some(quoted_rate) = refused
1695        .and_then(UpstreamRefused::evidence)
1696        .map(|resp| resp.body.rate_per_mb)
1697    {
1698        let headroom = ctx.deposit.saturating_sub(ctx.prior_amount);
1699        let estimate = min_payment(decdn_protocol::client::CHUNK_BYTES, quoted_rate);
1700        if quoted_rate > 0 && headroom < estimate {
1701            causes.push(format!(
1702                "this pool's remaining deposit ({headroom}) is below the ~{estimate} the node \
1703                 reserves before serving at its quoted rate of {quoted_rate} per MB. The fetch \
1704                 path already auto-refills below a low-water mark, so reaching this means the \
1705                 configured working deposit is itself too small: raise \
1706                 `--working-deposit-micro-usdc` (or `blockchain.buyer_working_deposit_micro_usdc`) \
1707                 and retry"
1708            ));
1709        }
1710    }
1711
1712    // Cause (b): no binding, so the node would not reactively pull for us.
1713    if ctx.client_binding.is_none() {
1714        causes.push(
1715            "no client identity binding was sent because \
1716             blockchain.capacity_bond_address is unset, so the node could not \
1717             reactively pull this cache-missed blob from its origin; set \
1718             blockchain.capacity_bond_address to enable reactive pull-through"
1719                .to_string(),
1720        );
1721    }
1722
1723    match causes.len() {
1724        0 => err,
1725        1 => err.context(causes.concat()),
1726        // Numbered rather than joined with "and": each clause is a full sentence
1727        // with its own remedy, so a reader needs to see they are separate fixes.
1728        n => err.context(format!(
1729            "{n} possible causes: {}",
1730            causes
1731                .iter()
1732                .enumerate()
1733                .map(|(i, c)| format!("({}) {c}", i + 1))
1734                .collect::<Vec<_>>()
1735                .join("; ")
1736        )),
1737    }
1738}
1739
1740/// Settles a drive that is dropped before it returns, as a Ctrl-C does.
1741///
1742/// A drive persists its lanes' voucher watermarks after it returns. A dropped
1743/// drive never gets there, so this guard runs `settle` from its `Drop` instead:
1744/// the lanes' paid bytes are queued for recording, at the armed (HIGH)
1745/// settlement ([`queue_face_watermarks`]), and the next run's vouchers continue
1746/// from them. A drive that returns calls [`Self::disarm`] and settles on its
1747/// normal path.
1748pub(crate) struct SettleOnDrop<F: FnOnce()> {
1749    /// The settle to run on drop; `None` once disarmed.
1750    settle: Option<F>,
1751}
1752
1753impl<F: FnOnce()> SettleOnDrop<F> {
1754    /// Arm `settle` for a drop before [`Self::disarm`].
1755    pub(crate) const fn new(settle: F) -> Self {
1756        Self {
1757            settle: Some(settle),
1758        }
1759    }
1760
1761    /// The drive returned: its normal path settles, so the drop does nothing.
1762    pub(crate) fn disarm(mut self) {
1763        self.settle = None;
1764    }
1765}
1766
1767impl<F: FnOnce()> Drop for SettleOnDrop<F> {
1768    fn drop(&mut self) {
1769        if let Some(settle) = self.settle.take() {
1770            settle();
1771        }
1772    }
1773}
1774
1775/// Persist what the pool lane paid, warning rather than masking the fetch
1776/// outcome.
1777///
1778/// Shared by the single- and multi-source paths: the bytes were paid for either
1779/// way, and a failure to record that only risks a rejected reuse next time.
1780fn persist_watermark(
1781    store: &RedbBuyerPoolStore,
1782    owner: Address,
1783    pool_id: PoolId,
1784    lane: LaneKey,
1785    progress: &VoucherProgress,
1786) {
1787    let Some(write) = ProgressWrite::of(progress) else {
1788        return;
1789    };
1790    let label = match write {
1791        ProgressWrite::Rebase { .. } => "rebased",
1792        ProgressWrite::Advance { .. } => "advanced",
1793        _ => "recorded",
1794    };
1795    let outcome = write.apply(store, owner, pool_id, lane);
1796    // A non-`Advanced` outcome (unknown pool / replaced owner slot / regression)
1797    // means the watermark did NOT move — same hazard as a backend error — so
1798    // surface it too rather than dropping it on the floor. A lost rebase write
1799    // heals itself: the next run signs from the old anchor, draws `Underpaid`
1800    // again, and rebases again.
1801    let (bytes_delivered, amount) = progress.totals();
1802    match outcome {
1803        Ok(AdvanceOutcome::Advanced) => {}
1804        Ok(other) => tracing::warn!(
1805            write = label,
1806            %bytes_delivered,
1807            %amount,
1808            "{label} voucher watermark not persisted for pool {pool_id} (provider {}): \
1809             {other:?}; the next reuse may re-sign a stale watermark, which that provider \
1810             rejects",
1811            lane.provider
1812        ),
1813        Err(e) => tracing::warn!(
1814            write = label,
1815            %bytes_delivered,
1816            %amount,
1817            "failed to persist {label} voucher watermark for pool {pool_id} (provider {}): \
1818             {e}; the next reuse may re-sign a stale watermark, which that provider rejects",
1819            lane.provider
1820        ),
1821    }
1822}
1823
1824/// Fetch a single blob over `cdn/client/v1`, auto-opening/reusing the caller's
1825/// `PaymentPool` deposit, and write it atomically to `--output`. `config_path`
1826/// (the global `--config`) supplies relays (#935), discovery (#936), and chain
1827/// coordinates.
1828///
1829/// With `--node-id` the node is dialed explicitly. Without it, `fetch`
1830/// auto-discovers (#936): read the active set from `CapacityBond`, probe a
1831/// random, region-first sample of candidates, and derive `--provider-address` from each
1832/// candidate's registry entry, striping across them (#1174).
1833///
1834/// # Errors
1835///
1836/// [`Interrupted`] on Ctrl-C, [`decdn_client::GaveUp`] once the stop policy's
1837/// no-progress limit passes, or the fault that ended the fetch.
1838pub async fn fetch(args: &cli::FetchArgs, config_path: Option<&Path>) -> anyhow::Result<()> {
1839    let timings = FetchTimings::start();
1840    let hash = parse_hash(&args.hash)?;
1841    let common = &args.common;
1842    // Before any network or keystore work, reject a flag combination clap
1843    // cannot express, naming the flags the user actually typed.
1844    common.validate()?;
1845
1846    // Relays: `--relay-url` overrides `network.relay_urls` (#935). Discovery:
1847    // `[network.discovery]` composes operator resolution legs, else N0 (#936).
1848    let relays = client_endpoint::resolve_relays(common.relay_url.as_deref(), config_path)?;
1849    let disc = client_endpoint::client_discovery(config_path)?;
1850
1851    let file = load_file_config(config_path)?;
1852    let mut chain = resolve_chain(common, &file)?;
1853
1854    // Delegated adoption: `--capability`/`--capability-file` names a pool the
1855    // caller does NOT own and a capability authorizing this client's key to
1856    // spend against it. `None` => the unchanged self-owned pool path. Zeroes
1857    // the working deposit in `chain` (a delegate cannot fund an owner's pool).
1858    let grant = resolve_delegation_grant(common, &mut chain)?;
1859
1860    // The buyer-pool store, opened once and recorded into by open-or-reuse.
1861    // The guard runs here, before the keystore password prompt: the answer
1862    // depends only on the data dir, and a refusal must not cost a prompt first.
1863    // Shared, so a lane's watermark write runs off the runtime thread.
1864    let store = Arc::new(open_client_store_for_buy(
1865        &chain.data_dir,
1866        chain.data_dir_source,
1867        "fetch",
1868    )?);
1869
1870    // One discovery-enabled endpoint, reused for probing and the delivery dial.
1871    let endpoint = client_endpoint::client_endpoint(&relays, &disc).await?;
1872    timings.mark(Mark::Endpoint);
1873    // The body runs in `fetch_over`, so the endpoint closes on every exit —
1874    // success, an early return, an error, or a Ctrl-C — and its open connections
1875    // end cleanly instead of being aborted on drop. A Ctrl-C drops `fetch_over`
1876    // mid-transfer: each drive's drop guard queues what it paid for recording,
1877    // and the `.partial` is the next run's resume prefix.
1878    let mut interrupt = Interrupt::watch();
1879    // The command's peer-record and watermark writes, off the runtime thread.
1880    let writes = OrderedWrites::default();
1881    let result = tokio::select! {
1882        result = fetch_over(args, hash, &relays, &chain, grant, &store, &endpoint, &writes, &timings) => {
1883            result
1884        }
1885        () = interrupt.wait() => Err(Interrupted.into()),
1886    };
1887    let output = if wants_stdout(&args.output) {
1888        "stdout"
1889    } else {
1890        "file"
1891    };
1892    timings.log(output, result.is_ok());
1893    // On every exit, a Ctrl-C's included: what the drives queued is durable
1894    // before the command returns.
1895    writes.settle().await;
1896    endpoint.close().await;
1897    result
1898}
1899
1900/// The per-stream idle floor inside one paid leg: a leg whose stream opens no
1901/// byte, or delivers none, for this long ends as a source fault. The acquire
1902/// loop's lane watchdog ([`decdn_client::LANE_WATCHDOG`]) covers a lane that
1903/// makes slow progress.
1904pub(crate) const STALL_WINDOW: Duration = Duration::from_secs(30);
1905
1906/// The part of [`fetch`] that runs over its open `endpoint`: resolve the
1907/// holders, take the first size claim (the probe hint, or a header-only open
1908/// when there is none), then fetch it through the acquire loop. The command
1909/// ends on done, a fault only the user can fix, or the stop policy: a
1910/// terminal waits for Ctrl-C, a script gives up after 10 minutes
1911/// without progress, and `--give-up-after-secs` overrides both.
1912#[allow(clippy::too_many_lines, clippy::too_many_arguments)]
1913async fn fetch_over(
1914    args: &cli::FetchArgs,
1915    hash: [u8; 32],
1916    relays: &[RelayUrl],
1917    chain: &ResolvedChain,
1918    grant: Option<CapabilityGrant>,
1919    store: &Arc<RedbBuyerPoolStore>,
1920    endpoint: &Endpoint,
1921    writes: &OrderedWrites,
1922    timings: &FetchTimings,
1923) -> anyhow::Result<()> {
1924    let common = &args.common;
1925    // The holders to start from: the explicit `--node-id`, or auto-discovery.
1926    // Only a configuration fault ends the fetch here; with no holder yet, the
1927    // first open discovers them. The round streams: the fetch starts at the
1928    // first verified holder, and the holders that answer later join it.
1929    let probe = ProbeOpts {
1930        timings: Some(timings),
1931        round: ProbeRound::Stream,
1932    };
1933    let resolved =
1934        holders_or_none(resolve_target_node(common, chain, endpoint, relays, hash, probe).await)?;
1935    timings.mark(Mark::Resolved);
1936
1937    // Buyer signer (vouchers + the openPool/topUp tx). Loaded after selection so a
1938    // failed discovery never prompts for a keystore password. Password from env,
1939    // else `--keystore-password-file`, else TTY.
1940    let password = super::chain_ctx::read_keystore_password(
1941        &super::chain_ctx::password_sources(
1942            chain.keystore_password_file.as_deref(),
1943            PasswordUse::Unlock,
1944        ),
1945        "eth keystore password",
1946    )?
1947    .into_secret();
1948    let signer = Arc::new(load_signer(&chain.keystore, &password)?);
1949    timings.mark(Mark::Unlocked);
1950    let self_address = signer.address();
1951
1952    let rpc = provider::build_provider(&chain.rpc_url, &signer)?;
1953    let contract = PaymentPool::new(chain.payment_pool, rpc.clone());
1954    let voucher_dom = voucher_domain(chain.chain_id, chain.payment_pool);
1955    let slash_dom = slash_judge_domain(chain.chain_id, chain.slash_judge);
1956    // The pool's token is `PaymentPool.usdc()`, immutable per contract. A repeat
1957    // fetch already holds it in the persisted pool row, so read it there and skip
1958    // the eth_call; only a first-ever pool (no row) pays the `usdc()` round-trip.
1959    // The reuse branch of `open_or_reuse_pool` already trusts this same
1960    // `state.token`, so this only makes the top-level value consistent with it.
1961    let token = match cached_pool_token(store, self_address, chain.deployment())? {
1962        Some(token) => token,
1963        None => contract
1964            .usdc()
1965            .call()
1966            .await
1967            .map_err(|e| anyhow::Error::new(e).context("read PaymentPool.usdc()"))?,
1968    };
1969
1970    let max_blob_bytes = common.max_blob_mb.saturating_mul(1024 * 1024);
1971    // The namespace routing hint (ADR 005 § Namespace routing): `--namespace <id>`
1972    // → big-endian `uint256`; absent => `NO_NAMESPACE` (best-effort cache/DHT).
1973    let namespace_id = args
1974        .namespace
1975        .map_or(decdn_protocol::client::NO_NAMESPACE, |n| {
1976            alloy::primitives::U256::from(n).to_be_bytes()
1977        });
1978    // A node that accepts the connection and never answers is as dead as one that
1979    // stops mid-stream, so the same window answers both (#1134). The pull carries
1980    // no overall wall-clock cap: it completes for any blob size as long as the
1981    // upstream keeps feeding it bytes.
1982    let deadlines = PullDeadlines::new(STALL_WINDOW, STALL_WINDOW, 0)?;
1983    // One connection per node for the whole fetch: every lane and leg opens
1984    // its streams on it.
1985    let connections = Connections::new(endpoint.clone());
1986
1987    let clock = Arc::new(ProgressClock::new());
1988    let stop = StopPolicy::new(
1989        std::io::IsTerminal::is_terminal(&std::io::stderr()),
1990        common.give_up_after(),
1991        clock,
1992    );
1993    let health = Arc::new(PeerHealth::default());
1994    // One funding recovery state for the whole fetch, both passes included.
1995    let recovery = Arc::new(RecoveryGate::new());
1996    // Set once a funding recovery step replaced the pool: the second pass then
1997    // runs the remaining work against the new pool, and resumes the `.partial`.
1998    let mut replaced: Option<PoolReplaced> = None;
1999    loop {
2000        // Per pass: the funding facts, the lanes and their handles all name the
2001        // pool the pass pays from.
2002        let funding = RunFunding::default();
2003        // The shared pull/funding deps every lane borrows for the whole pass.
2004        let deps = DriveFetchDeps {
2005            endpoint,
2006            store,
2007            contract: &contract,
2008            rpc: &rpc,
2009            slash_dom: &slash_dom,
2010            self_address,
2011            token,
2012            chain,
2013            namespace_id,
2014            max_rate_per_mb: common.max_rate_per_mb,
2015            max_blob_bytes,
2016            deadlines,
2017            connections: &connections,
2018            writes,
2019            funding: &funding,
2020            timings: Some(timings),
2021        };
2022        // The fetch's lanes build concurrently and every one opens or reuses the
2023        // one pool on-chain, so the open-or-reuse runs one lane at a time, as it
2024        // does across a bundle pull's entries.
2025        let open_lock = tokio::sync::Mutex::new(());
2026        let sources = CliSources::new(
2027            &deps,
2028            common,
2029            relays,
2030            grant.as_ref(),
2031            &signer,
2032            &voucher_dom,
2033            Some(&open_lock),
2034            None,
2035            None,
2036            &recovery,
2037        );
2038        let holders = sources.holders_from(&resolved);
2039        if replaced.is_none() {
2040            timings.set_holders_start(holders.len());
2041        }
2042        // A fetch dropped by Ctrl-C queues every lane's vouchers for recording too.
2043        let on_drop = SettleOnDrop::new(|| sources.persist_watermarks_detached());
2044        let result = async {
2045            // The first size claim: the probe's hint, or a header-only open.
2046            let claim = sources.first_claim(hash, holders, &health, &stop).await?;
2047            if wants_stdout(&args.output) {
2048                return stream_to_stdout(
2049                    &deps,
2050                    &sources,
2051                    claim.holders,
2052                    Arc::clone(&health),
2053                    hash,
2054                    claim.total_bytes,
2055                    stop.clone(),
2056                    common.max_sources,
2057                )
2058                .await;
2059            }
2060            download_to_file(
2061                &deps,
2062                &sources,
2063                claim.holders,
2064                Arc::clone(&health),
2065                DownloadTarget {
2066                    hash,
2067                    total_bytes: claim.total_bytes,
2068                    dest: &args.output,
2069                    ranges: None,
2070                },
2071                &stop,
2072                common.max_sources,
2073            )
2074            .await
2075        }
2076        .await;
2077        on_drop.disarm();
2078        sources.persist_watermarks().await;
2079        if let Some(shortfall) = funding.shortfall() {
2080            eprintln!("warning: {shortfall}");
2081        }
2082        let result = result.map_err(|err| sources.annotate(err));
2083        match next_pass(result, &mut replaced, wants_stdout(&args.output)) {
2084            Some(pass_result) => return pass_result,
2085            None => recovery.start_next_pass(),
2086        }
2087    }
2088}
2089
2090/// What a fetch pass that ended with `result` does next: `None` to run the
2091/// remaining work again against the pool a funding recovery step just opened,
2092/// or the command's result (ADR 003 § Funding recovery).
2093///
2094/// A pass runs again at most once: a second replacement in one command ends it
2095/// "funding needed". A stream to stdout never runs again, because the bytes it
2096/// already wrote cannot be taken back; its replacement ends the command with
2097/// the new pool named. Once a pass replaced the pool, the closed pool's
2098/// remaining balance is named on stderr, with the reclaim command.
2099fn next_pass(
2100    result: anyhow::Result<()>,
2101    replaced: &mut Option<PoolReplaced>,
2102    to_stdout: bool,
2103) -> Option<anyhow::Result<()>> {
2104    let now_replaced = result
2105        .as_ref()
2106        .err()
2107        .and_then(|err| err.downcast_ref::<PoolReplaced>().copied());
2108    if let Some(new) = now_replaced
2109        && replaced.is_none()
2110        && !to_stdout
2111    {
2112        eprintln!("note: {new}");
2113        *replaced = Some(new);
2114        return None;
2115    }
2116    if let Some(earlier) = (*replaced).or(now_replaced) {
2117        eprintln!(
2118            "note: pool {} accepts no more funds and may still hold a balance; once its \
2119             dispute window ends, reclaim it with: decdn pool reclaim --pool {}",
2120            earlier.closed, earlier.closed
2121        );
2122    }
2123    Some(result.map_err(|err| match (now_replaced, *replaced) {
2124        (Some(_), Some(_)) => err.context(
2125            "funding needed: the pool this command opened accepts no more funds either; run \
2126             the command again",
2127        ),
2128        (Some(new), None) => err.context(format!(
2129            "pool {} is now the current pool; run the command again to stream from it",
2130            new.opened
2131        )),
2132        _ => err,
2133    }))
2134}
2135
2136/// Reconnect a delegated fetch that ended "funding needed" (ADR 003 §Funding
2137/// recovery) to the owner-side remedy: the delegate holds no wallet on this
2138/// pool, so it cannot `topUp`, raise its own cap, or mint itself a fresh
2139/// capability. A [`decdn_client::FundingNeeded::PublisherPool`] stop names the
2140/// pool owner's top-up: the capability still covers the fetch. A
2141/// [`decdn_client::FundingNeeded::NewCapability`] stop, any other
2142/// [`NoAffordableSource`] stop, or a funding voucher rejection
2143/// (`SpendingCapExhausted`, `CapabilityExpired`, `PoolExhausted`,
2144/// `SignerCapExhausted`) names a fresh capability for a new signer key,
2145/// because a registered signer's terms are write-once. A terminal `Underpaid`
2146/// (the resync budget ran out) gets its own next step. Any other error passes
2147/// through verbatim (a stall, a transport fault, a "no node will serve this",
2148/// or a `NotFound` already annotated by [`annotate_unbound_cache_miss`]).
2149pub(crate) fn annotate_delegated_exhaustion(err: anyhow::Error) -> anyhow::Error {
2150    use decdn_protocol::client::VoucherRejectReason;
2151
2152    let funding_rejection = err
2153        .downcast_ref::<UpstreamVoucherRejected>()
2154        .is_some_and(|rejected| {
2155            matches!(
2156                rejected.reason,
2157                VoucherRejectReason::SpendingCapExhausted
2158                    | VoucherRejectReason::CapabilityExpired
2159                    | VoucherRejectReason::PoolExhausted
2160                    | VoucherRejectReason::SignerCapExhausted
2161            )
2162        });
2163    let underpaid = err
2164        .downcast_ref::<UpstreamVoucherRejected>()
2165        .is_some_and(|rejected| rejected.reason == VoucherRejectReason::Underpaid);
2166    if let Some(&decdn_client::FundingNeeded::PublisherPool { pool }) = err.downcast_ref() {
2167        return err.context(format!(
2168            "ask the owner of pool {pool} to top it up: the capability still covers the fetch, \
2169             and a delegated client cannot top up a pool it does not own. A node refuses a \
2170             signer whose registered terms are spent the same way; once this signer key is \
2171             registered on-chain its terms are write-once, so the owner then issues a \
2172             capability for a new signer key"
2173        ));
2174    }
2175    if funding_rejection
2176        || err.downcast_ref::<NoAffordableSource>().is_some()
2177        || err.downcast_ref::<decdn_client::FundingNeeded>().is_some()
2178    {
2179        err.context(
2180            "funding needed: no provider serves this capability at its current funding. Ask \
2181             the pool owner to top up the pool or issue a fresh capability; once this signer \
2182             key is registered on-chain its terms are write-once, so the fresh capability must \
2183             name a new signer key (a delegated client cannot top up a pool it does not own)",
2184        )
2185    } else if underpaid {
2186        err.context(
2187            "the node kept refusing this lane's vouchers as underpaid after the resync attempts \
2188             ran out — retry the fetch; a lane with no accepted voucher yet means the signed \
2189             price is below the node's quote",
2190        )
2191    } else {
2192        err
2193    }
2194}
2195
2196/// Shared pull/funding deps every lane of one fetch borrows for its lifetime
2197/// ([`build_multi_lane`]). Mirrors the locals `fetch()`
2198/// and `bundle_pull::PullCtx` already hold so both callers build the same
2199/// lanes. Every field is a borrow or a `Copy` scalar.
2200pub(crate) struct DriveFetchDeps<'a, P> {
2201    pub(crate) endpoint: &'a Endpoint,
2202    /// Shared, so a lane's watermark write runs on the blocking pool
2203    /// ([`queue_face_watermarks`]).
2204    pub(crate) store: &'a Arc<RedbBuyerPoolStore>,
2205    pub(crate) contract: &'a PaymentPool::PaymentPoolInstance<P>,
2206    pub(crate) rpc: &'a P,
2207    pub(crate) slash_dom: &'a Eip712Domain,
2208    pub(crate) self_address: Address,
2209    /// The pool's settlement token (USDC), read once via `PaymentPool.usdc()`
2210    /// and threaded through rather than re-read per top-up.
2211    pub(crate) token: Address,
2212    pub(crate) chain: &'a ResolvedChain,
2213    pub(crate) namespace_id: [u8; 32],
2214    pub(crate) max_rate_per_mb: u64,
2215    pub(crate) max_blob_bytes: u64,
2216    pub(crate) deadlines: PullDeadlines,
2217    /// The command's one connection per node, shared by every lane of every
2218    /// fetch the command runs.
2219    pub(crate) connections: &'a Connections,
2220    /// The command's peer-record and watermark writes, run off the runtime
2221    /// thread in queue order across every fetch the command runs. Every
2222    /// ordered state write of a fetch goes through it.
2223    pub(crate) writes: &'a OrderedWrites,
2224    /// The run's funding facts, shared by every lane build and top-up of the
2225    /// run ([`RunFunding`]).
2226    pub(crate) funding: &'a RunFunding,
2227    /// The `decdn fetch` time-to-first-byte marks; `None` for a `bundle pull`.
2228    pub(crate) timings: Option<&'a FetchTimings>,
2229}
2230
2231/// The lane's shared ledger + context: from the run registry when bundle pull
2232/// supplies one (so every entry/chunk on the lane shares one monotonic issuer),
2233/// else a fresh pair for a standalone `decdn fetch`.
2234///
2235/// On a registry reuse (an already-registered lane, second+ touch), `ctx` — this
2236/// call's freshly built context, which may reflect an `open_or_reuse_pool`
2237/// low-water top-up the registry's stored context predates — is otherwise
2238/// discarded in favor of the shared handle. Deposit only ever grows via
2239/// top-ups and the on-chain deposit is the hard backstop, so raising the
2240/// shared handle's `deposit` toward this call's fresh reading is always safe;
2241/// reconciling it here stops the pool-wide gate from reading a stale, lower
2242/// deposit and refusing prematurely (`PoolExhausted`) once the true balance
2243/// has grown. The reconcile also runs (as a no-op) when this call's build won
2244/// the registry race, since the shared value already equals the fresh one.
2245fn lane_ledger(
2246    ledgers: Option<&LaneLedgers>,
2247    lane: LaneKey,
2248    ctx: PoolContext,
2249) -> (Arc<PoolLedger>, Arc<Mutex<PoolContext>>) {
2250    let seed = Cumulative {
2251        bytes: ctx.prior_bytes_delivered,
2252        amount: ctx.prior_amount,
2253    };
2254    // `U256` is `Copy`; capture the fresh deposit before `ctx` moves into the
2255    // `get_or_insert` build closure below.
2256    let fresh_deposit = ctx.deposit;
2257    match ledgers {
2258        Some(reg) => {
2259            let h = reg.get_or_insert(lane, || LaneHandle {
2260                ledger: Arc::new(PoolLedger::new(seed)),
2261                ctx: Arc::new(Mutex::new(ctx)),
2262            });
2263            {
2264                let mut g = h.ctx.lock().unwrap_or_else(PoisonError::into_inner);
2265                g.deposit = g.deposit.max(fresh_deposit);
2266            }
2267            (h.ledger, h.ctx)
2268        }
2269        None => (Arc::new(PoolLedger::new(seed)), Arc::new(Mutex::new(ctx))),
2270    }
2271}
2272
2273/// One built payment lane: the per-provider `PoolContext`/`PoolLedger` and the
2274/// [`PeerSource`] that pays with them. [`super::cli_sources::CliSources`] moves
2275/// the source into the [`decdn_client::StreamCandidate`] the acquire loop
2276/// consumes and keeps
2277/// a [`FaceLaneHandle`] on the same `ctx`/`ledger` for the watermark.
2278pub(crate) struct MultiLane<'a> {
2279    pub(crate) provider: Address,
2280    pub(crate) pool_id: PoolId,
2281    /// The lane's persisted prior amount — the baseline
2282    /// [`persist_watermark`] computes the advance against.
2283    pub(crate) prior_amount: U256,
2284    pub(crate) ctx: Arc<Mutex<PoolContext>>,
2285    pub(crate) ledger: Arc<PoolLedger>,
2286    /// The lane's source.
2287    pub(crate) source: PeerSource<'a>,
2288}
2289
2290/// Build a probe target for a discovered candidate: its `node_id`, an optional
2291/// relay hint, and its registry multiaddrs as direct-address hints (ADR 001 §
2292/// Node Discovery). See [`lane_target`] for why both ride the first packet.
2293fn probe_target(cand: &NodeCandidate, relay: Option<RelayUrl>) -> EndpointAddr {
2294    let mut target = EndpointAddr::new(cand.node_id);
2295    if let Some(url) = relay {
2296        target = target.with_relay_url(url);
2297    }
2298    discovery::with_dial_addrs(target, cand)
2299}
2300
2301/// Build the target address for a candidate: its `node_id`, the pinned
2302/// `addr` when the user gave one (`--addr`, which requires `--node-id`), the
2303/// first configured relay hint, and its registry multiaddrs as direct-address
2304/// hints (ADR 001 § Node Discovery).
2305///
2306/// iroh sends the first handshake packet to every path the target carries, so
2307/// the relay hint and the direct addresses race from the start: a reachable
2308/// node settles on the direct path, and a node behind NAT connects over the
2309/// relay with no wait for the pkarr lookup. A hint that names a relay other
2310/// than the node's home relay drops its packets, and the lookup supplies the
2311/// right one.
2312pub(crate) fn lane_target(
2313    candidate: &NodeCandidate,
2314    addr: Option<std::net::SocketAddr>,
2315    relays: &[RelayUrl],
2316) -> EndpointAddr {
2317    let mut target = EndpointAddr::new(candidate.node_id);
2318    if let Some(addr) = addr {
2319        target = target.with_ip_addr(addr);
2320    }
2321    if let Some(url) = relays.first() {
2322        target = target.with_relay_url(url.clone());
2323    }
2324    // Registry multiaddrs as direct-address hints: a reachable lane provider
2325    // connects without a relay (ADR 001 § Node Discovery).
2326    discovery::with_dial_addrs(target, candidate)
2327}
2328
2329/// Build one [`MultiLane`] for `provider` at `target`: open/reuse the pool,
2330/// seed a ledger from that lane's persisted cumulative, and wrap a
2331/// [`PeerSource`] over it.
2332///
2333/// `open_lock`, when `Some`, serializes the pool open-or-reuse inside
2334/// [`build_ctx_for_fetch`]: across one fetch's concurrently built lanes, which
2335/// all open or reuse the one on-chain pool, and across other fetches sharing
2336/// that pool (bundle pull's cross-entry concurrency, #1774); the guard is dropped before
2337/// any streaming, and skipped entirely on the delegated path, which opens
2338/// nothing on-chain. A caller that holds a provider's stream permit takes it
2339/// before `open_lock`, so the lock order stays permit → `open_lock` and no
2340/// hold-and-wait cycle can form.
2341#[allow(clippy::too_many_arguments)]
2342pub(crate) async fn build_multi_lane<'a, P>(
2343    deps: &DriveFetchDeps<'a, P>,
2344    grant: Option<&CapabilityGrant>,
2345    signer: &Arc<PrivateKeySigner>,
2346    voucher_dom: &Eip712Domain,
2347    provider: Address,
2348    target: EndpointAddr,
2349    open_lock: Option<&tokio::sync::Mutex<()>>,
2350    ledgers: Option<&LaneLedgers>,
2351) -> anyhow::Result<MultiLane<'a>>
2352where
2353    P: alloy::providers::Provider + Clone,
2354{
2355    let (ctx, spend) = {
2356        // Bundle pull funnels every entry — and every lane of a multi-source
2357        // entry — through the one shared `PaymentPool` deposit, so the
2358        // open-or-reuse serializes across the whole bundle. The delegated path
2359        // opens nothing on-chain (`build_delegated_pool_ctx` adopts the owner's
2360        // pool), so it skips the guard: holding the bundle-wide lock there
2361        // would serialize unrelated fetches for no mutual-exclusion win.
2362        let _open_guard = match (open_lock, grant) {
2363            (Some(lock), None) => Some(lock.lock().await),
2364            _ => None,
2365        };
2366        build_ctx_for_fetch(
2367            grant,
2368            deps.store,
2369            deps.contract,
2370            deps.rpc,
2371            signer,
2372            voucher_dom,
2373            provider,
2374            deps.self_address,
2375            deps.chain,
2376            deps.endpoint,
2377            deps.funding,
2378        )
2379        .await?
2380    };
2381    let pool_id = ctx.pool_id;
2382    let prior_amount = ctx.prior_amount;
2383    let lane = LaneKey {
2384        pool_id,
2385        signer: deps.self_address,
2386        provider,
2387    };
2388    // The lane's ledger + context, seeded from its persisted `(signer,
2389    // provider)` cumulative so the first voucher continues the lane (a restart
2390    // from zero is rejected as a regression) — from the run registry when
2391    // `ledgers` is `Some`, else a fresh pair.
2392    let (ledger, ctx) = lane_ledger(ledgers, lane, ctx);
2393    // The run's view of the pool's spend counts this lane through its ledger
2394    // from now on, whether or not the acquire loop ever starts it. A delegated
2395    // lane draws on a pool this caller does not own, and joins nothing.
2396    if let Some(spend) = spend {
2397        deps.funding.join_lane(lane, spend, &ledger);
2398    }
2399    let source = lane_source(
2400        deps,
2401        target,
2402        Arc::clone(&ctx),
2403        Arc::clone(&ledger),
2404        provider,
2405    );
2406    if let Some(timings) = deps.timings {
2407        timings.mark(Mark::Lane);
2408    }
2409    Ok(MultiLane {
2410        provider,
2411        pool_id,
2412        prior_amount,
2413        ctx,
2414        ledger,
2415        source,
2416    })
2417}
2418
2419/// `provider`'s lane source at `target`, paying through `ctx` and `ledger`.
2420/// Every open takes a stream on the command's connection to `target`
2421/// ([`DriveFetchDeps::connections`]).
2422fn lane_source<'a, P>(
2423    deps: &DriveFetchDeps<'a, P>,
2424    target: EndpointAddr,
2425    ctx: Arc<Mutex<PoolContext>>,
2426    ledger: Arc<PoolLedger>,
2427    provider: Address,
2428) -> PeerSource<'a> {
2429    PeerSource::new(
2430        deps.endpoint,
2431        target,
2432        ctx,
2433        ledger,
2434        deps.slash_dom,
2435        provider,
2436        deps.namespace_id,
2437        deps.max_blob_bytes,
2438        deps.max_rate_per_mb,
2439        deps.deadlines,
2440        Some(deps.connections.clone()),
2441    )
2442}
2443
2444/// One lane's watermark-persistence handle: what [`stream_lane_watermarks`]
2445/// needs to settle the lane AFTER the fetch, kept alive after the lane's owned
2446/// [`PeerSource`] has moved into its [`decdn_client::StreamCandidate`]. The
2447/// `ledger` and
2448/// `ctx` are the SAME `Arc`s the fetch paid through, so the lane's settlement
2449/// is read here once the fetch ends.
2450pub(crate) struct FaceLaneHandle {
2451    pub(crate) pool_id: PoolId,
2452    pub(crate) provider: Address,
2453    pub(crate) prior_amount: U256,
2454    pub(crate) ledger: Arc<PoolLedger>,
2455    /// The lane's buyer context, read to annotate an unbound cache miss.
2456    pub(crate) ctx: Arc<Mutex<PoolContext>>,
2457}
2458
2459/// The per-lane [`LaneWatermark`]s to persist after a face fetch, read from the
2460/// retained [`FaceLaneHandle`]s — the same settle-at-armed-cumulative rule
2461/// [`multi_lane_watermarks`] then keys for persistence.
2462///
2463/// One watermark per ledger: a lane rebuilt on the run's shared ledger leaves a
2464/// second handle on it, and the first read takes the ledger's unsaved rebase,
2465/// so a second write for it would only repeat the first. Each rebuild read its
2466/// own baseline from the store, and a sibling entry's rebase can have moved
2467/// that baseline down since the first build, so the watermark takes the lowest
2468/// one: the advance past it still persists.
2469fn stream_lane_watermarks(lanes: &[FaceLaneHandle]) -> Vec<LaneWatermark> {
2470    let mut per_ledger: Vec<(&FaceLaneHandle, U256)> = Vec::new();
2471    for l in lanes {
2472        match per_ledger
2473            .iter_mut()
2474            .find(|(first, _)| Arc::ptr_eq(&first.ledger, &l.ledger))
2475        {
2476            Some((_, prior)) => *prior = (*prior).min(l.prior_amount),
2477            None => per_ledger.push((l, l.prior_amount)),
2478        }
2479    }
2480    per_ledger
2481        .into_iter()
2482        .map(|(l, prior_amount)| LaneWatermark {
2483            pool_id: l.pool_id,
2484            provider: l.provider,
2485            prior_amount,
2486            // Taken before the settlement read, so the settlement is at or above it.
2487            rebase_anchor: l.ledger.take_unsaved_rebase(),
2488            settlement: l.ledger.settlement(),
2489        })
2490        .collect()
2491}
2492
2493/// Copy a [`decdn_client::VerifiedReader`]'s verified bytes to `sink`,
2494/// feeding `on_progress` the cumulative content bytes drained. Returns the total
2495/// drained. Only bao-verified bytes ever leave the reader, so `sink` (stdout) is
2496/// safe to pipe.
2497async fn copy_verified<R, W>(
2498    reader: &mut R,
2499    sink: &mut W,
2500    total_bytes: u64,
2501    on_progress: Option<&dyn Fn(u64, u64)>,
2502) -> anyhow::Result<u64>
2503where
2504    R: tokio::io::AsyncRead + Unpin,
2505    W: tokio::io::AsyncWrite + Unpin,
2506{
2507    use tokio::io::{AsyncReadExt, AsyncWriteExt};
2508
2509    let mut buf = vec![0u8; 256 * 1024];
2510    let mut drained: u64 = 0;
2511    loop {
2512        let n = reader
2513            .read(&mut buf)
2514            .await
2515            .map_err(|e| anyhow::anyhow!("read verified stream: {e}"))?;
2516        if n == 0 {
2517            break;
2518        }
2519        let chunk = buf
2520            .get(..n)
2521            .ok_or_else(|| anyhow::anyhow!("read returned more than the buffer holds"))?;
2522        sink.write_all(chunk)
2523            .await
2524            .map_err(|e| anyhow::anyhow!("write to stdout: {e}"))?;
2525        drained = drained.saturating_add(u64::try_from(n).unwrap_or(u64::MAX));
2526        if let Some(cb) = on_progress {
2527            cb(drained, total_bytes);
2528        }
2529    }
2530    sink.flush()
2531        .await
2532        .map_err(|e| anyhow::anyhow!("flush stdout: {e}"))?;
2533    Ok(drained)
2534}
2535
2536/// One lane's buyer-store watermark write: the lane and the progress to record.
2537type LaneWrite = (LaneKey, VoucherProgress);
2538
2539/// The watermark write every face lane calls for, read from the retained
2540/// handles now — the same settle-at-armed-cumulative rule the file path
2541/// applies, one write per lane ledger.
2542fn face_watermark_writes(self_address: Address, handles: &[FaceLaneHandle]) -> Vec<LaneWrite> {
2543    multi_lane_watermarks(self_address, &stream_lane_watermarks(handles))
2544}
2545
2546/// Apply every write in `writes`. Blocking: each write that moves the row is a
2547/// redb commit.
2548fn write_watermarks(store: &RedbBuyerPoolStore, self_address: Address, writes: Vec<LaneWrite>) {
2549    for (lane, vprogress) in writes {
2550        persist_watermark(store, self_address, lane.pool_id, lane, &vprogress);
2551    }
2552}
2553
2554/// Read every face lane's voucher watermark from its ledger now
2555/// ([`face_watermark_writes`]), queue the writes on the command's ordered
2556/// `writes`, and return a receiver that resolves once they land. The faces do
2557/// not persist, so the thin CLI layer does it after the fetch, before surfacing
2558/// any error: the bytes each lane delivered are paid for whatever the outcome.
2559///
2560/// Each write that moves the row is a durable redb commit, and a `bundle
2561/// pull`'s lanes share the caller's task, so a commit inline would stop every
2562/// lane of the run while it syncs (#2211). The queue keeps the writes in the
2563/// order the ledgers were read, across every entry: a rebase replaces the lane
2564/// row outright, so a rebase landing after a newer advance, or a stale advance
2565/// landing after a rebase, would leave the next run signing from the wrong
2566/// progress. The read and the queueing are one synchronous call for that
2567/// reason, and the order holds only while every caller polls on one task, as
2568/// a fetch and a `bundle pull` do.
2569///
2570/// A drop guard, which cannot wait, drops the receiver. The command then waits
2571/// for the queue before it returns ([`OrderedWrites::settle`]), on a Ctrl-C
2572/// too. A second Ctrl-C exits the process at once, and a write still queued or
2573/// running then is lost; the next run may re-sign a stale watermark, which its
2574/// provider rejects. A runtime that shuts down cancels blocking tasks that have
2575/// not started, and the queue then drains on the shutting-down thread.
2576pub(crate) fn queue_face_watermarks(
2577    writes: &OrderedWrites,
2578    store: &Arc<RedbBuyerPoolStore>,
2579    self_address: Address,
2580    handles: &[FaceLaneHandle],
2581) -> tokio::sync::oneshot::Receiver<()> {
2582    queue_watermark_writes(
2583        writes,
2584        store,
2585        self_address,
2586        face_watermark_writes(self_address, handles),
2587    )
2588}
2589
2590/// Queue `lane_writes` on `writes` ([`queue_face_watermarks`]).
2591fn queue_watermark_writes(
2592    writes: &OrderedWrites,
2593    store: &Arc<RedbBuyerPoolStore>,
2594    self_address: Address,
2595    lane_writes: Vec<LaneWrite>,
2596) -> tokio::sync::oneshot::Receiver<()> {
2597    let store = Arc::clone(store);
2598    writes.queue_awaitable(move || write_watermarks(&store, self_address, lane_writes))
2599}
2600
2601/// Stream `hash`'s verified bytes to STDOUT via the [`Streamer`] consumption face
2602/// (#1848 4b). Only bao-verified bytes ever reach the pipe — safe to `| tar x` —
2603/// the fetch is consumption-paced (a slow or early-quit consumer stops the pull
2604/// and the spend within one read-ahead window), and a source that faults
2605/// mid-stream cools while another carries on, with no re-pull or re-pay.
2606///
2607/// `max_sources` is the front lane cap. Progress draws on stderr, only when
2608/// stderr is a terminal; stdout carries nothing but the verified bytes. The
2609/// caller persists each lane's voucher watermark after the stream.
2610#[allow(clippy::too_many_arguments)]
2611async fn stream_to_stdout<P>(
2612    deps: &DriveFetchDeps<'_, P>,
2613    sources: &CliSources<'_, P>,
2614    holders: Vec<Holder>,
2615    health: Arc<PeerHealth>,
2616    hash: [u8; 32],
2617    total_bytes: u64,
2618    stop: StopPolicy,
2619    max_sources: usize,
2620) -> anyhow::Result<()>
2621where
2622    P: alloy::providers::Provider + Clone,
2623{
2624    // Read-ahead stays the default: the outstanding-spend bound for an
2625    // abandoned pipe.
2626    let pull_config = PullConfig {
2627        streamer_lane_cap: max_sources.max(1),
2628        ..PullConfig::new()
2629    };
2630    let funder = sources.funder();
2631
2632    // The fill store's `.partial` lives here for the stream's lifetime; a streamed
2633    // blob is not kept, so a temp dir (removed on drop) under the data dir is its
2634    // natural home.
2635    let scratch = tempfile::tempdir_in(&deps.chain.data_dir)
2636        .map_err(|e| anyhow::anyhow!("open stream scratch dir: {e}"))?;
2637    let mut streamer = Streamer::new(sources, funder, scratch.path())
2638        .holders(holders)
2639        .health(health)
2640        .recovery(Arc::clone(sources.recovery()))
2641        .max_blob_bytes(deps.max_blob_bytes);
2642    if let Some(slot) = sources.credentials() {
2643        streamer = streamer.credentials(slot);
2644    }
2645    let (mut reader, mut drive) = streamer
2646        .open(hash, total_bytes, &pull_config, Arc::new(NoCache), stop)
2647        .await?;
2648
2649    // The bar draws on stderr, so it shows only when stderr is a terminal; stdout
2650    // carries the verified bytes either way.
2651    let bar = std::io::IsTerminal::is_terminal(&std::io::stderr()).then(delivery_progress);
2652    // `copy_verified` reports after each write, so the first report with a
2653    // byte is the first byte on stdout.
2654    let on_progress = |received: u64, expected: u64| {
2655        if received > 0
2656            && let Some(timings) = deps.timings
2657        {
2658            timings.mark(Mark::FirstByte);
2659        }
2660        if let Some((_, cb, _)) = &bar {
2661            cb(received, expected);
2662        }
2663    };
2664
2665    // The drive runs beside the copy, not inside its reads: a blocked stdout
2666    // must not stop an open paid leg from paying and draining. The read-ahead
2667    // window bounds how far it runs ahead of the pipe.
2668    let mut stdout = tokio::io::stdout();
2669    let copy_result = drive
2670        .alongside(copy_verified(
2671            &mut reader,
2672            &mut stdout,
2673            total_bytes,
2674            Some(&on_progress),
2675        ))
2676        .await;
2677
2678    if let Some((bar, _, _)) = &bar {
2679        bar.finish_and_clear();
2680    }
2681    let copied = copy_result.map_err(|read_err| stream_error(read_err, drive.take_error()))?;
2682    tracing::info!("streamed {copied} verified bytes to stdout");
2683    Ok(())
2684}
2685
2686/// The error a failed stdout stream ends with: the fetch's own typed error when
2687/// the drive ended with one (so a give-up, a fatal fault or a unanimous verdict
2688/// keeps its type for the exit code and the remedy hints), else the read or
2689/// write error itself.
2690fn stream_error(read_err: anyhow::Error, fetch_err: Option<anyhow::Error>) -> anyhow::Error {
2691    fetch_err.unwrap_or(read_err)
2692}
2693
2694/// Fetch `hash` to `output` via the [`Downloader`] consumption face (#1848 4c),
2695/// striping across `holders` with at most `max_sources` lanes at once (a
2696/// holder discovery adds later can take a free lane), and print the summary
2697/// line. The `.partial` beside `output` stays in place on a failure for a
2698/// later resume. The caller persists each lane's voucher watermark after the
2699/// fetch.
2700async fn download_to_file<P>(
2701    deps: &DriveFetchDeps<'_, P>,
2702    sources: &CliSources<'_, P>,
2703    holders: Vec<Holder>,
2704    health: Arc<PeerHealth>,
2705    target: DownloadTarget<'_>,
2706    stop: &StopPolicy,
2707    max_sources: usize,
2708) -> anyhow::Result<()>
2709where
2710    P: alloy::providers::Provider + Clone,
2711{
2712    let (bar, on_bar, meter) = delivery_progress();
2713    let on_progress = |received: u64, expected: u64| {
2714        if received > 0
2715            && let Some(timings) = deps.timings
2716        {
2717            timings.mark(Mark::FirstByte);
2718        }
2719        on_bar(received, expected);
2720    };
2721    let mut downloader = Downloader::new(sources, sources.funder())
2722        .holders(holders)
2723        .health(health)
2724        .recovery(Arc::clone(sources.recovery()))
2725        .max_lanes(max_sources)
2726        .max_blob_bytes(deps.max_blob_bytes);
2727    if let Some(slot) = sources.credentials() {
2728        downloader = downloader.credentials(slot);
2729    }
2730    let result =
2731        Box::pin(downloader.fetch_to_paths_until(&[target], Some(&on_progress), stop)).await;
2732    bar.finish_and_clear();
2733    result?;
2734    // The finished file holds exactly the proven size, which can differ from
2735    // the first claim the fetch started from.
2736    let proven = tokio::fs::metadata(target.dest)
2737        .await
2738        .map_or(target.total_bytes, |m| m.len());
2739    print_fetch_summary(proven, &meter, target.dest);
2740    Ok(())
2741}
2742
2743/// One lane's persisted-watermark inputs, lifted out of [`MultiLane`] so the
2744/// per-lane settlement rule is a pure function the tests can drive.
2745#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2746struct LaneWatermark {
2747    pool_id: PoolId,
2748    provider: Address,
2749    /// The lane's persisted prior amount — the baseline the advance is computed
2750    /// against.
2751    prior_amount: U256,
2752    /// The lane's ARMED cumulative: what the lane owes if every voucher it put
2753    /// on the wire was received.
2754    settlement: Cumulative,
2755    /// The watermark the lane's ledger rebased down to and has not persisted.
2756    rebase_anchor: Option<Cumulative>,
2757}
2758
2759/// Pair each lane's own `LaneKey` with the watermark to persist for it.
2760///
2761/// Every lane settles at its ARMED cumulative rather than branching on the
2762/// fetch's outcome. The fetch result is ONE outcome shared by
2763/// every lane, but "did this lane's last voucher land?" is a PER-LANE question,
2764/// and on the multi-source path a successful fetch can leave a lane
2765/// armed-above-committed: a cancelled or stalled leg drops its `fill_gap`
2766/// future wherever it is parked, including inside the voucher exchange that
2767/// `issue` deliberately arms before sending, and a leg stopped at a steal
2768/// split keeps a closing voucher that failed to send armed. Settling that lane at `committed` on the
2769/// fetch's `Ok` persists a cumulative BELOW what the node can redeem, and the
2770/// next fetch on that lane signs a cumulative the upstream already holds —
2771/// rejected as a regression.
2772///
2773/// Settling high costs nothing on the clean path: with nothing armed,
2774/// `settlement()` equals `committed()`.
2775fn multi_lane_watermarks(
2776    signer: Address,
2777    lanes: &[LaneWatermark],
2778) -> Vec<(LaneKey, VoucherProgress)> {
2779    lanes
2780        .iter()
2781        .map(|l| {
2782            (
2783                LaneKey {
2784                    pool_id: l.pool_id,
2785                    signer,
2786                    provider: l.provider,
2787                },
2788                VoucherProgress::from_cumulative(l.settlement, l.prior_amount)
2789                    .with_rebase_anchor(l.rebase_anchor),
2790            )
2791        })
2792        .collect()
2793}
2794
2795/// What the lane builds of one run learn about funding its pool, shared by
2796/// every fetch of the run: one `decdn fetch`, or every entry of a `bundle pull`.
2797///
2798/// It holds two facts. The first is the pool's spend across every lane: a
2799/// baseline for the lanes the run does not drive, plus what each lane the run
2800/// built has committed since. The deposit gate reads it
2801/// ([`Funder::pool_spent`]), so a pool shared across many providers gates on its
2802/// true remaining deposit whichever lanes the acquire loop starts. The second
2803/// is a wallet that holds too little USDC to top the pool up: once seen, no
2804/// later lane build of the run tries the proactive refill again, a funding
2805/// recovery step fails without a transaction, and the command ends with one
2806/// `warning:` line naming it. It also names the pool the run's first built
2807/// lane pays from, which a funding recovery step tops up: every entry of a
2808/// bundle pull shares it, the entries that have built no lane yet included.
2809#[derive(Debug, Default)]
2810pub(crate) struct RunFunding {
2811    spend: Mutex<RunSpend>,
2812    shortfall: Mutex<Option<String>>,
2813    pool_id: OnceLock<PoolId>,
2814}
2815
2816/// What a lane build learned about the pool's spend, for
2817/// [`RunFunding::join_lane`].
2818#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2819pub(crate) struct LaneSpend {
2820    /// The pool's spend on every other lane, as the build saw it.
2821    pub(crate) outside: U256,
2822    /// The amount the lane resumes from.
2823    pub(crate) prior: U256,
2824    /// Whether the pool row recorded the lane before this build seeded it from
2825    /// chain, so that an earlier lane's `outside` counts its prior.
2826    pub(crate) recorded: bool,
2827    /// Whether `outside` includes redeemed spend no tracked lane accounts for
2828    /// ([`BuyerPoolState::redeemed_elsewhere`], from an adopted row's
2829    /// `totalRedeemed`), which counts the watermark of every lane the row has
2830    /// not seeded.
2831    pub(crate) from_chain: bool,
2832}
2833
2834/// The pool's spend as a run's lanes join it.
2835#[derive(Debug, Default)]
2836struct RunSpend {
2837    /// The spend on lanes the run does not drive. `None` until the first lane
2838    /// joins.
2839    baseline: Option<U256>,
2840    /// Whether the first lane's `outside` came from the chain.
2841    from_chain: bool,
2842    /// Every ledger each joined lane has paid through. A lane rebuilt with a
2843    /// fresh ledger keeps its earlier ones: vouchers on a lane are cumulative,
2844    /// so the lane's spend is the largest of them.
2845    lanes: HashMap<LaneKey, Vec<Arc<PoolLedger>>>,
2846}
2847
2848impl RunFunding {
2849    /// Name `pool_id` as the pool the run's lanes pay from. The first lane
2850    /// built names it; every later lane pays from the same pool.
2851    pub(crate) fn note_pool(&self, pool_id: PoolId) {
2852        let _ = self.pool_id.set(pool_id);
2853    }
2854
2855    /// The pool the run's lanes pay from, once a lane is built.
2856    pub(crate) fn pool_id(&self) -> Option<PoolId> {
2857        self.pool_id.get().copied()
2858    }
2859
2860    /// Record that `lane` joins the run, paying through `ledger`.
2861    ///
2862    /// The first lane sets the baseline to its `outside`. Each later lane
2863    /// moves its prior out of the baseline when the baseline counts it: when
2864    /// the row recorded the lane, or when the baseline came from the chain. Its
2865    /// ledger counts that amount from now on. A lane that has joined before
2866    /// only adds `ledger`, if it is a new one.
2867    pub(crate) fn join_lane(&self, lane: LaneKey, spend: LaneSpend, ledger: &Arc<PoolLedger>) {
2868        let mut run = self.spend.lock().unwrap_or_else(PoisonError::into_inner);
2869        if let Some(ledgers) = run.lanes.get_mut(&lane) {
2870            if !ledgers.iter().any(|known| Arc::ptr_eq(known, ledger)) {
2871                ledgers.push(Arc::clone(ledger));
2872            }
2873            return;
2874        }
2875        run.lanes.insert(lane, vec![Arc::clone(ledger)]);
2876        run.baseline = Some(match run.baseline {
2877            None => {
2878                run.from_chain = spend.from_chain;
2879                spend.outside
2880            }
2881            Some(baseline) if spend.recorded || run.from_chain => {
2882                baseline.saturating_sub(spend.prior)
2883            }
2884            Some(baseline) => baseline,
2885        });
2886    }
2887
2888    /// The pool's spend across every lane: the baseline plus each joined
2889    /// lane's committed amount. `None` before any lane joins.
2890    pub(crate) fn pool_spent(&self) -> Option<U256> {
2891        let run = self.spend.lock().unwrap_or_else(PoisonError::into_inner);
2892        let baseline = run.baseline?;
2893        Some(run.lanes.values().fold(baseline, |total, ledgers| {
2894            let lane = ledgers
2895                .iter()
2896                .map(|ledger| ledger.committed().amount)
2897                .max()
2898                .unwrap_or(U256::ZERO);
2899            total.saturating_add(lane)
2900        }))
2901    }
2902
2903    /// The warning for a wallet seen to hold too little USDC for a top-up in
2904    /// this run, if one was.
2905    pub(crate) fn shortfall(&self) -> Option<String> {
2906        self.shortfall
2907            .lock()
2908            .unwrap_or_else(PoisonError::into_inner)
2909            .clone()
2910    }
2911
2912    /// Diagnose a failed top-up leg (`err`) for `pool_id` by reading the
2913    /// wallet's USDC balance. When the wallet holds less than `additional`,
2914    /// which no retry fixes, record the shortfall and return its warning; it
2915    /// is logged at WARN the first time. Otherwise return `None`, after a WARN
2916    /// that names the failure, the balance, or why the balance could not be
2917    /// read.
2918    pub(crate) async fn check_wallet<P>(
2919        &self,
2920        rpc: &P,
2921        token: Address,
2922        owner: Address,
2923        pool_id: PoolId,
2924        additional: U256,
2925        err: &anyhow::Error,
2926    ) -> Option<String>
2927    where
2928        P: alloy::providers::Provider + Clone,
2929    {
2930        let error = decdn_common::redact::sanitize_err_chain(err);
2931        let balance = match decdn_incentive::Erc20::new(token, rpc.clone())
2932            .balanceOf(owner)
2933            .call()
2934            .await
2935        {
2936            Ok(balance) => balance,
2937            Err(read) => {
2938                let read = decdn_common::redact::sanitize_err_chain(&anyhow::Error::new(read));
2939                tracing::warn!(
2940                    %pool_id,
2941                    %additional,
2942                    %error,
2943                    wallet_usdc_error = %read,
2944                    "buyer pool top-up failed, and the wallet's USDC balance could not be read"
2945                );
2946                return None;
2947            }
2948        };
2949        if balance >= additional {
2950            tracing::warn!(
2951                %pool_id,
2952                %additional,
2953                wallet_usdc = %balance,
2954                %error,
2955                "buyer pool top-up failed although the wallet holds enough USDC"
2956            );
2957            return None;
2958        }
2959        let warning = format!(
2960            "wallet {owner} holds {balance} µUSDC, less than the {additional} µUSDC top-up \
2961             buyer pool {pool_id} needs; fund the wallet so the pool can be topped up"
2962        );
2963        let mut shortfall = self
2964            .shortfall
2965            .lock()
2966            .unwrap_or_else(PoisonError::into_inner);
2967        if shortfall.is_none() {
2968            tracing::warn!(
2969                %pool_id,
2970                %additional,
2971                wallet_usdc = %balance,
2972                %error,
2973                "the wallet holds too little USDC to top up the buyer pool"
2974            );
2975            *shortfall = Some(warning.clone());
2976        }
2977        Some(warning)
2978    }
2979}
2980
2981/// The CLI's [`Funder`]: the funding recovery step (ADR 003 § Funding
2982/// recovery). A self-funded owner tops its current pool up toward the working
2983/// deposit through the same `ensure_allowance -> top_up -> add_deposit` path
2984/// as the proactive low-water refill ([`refill_if_low`]). A pool that is
2985/// `Closing` or `Closed` reverts that `topUp` with `PoolNotOpen`; the owner
2986/// then opens a new pool at the working deposit and records it as the current
2987/// pool in the buyer store, and the step reports [`Recovery::Replaced`]. The
2988/// old pool is not reclaimed here.
2989///
2990/// A delegated signer (`--capability`) has no funder this command can ask, so
2991/// its working deposit is zero and every step reports
2992/// [`Recovery::Unavailable`]: the fetch ends "funding needed" with the
2993/// new-signer-key remedy ([`annotate_delegated_exhaustion`]).
2994///
2995/// The acquire loop decides WHETHER to fund (its candidate set is exhausted
2996/// and the progress rule allows a step); this only executes the on-chain move.
2997/// Every failure returns to the loop, which ends the fetch "funding needed",
2998/// or, when the escrow may have moved, fatally.
2999pub(crate) struct CliFunder<'a, P> {
3000    pub(crate) contract: &'a PaymentPool::PaymentPoolInstance<P>,
3001    pub(crate) rpc: &'a P,
3002    pub(crate) store: &'a RedbBuyerPoolStore,
3003    pub(crate) owner: Address,
3004    /// Signs a replacement pool's `openPool` and its self-issued capability.
3005    pub(crate) signer: &'a Arc<PrivateKeySigner>,
3006    /// The chain and `PaymentPool` a replacement pool opens on.
3007    pub(crate) deployment: Deployment,
3008    pub(crate) token: Address,
3009    pub(crate) payment_pool_addr: Address,
3010    /// The deposit a step tops up toward and a replacement pool opens at.
3011    /// Zero for a delegated signer, which has no funding path.
3012    pub(crate) working_deposit: U256,
3013    pub(crate) max_approve: bool,
3014    /// The run's funding facts: its outside spend, a wallet shortfall that
3015    /// makes a further top-up pointless, and the pool to top up
3016    /// ([`RunFunding::pool_id`]).
3017    pub(crate) funding: &'a RunFunding,
3018}
3019
3020impl<P> Funder for CliFunder<'_, P>
3021where
3022    P: alloy::providers::Provider + Clone,
3023{
3024    fn pool_spent(&self) -> Option<U256> {
3025        self.funding.pool_spent()
3026    }
3027
3028    fn recover(&self, remaining: U256) -> SourceFuture<'_, Recovery> {
3029        Box::pin(async move {
3030            // A delegated signer owns no pool it could fund.
3031            if self.working_deposit.is_zero() {
3032                return Ok(Recovery::Unavailable);
3033            }
3034            let pool_id = self
3035                .funding
3036                .pool_id()
3037                .ok_or_else(|| anyhow::anyhow!("no payment lane is built, so no pool to top up"))?;
3038            let additional = self.working_deposit.saturating_sub(remaining);
3039            if additional.is_zero() {
3040                return self.settle_or_replace(pool_id, remaining).await;
3041            }
3042            if let Some(shortfall) = self.funding.shortfall() {
3043                return Err(
3044                    anyhow::anyhow!("the wallet cannot fund a top-up: {shortfall}")
3045                        .context(WalletShortfall),
3046                );
3047            }
3048            // `topUp` pulls `additional` USDC via `transferFrom`, so the
3049            // standing allowance must cover it first: unlimited under
3050            // `--max-approve`, else exactly `additional`.
3051            let escrowed = async {
3052                ensure_allowance(
3053                    self.rpc,
3054                    self.token,
3055                    self.owner,
3056                    self.payment_pool_addr,
3057                    if self.max_approve {
3058                        None
3059                    } else {
3060                        Some(additional)
3061                    },
3062                )
3063                .await?;
3064                top_up(self.contract, self.owner, pool_id, additional).await
3065            }
3066            .await;
3067            // A failure before any receipt names its cause once: a wallet
3068            // short of USDC is recorded for the run's closing warning.
3069            let ToppedUpPool { credited, tx, .. } = match escrowed {
3070                Ok(topped_up) => topped_up,
3071                Err(err) if err.downcast_ref::<TopUpUnconfirmed>().is_some() => return Err(err),
3072                // The gas estimate reverted `PoolNotOpen`: the pool is closing
3073                // or closed, and a pool is never reopened. Open its successor.
3074                Err(err) if err.downcast_ref::<PoolNotOpen>().is_some() => {
3075                    tracing::info!(
3076                        %pool_id,
3077                        "the current buyer pool no longer accepts funds; opening a new pool"
3078                    );
3079                    let opened = self.open_replacement().await?;
3080                    return Ok(Recovery::Replaced(PoolReplaced {
3081                        closed: pool_id,
3082                        opened,
3083                    }));
3084                }
3085                Err(err) => {
3086                    let short = self
3087                        .funding
3088                        .check_wallet(self.rpc, self.token, self.owner, pool_id, additional, &err)
3089                        .await;
3090                    return Err(match short {
3091                        Some(_) => err.context(WalletShortfall),
3092                        None => err,
3093                    });
3094                }
3095            };
3096            // The escrow has moved: a credit that does not land names the tx
3097            // and the pool, and is fatal ([`grade_deposit_credit`]).
3098            let effect = topped_up_effect(pool_id, credited);
3099            let new_deposit = grade_deposit_credit(
3100                self.store.add_deposit(self.owner, pool_id, credited),
3101                &effect,
3102                tx,
3103            )?;
3104            Ok(Recovery::ToppedUp(new_deposit))
3105        })
3106    }
3107}
3108
3109impl<P> CliFunder<'_, P>
3110where
3111    P: alloy::providers::Provider + Clone,
3112{
3113    /// The recovery step for a pool that already holds its working deposit,
3114    /// so a top-up has nothing to add. A `topUp` gas estimate (no transaction)
3115    /// tells a closing or closed pool, which a new pool replaces. An open pool
3116    /// is the nodes' stale view of a deposit they have not yet seen: the step
3117    /// settles, so the gate's settle window holds those nodes' refusals and
3118    /// asks them again ([`Recovery::ToppedUp`] at the current deposit).
3119    ///
3120    /// # Errors
3121    ///
3122    /// The replacement open fails.
3123    async fn settle_or_replace(
3124        &self,
3125        pool_id: PoolId,
3126        remaining: U256,
3127    ) -> anyhow::Result<Recovery> {
3128        if !pool_accepts_funds(self.contract, self.owner, pool_id).await {
3129            tracing::info!(
3130                %pool_id,
3131                "the current buyer pool holds its working deposit but accepts no more funds; \
3132                 opening a new pool"
3133            );
3134            let opened = self.open_replacement().await?;
3135            return Ok(Recovery::Replaced(PoolReplaced {
3136                closed: pool_id,
3137                opened,
3138            }));
3139        }
3140        // The settle step escrows nothing, so an estimate is safe here: it only
3141        // names the deposit the nodes are asked again at.
3142        let estimate = || remaining.saturating_add(self.funding.pool_spent().unwrap_or(U256::ZERO));
3143        let deposit = match self.store.get_by_pool_id(pool_id) {
3144            Ok(Some(row)) => row.deposit,
3145            Ok(None) => estimate(),
3146            Err(error) => {
3147                tracing::warn!(
3148                    %pool_id,
3149                    %error,
3150                    "could not read the buyer pool's row; estimating its deposit from the run"
3151                );
3152                estimate()
3153            }
3154        };
3155        tracing::info!(
3156            %pool_id,
3157            %deposit,
3158            "the buyer pool already holds its working deposit; waiting for the nodes to see it"
3159        );
3160        Ok(Recovery::ToppedUp(deposit))
3161    }
3162
3163    /// Open a new pool at the working deposit and record it as the owner's
3164    /// current pool in the buyer store, where the next lane build reuses it.
3165    /// The closed pool's row stays in the store, so `decdn pool reclaim` and
3166    /// `pool list` still find it.
3167    ///
3168    /// # Errors
3169    ///
3170    /// The allowance or `openPool` fails (nothing escrowed), or the opened
3171    /// pool cannot be recorded (escrowed and untracked: fatal).
3172    async fn open_replacement(&self) -> anyhow::Result<PoolId> {
3173        let deposit = self.working_deposit;
3174        ensure_allowance(
3175            self.rpc,
3176            self.token,
3177            self.owner,
3178            self.payment_pool_addr,
3179            if self.max_approve {
3180                None
3181            } else {
3182                Some(deposit)
3183            },
3184        )
3185        .await?;
3186        let opened = open_pool(
3187            self.contract,
3188            Arc::clone(self.signer),
3189            self.deployment,
3190            self.token,
3191            self.owner,
3192            deposit,
3193        )
3194        .await?;
3195        self.store
3196            .record(&opened.state)
3197            .map_err(|e| escrowed_but_untracked("buyer pool opened", opened.tx, e))?;
3198        tracing::info!(pool_id = %opened.state.pool_id, %deposit, "opened a new buyer pool");
3199        Ok(opened.state.pool_id)
3200    }
3201}
3202
3203/// Whether `--output` names the stdout stream rather than a file. Exactly a
3204/// single dash (`-o -`) streams verified bytes to stdout; a file literally named
3205/// `-` is written by addressing it as `./-`, the usual convention (#1848 4b).
3206fn wants_stdout(output: &Path) -> bool {
3207    output == Path::new("-")
3208}
3209
3210/// Where the [`decdn_client::ClientRangedStore`] for `--output` lives: its directory (the
3211/// output's parent, or the current dir) and its stem (the output's own file
3212/// name). Keying the store by the output name makes its promoted final path IS
3213/// `--output` (no post-finalize rename), and its `.partial` sits beside the
3214/// destination exactly like `<output>.partial`, so promotion is a
3215/// same-filesystem atomic rename.
3216pub(crate) fn ranged_store_location(output: &Path) -> anyhow::Result<(PathBuf, String)> {
3217    let dir = match output.parent() {
3218        Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
3219        _ => PathBuf::from("."),
3220    };
3221    let stem = output
3222        .file_name()
3223        .and_then(|n| n.to_str())
3224        .ok_or_else(|| anyhow::anyhow!("--output {} has no usable file name", output.display()))?
3225        .to_string();
3226    Ok((dir, stem))
3227}
3228
3229/// Time constant for the smoothed delivery rate (seconds). Larger holds the
3230/// readout steadier across bursty arrival; smaller tracks real speed changes
3231/// faster. A few seconds keeps the number legible without lagging a genuine
3232/// slowdown for long.
3233const RATE_SMOOTHING_TAU_SECS: f64 = 3.0;
3234
3235/// Rolling delivery-rate estimate behind a progress bar's `{msg}`, and the
3236/// running totals the end-of-fetch summary reads back. Single-blob `fetch` feeds
3237/// it from the delivery bar; `bundle pull` feeds it from its bottom total bar, so
3238/// the rate there is whole-download throughput rather than one file's.
3239///
3240/// The bar's positions are cumulative verified **content** bytes — what the
3241/// [`decdn_client::ProgressCallback`] reports — so the rate is content
3242/// throughput (the interleaved bao proof nodes the wire also carries are not
3243/// counted).
3244///
3245/// Each `set_position` on the bar is bursty — many chunks land in one instant,
3246/// then a gap — so a naive `delta / dt` per callback spikes and collapses. This
3247/// holds a time-weighted exponential moving average instead: each sample folds
3248/// in with weight `1 - exp(-dt / tau)`, so the estimate is stable regardless of
3249/// how unevenly callbacks are spaced.
3250#[derive(Default)]
3251pub(crate) struct SpeedState {
3252    /// Instant and cumulative-byte position at the first observed sample. The
3253    /// summary measures elapsed and bytes-moved from here, so a resumed fetch
3254    /// (which starts at a non-zero `base_present`) reports only what this run
3255    /// actually transferred rather than dividing already-present bytes by this
3256    /// run's short window.
3257    started: Option<(Instant, u64)>,
3258    /// Instant and cumulative received content bytes at the previous sample.
3259    last: Option<(Instant, u64)>,
3260    /// Smoothed rate in bytes/sec. `None` until the second sample gives a `dt`.
3261    ewma_bps: Option<f64>,
3262}
3263
3264impl SpeedState {
3265    /// Fold one sample — cumulative content bytes `received` as of `now` — into
3266    /// the estimate and return the current smoothed rate in bytes/sec (`0.0`
3267    /// until a second sample gives a `dt`).
3268    pub(crate) fn observe(&mut self, now: Instant, received: u64) -> f64 {
3269        self.started.get_or_insert((now, received));
3270        if let Some((prev_at, prev_bytes)) = self.last {
3271            let dt = now.saturating_duration_since(prev_at).as_secs_f64();
3272            // Skip same-instant callbacks (a burst): they carry no usable `dt`
3273            // and would divide by ~zero into a spike.
3274            if dt > 0.0 {
3275                let inst = bytes_as_f64(received.saturating_sub(prev_bytes)) / dt;
3276                let alpha = 1.0 - (-dt / RATE_SMOOTHING_TAU_SECS).exp();
3277                // Seed from 0, not `inst`: on the first sample a tiny `dt` makes
3278                // `inst` huge, but `alpha * inst = (1 - exp(-dt/tau)) * (delta/dt)
3279                // -> delta/tau` as `dt -> 0`, so the estimate stays bounded
3280                // instead of spiking, then converges upward.
3281                let prev_bps = self.ewma_bps.unwrap_or(0.0);
3282                self.ewma_bps = Some(prev_bps + alpha * (inst - prev_bps));
3283            }
3284        }
3285        self.last = Some((now, received));
3286        self.ewma_bps.unwrap_or(0.0)
3287    }
3288}
3289
3290/// Widen a byte count to `f64` for rate arithmetic. A single transfer never
3291/// approaches 2^53 bytes, so the precision the cast lint guards against is not
3292/// at risk here.
3293#[expect(
3294    clippy::cast_precision_loss,
3295    reason = "byte counts stay far below f64's 2^53 exact-integer ceiling"
3296)]
3297pub(crate) const fn bytes_as_f64(n: u64) -> f64 {
3298    n as f64
3299}
3300
3301/// Format a non-negative bytes/sec rate as e.g. `12.3 MiB/s`. A rate at or
3302/// below zero (no data yet, or a stall) renders as `--`.
3303pub(crate) fn fmt_rate(bps: f64) -> String {
3304    if bps.is_finite() && bps >= 1.0 {
3305        // The rate is a small non-negative value; clamp before the cast so the
3306        // `HumanBytes` argument can never wrap or lose sign.
3307        #[expect(
3308            clippy::cast_possible_truncation,
3309            clippy::cast_sign_loss,
3310            reason = "bps is finite and >= 1.0 here; the cast cannot wrap or go negative"
3311        )]
3312        let whole = bps.min(bytes_as_f64(u64::MAX)) as u64;
3313        format!("{}/s", indicatif::HumanBytes(whole))
3314    } else {
3315        "--".to_string()
3316    }
3317}
3318
3319/// Estimate remaining time from the smoothed rate, formatted like `ETA 8s`.
3320/// Below a usable rate it reports `ETA --` rather than a divide-by-tiny blowup.
3321pub(crate) fn fmt_eta(remaining_bytes: u64, bps: f64) -> String {
3322    if bps >= 1.0 {
3323        // Clamp the projection so `from_secs_f64` never overflows `Duration`.
3324        let secs = (bytes_as_f64(remaining_bytes) / bps).clamp(0.0, 8.64e7);
3325        format!(
3326            "ETA {}",
3327            indicatif::HumanDuration(Duration::from_secs_f64(secs))
3328        )
3329    } else {
3330        "ETA --".to_string()
3331    }
3332}
3333
3334/// Reads the transfer duration and bytes moved this run back from a
3335/// [`SpeedState`] after the bar finishes, for the end-of-fetch summary.
3336#[derive(Clone)]
3337pub(crate) struct DeliveryMeter {
3338    state: Arc<Mutex<SpeedState>>,
3339}
3340
3341impl DeliveryMeter {
3342    /// `(elapsed across this run, content bytes this run transferred)`, or
3343    /// `None` if no byte ever arrived (a failure before delivery) or the lock is
3344    /// poisoned. Both are measured from the first observed sample, so a resumed
3345    /// fetch excludes the already-present `base_present` bytes it did not move.
3346    fn summary(&self) -> Option<(Duration, u64)> {
3347        let state = self.state.lock().ok()?;
3348        let (start_at, start_bytes) = state.started?;
3349        let (last_at, last_bytes) = state.last?;
3350        Some((
3351            last_at.saturating_duration_since(start_at),
3352            last_bytes.saturating_sub(start_bytes),
3353        ))
3354    }
3355}
3356
3357/// The `decdn fetch` delivery progress bar, the callback that drives it, and a
3358/// [`DeliveryMeter`] the caller reads after `finish_and_clear` for the terminal
3359/// summary (#1118). The callback advances the bar, folds each update into the
3360/// shared [`SpeedState`] so `{msg}` shows a steady rate/ETA, and mirrors the
3361/// percent into the terminal's tab bar ([`TabProgress`]) where supported; the
3362/// tab indicator is removed when the callback drops.
3363fn delivery_progress() -> (
3364    indicatif::ProgressBar,
3365    impl Fn(u64, u64) + 'static,
3366    DeliveryMeter,
3367) {
3368    let bar = new_progress_bar();
3369    // `ProgressBar` is `Arc`-backed, so the clone the callback owns drives the
3370    // same bar the caller clears.
3371    let (on_progress, meter) = bar_callback(bar.clone(), TabProgress::detect(true));
3372    (bar, on_progress, meter)
3373}
3374
3375/// Build the callback that drives `bar` (it sets the bar's length to the
3376/// fetch's current size bound whenever it changes, advances its position to
3377/// the cumulative verified content-byte count, and folds each update into a
3378/// [`SpeedState`] for the rate/ETA `{msg}`), and return it with the
3379/// [`DeliveryMeter`] the caller reads after the bar finishes. `tab`, when set,
3380/// shows the same percent in the terminal's tab bar.
3381///
3382/// The callback's `received`/`expected` are content bytes, per
3383/// [`decdn_client::ProgressCallback`]. `expected` is the size bound: a size
3384/// claim the fetch grows or shrinks until a leg proves the size. Both the bar
3385/// length and its position are in the same unit.
3386fn bar_callback(
3387    bar: indicatif::ProgressBar,
3388    tab: Option<Arc<TabProgress>>,
3389) -> (impl Fn(u64, u64) + 'static, DeliveryMeter) {
3390    // Setting the length takes a write lock, so it is set only when the bound
3391    // moves, not on every chunk in the hot receive loop. `u64::MAX` is never a
3392    // bound, so the first report always sets it.
3393    let length = std::sync::atomic::AtomicU64::new(u64::MAX);
3394    let state = Arc::new(Mutex::new(SpeedState::default()));
3395    let cb_state = Arc::clone(&state);
3396    let on_progress = move |received: u64, expected: u64| {
3397        if length.swap(expected, std::sync::atomic::Ordering::Relaxed) != expected {
3398            bar.set_length(expected);
3399        }
3400        bar.set_position(received);
3401        if let Some(tab) = &tab {
3402            tab.update(received, expected);
3403        }
3404
3405        // A poisoned lock only costs this one rate update; the bar still advances.
3406        if let Ok(mut s) = cb_state.lock() {
3407            let bps = s.observe(Instant::now(), received);
3408            bar.set_message(format!(
3409                "({}, {}) ",
3410                fmt_rate(bps),
3411                fmt_eta(expected.saturating_sub(received), bps)
3412            ));
3413        }
3414    };
3415    (on_progress, DeliveryMeter { state })
3416}
3417
3418/// Print the terminal line after a successful fetch: content bytes, and — when
3419/// the [`DeliveryMeter`] captured any delivery — the elapsed time and average
3420/// transfer rate over the content bytes this run moved. Falls back to the bare
3421/// byte/output line when nothing was delivered on this leg (e.g. a fully
3422/// resumed transfer that re-pulled no bytes).
3423fn print_fetch_summary(content_bytes: u64, meter: &DeliveryMeter, output: &Path) {
3424    match meter.summary() {
3425        Some((elapsed, moved_bytes)) if elapsed > Duration::ZERO && moved_bytes > 0 => {
3426            let avg_bps = bytes_as_f64(moved_bytes) / elapsed.as_secs_f64();
3427            println!(
3428                "fetched {content_bytes} bytes in {} (avg {}) -> {}",
3429                indicatif::HumanDuration(elapsed),
3430                fmt_rate(avg_bps),
3431                output.display()
3432            );
3433        }
3434        _ => println!("fetched {content_bytes} bytes -> {}", output.display()),
3435    }
3436}
3437
3438/// Resolve the delegated `--capability`/`--capability-file` token into a
3439/// [`CapabilityGrant`], and, when one is present, zero the working deposit
3440/// in `chain`: a delegate owns no pool it could `topUp`, so its funding
3441/// recovery step has nothing to add ([`CliFunder`]). `None` selects the
3442/// unchanged self-owned pool path. Shared by `decdn fetch` and `bundle pull`.
3443///
3444/// # Errors
3445///
3446/// When `--capability-file` cannot be read, or the token fails to decode.
3447pub(crate) fn resolve_delegation_grant(
3448    common: &cli::ClientFetchArgs,
3449    chain: &mut ResolvedChain,
3450) -> anyhow::Result<Option<CapabilityGrant>> {
3451    let grant = common
3452        .resolve_capability_token()?
3453        .map(|token| CapabilityGrant::from_token(&token))
3454        .transpose()
3455        .map_err(|e| anyhow::anyhow!("invalid --capability token: {e}"))?;
3456    if grant.is_some() {
3457        chain.working_deposit = U256::ZERO;
3458    }
3459    Ok(grant)
3460}
3461
3462/// Select the pool context for one fetch: the delegated adoption path when a
3463/// `--capability` [`CapabilityGrant`] is present (adopt the named pool, present
3464/// the owner's capability, no open), else the self-owned open-or-reuse path.
3465/// Extracted so `fetch()` stays one readable pass over its stages.
3466#[allow(clippy::too_many_arguments)]
3467async fn build_ctx_for_fetch<P>(
3468    grant: Option<&CapabilityGrant>,
3469    store: &RedbBuyerPoolStore,
3470    contract: &PaymentPool::PaymentPoolInstance<P>,
3471    rpc: &P,
3472    signer: &Arc<PrivateKeySigner>,
3473    voucher_dom: &Eip712Domain,
3474    provider: Address,
3475    self_address: Address,
3476    chain: &ResolvedChain,
3477    endpoint: &Endpoint,
3478    funding: &RunFunding,
3479) -> anyhow::Result<(PoolContext, Option<LaneSpend>)>
3480where
3481    P: alloy::providers::Provider + Clone,
3482{
3483    if let Some(grant) = grant {
3484        build_delegated_pool_ctx(
3485            store,
3486            contract,
3487            signer,
3488            voucher_dom,
3489            provider,
3490            self_address,
3491            chain,
3492            endpoint,
3493            grant,
3494        )
3495        .await
3496        .map(|ctx| (ctx, None))
3497    } else {
3498        build_pool_ctx(
3499            store,
3500            contract,
3501            rpc,
3502            signer,
3503            provider,
3504            self_address,
3505            chain,
3506            endpoint,
3507            funding,
3508        )
3509        .await
3510        .map(|(ctx, spend)| (ctx, Some(spend)))
3511    }
3512}
3513
3514/// [`open_or_reuse_pool`] plus the ADR 005 client identity binding (#1115):
3515/// sign our OWN iroh `NodeId` with the buyer key so the serving node can prove
3516/// we own the pool and reactively pull a cache-missed blob from its configured
3517/// origin.
3518///
3519/// The bind domain's verifying contract is the `CapacityBond`; without one
3520/// configured we can't sign, so the request goes out unbound and the node serves
3521/// only content it already holds (a cache miss is refused). No warning is
3522/// emitted for that — it would fire on every successful cached fetch too,
3523/// training users to ignore it; the refusal is explained at the point of failure
3524/// by [`annotate_unbound_cache_miss`].
3525#[allow(clippy::too_many_arguments)]
3526async fn build_pool_ctx<P>(
3527    store: &RedbBuyerPoolStore,
3528    contract: &PaymentPool::PaymentPoolInstance<P>,
3529    rpc: &P,
3530    signer: &Arc<PrivateKeySigner>,
3531    provider: Address,
3532    self_address: Address,
3533    chain: &ResolvedChain,
3534    endpoint: &Endpoint,
3535    funding: &RunFunding,
3536) -> anyhow::Result<(PoolContext, LaneSpend)>
3537where
3538    P: alloy::providers::Provider + Clone,
3539{
3540    let (ctx, spend) = open_or_reuse_pool(
3541        store,
3542        contract,
3543        rpc,
3544        funding,
3545        signer,
3546        provider,
3547        self_address,
3548        chain.deployment(),
3549        chain.working_deposit,
3550        chain.max_approve,
3551        // The store this fetch writes was opened from `chain.data_dir`, and it
3552        // signs with `chain.keystore`; the verdict covers both.
3553        ChainAdoption::for_buy(&chain.data_dir, &chain.keystore)?,
3554    )
3555    .await?;
3556    Ok((attach_client_binding(ctx, chain, endpoint, signer)?, spend))
3557}
3558
3559/// Reject a delegated fetch whose loaded key is not the signer the capability
3560/// authorizes. The client can only sign vouchers as `self_address`, and a
3561/// capability scoped to a different signer would have every voucher rejected as
3562/// `WrongSigner` — so fail fast, before any network or on-chain work.
3563fn ensure_delegate_signer(self_address: Address, authorized: Address) -> anyhow::Result<()> {
3564    anyhow::ensure!(
3565        self_address == authorized,
3566        "this capability authorizes signer {authorized}, but the loaded key is {self_address} — \
3567         load the delegate keystore the pool owner assigned (--keystore / \
3568         blockchain.eth_keystore), or ask for a capability issued to {self_address}",
3569    );
3570    Ok(())
3571}
3572
3573/// Build a [`PoolContext`] that adopts a pool the caller does NOT own, from a
3574/// delegated [`CapabilityGrant`] (`decdn pool assign` → `--capability`).
3575///
3576/// Unlike [`build_pool_ctx`] this opens nothing on-chain and signs no
3577/// self-capability: it presents the OWNER's capability (rebuilt from the token)
3578/// so the serving node registers this client's signer on its first redemption.
3579/// It hard-fails when the loaded keystore is not the delegate the capability
3580/// authorizes — the client cannot sign vouchers under a capability scoped to a
3581/// different signer, and a node would reject them as `WrongSigner`.
3582///
3583/// The pool's on-chain row is read once for the informational `deposit` and to
3584/// confirm the pool exists (a zero-owner row means it was never opened on this
3585/// contract). The lane resumes from any locally-tracked watermark for
3586/// `(pool, this signer, provider)`; a delegate that has never streamed this lane
3587/// starts at zero. The ADR 005 client binding is attached the same as the
3588/// self-owned path — it only authorizes reactive origin pull-through when the
3589/// binding owner also owns the pool, which a delegate does not, so a cache miss
3590/// still refuses (already-cached content serves and is paid via the capability).
3591#[allow(clippy::too_many_arguments)]
3592pub(crate) async fn build_delegated_pool_ctx<P>(
3593    store: &RedbBuyerPoolStore,
3594    contract: &PaymentPool::PaymentPoolInstance<P>,
3595    signer: &Arc<PrivateKeySigner>,
3596    voucher_dom: &Eip712Domain,
3597    provider: Address,
3598    self_address: Address,
3599    chain: &ResolvedChain,
3600    endpoint: &Endpoint,
3601    grant: &CapabilityGrant,
3602) -> anyhow::Result<PoolContext>
3603where
3604    P: alloy::providers::Provider + Clone,
3605{
3606    ensure_delegate_signer(self_address, grant.signer)?;
3607
3608    let signed_capability = grant.to_signed_capability().map_err(|e| {
3609        anyhow::anyhow!("capability token carries a malformed owner signature: {e}")
3610    })?;
3611
3612    let pool_id = grant.pool_id;
3613    let pool = contract.getPool(pool_id).call().await.map_err(|e| {
3614        anyhow::Error::new(e).context(format!("read on-chain pool {pool_id} for the capability"))
3615    })?;
3616    anyhow::ensure!(
3617        pool.owner != Address::ZERO,
3618        "pool {pool_id} named by the capability does not exist on this PaymentPool contract \
3619         ({}) — wrong --payment-pool-address/chain, or a stale token",
3620        chain.payment_pool,
3621    );
3622
3623    // Resume this delegate's lane watermark if a local record exists; a delegate
3624    // that has never streamed this lane starts at zero (a fresh lane).
3625    let lane = LaneKey {
3626        pool_id,
3627        signer: self_address,
3628        provider,
3629    };
3630    let (prior_bytes, prior_amount) = store
3631        .get_by_pool_id(pool_id)?
3632        .and_then(|state| state.lane_progress(lane))
3633        .map_or((U256::ZERO, U256::ZERO), |p| (p.last_bytes, p.last_amount));
3634
3635    // A transient state carrying the informational pool facts the context reads
3636    // (`pool_id`, `deposit`); it is not persisted (the delegate owns no pool row).
3637    // The pool does not store its token — it is the contract's immutable
3638    // `usdc()`, so read it there rather than duplicating it per pool.
3639    let token = contract
3640        .usdc()
3641        .call()
3642        .await
3643        .map_err(|e| anyhow::Error::new(e).context("read PaymentPool.usdc()"))?;
3644    let state = BuyerPoolState::new(
3645        pool_id,
3646        chain.deployment(),
3647        pool.owner,
3648        token,
3649        U256::from(pool.deposit),
3650    );
3651    let ctx = PoolContext::for_pool(&state, Arc::clone(signer), voucher_dom.clone())
3652        .with_provider(provider, prior_bytes, prior_amount)
3653        .with_capability(signed_capability);
3654    attach_client_binding(ctx, chain, endpoint, signer)
3655}
3656
3657/// Attach the ADR 005 client identity binding to an already-built
3658/// [`PoolContext`] (#1115): sign our OWN iroh `NodeId` with the buyer key so
3659/// the serving node can prove we own the pool and reactively pull a
3660/// cache-missed blob from its configured origin. Separate from
3661/// [`build_pool_ctx`] so `decdn bundle pull` — which takes the `open_lock`
3662/// itself around [`open_or_reuse_pool`] — can attach the same binding outside
3663/// that critical section.
3664///
3665/// No binding when `chain.capacity_bond` is unset — see [`build_pool_ctx`]'s
3666/// docs for why that is silent.
3667pub(crate) fn attach_client_binding(
3668    ctx: PoolContext,
3669    chain: &ResolvedChain,
3670    endpoint: &Endpoint,
3671    signer: &Arc<PrivateKeySigner>,
3672) -> anyhow::Result<PoolContext> {
3673    let Some(capacity_bond) = chain.capacity_bond else {
3674        return Ok(ctx);
3675    };
3676    let bind_dom = bind_node_id_domain(chain.chain_id, capacity_bond);
3677    let own_node_id = B256::from(*endpoint.id().as_bytes());
3678    Ok(ctx.with_client_binding(sign_client_binding(signer, own_node_id, &bind_dom)?))
3679}
3680
3681/// Adopt the live pool `owner` already holds on `deployment`, recording it in
3682/// the client store, or return `None` if the chain lists no `Open` pool with
3683/// deposit left to spend. The adopted row carries the pool's `totalRedeemed`
3684/// as [`BuyerPoolState::redeemed_elsewhere`].
3685///
3686/// The node adopts by the same rule at bootstrap (the newest `Open`, solvent
3687/// pool); the client does it on demand, because a client is a one-shot process
3688/// with no bootstrap to hang it on. The two differ on failure, deliberately. The
3689/// node fails open: an unreadable enumeration leaves the first miss to open a
3690/// fresh pool, because a daemon that could not buy for the rest of its life is
3691/// worse than an occasional stranded deposit. The client fails closed: a read
3692/// that faults errors rather than answering `None`, because a one-shot fetch can
3693/// simply be rerun, and "could not tell" must not become "go open another pool".
3694/// Making the two consistent would mean weakening one of them.
3695async fn adopt_owned_pool<P>(
3696    store: &RedbBuyerPoolStore,
3697    contract: &PaymentPool::PaymentPoolInstance<P>,
3698    owner: Address,
3699    deployment: Deployment,
3700) -> anyhow::Result<Option<BuyerPoolState>>
3701where
3702    P: alloy::providers::Provider + Clone,
3703{
3704    let Some((pool_id, pool)) = newest_solvent_owned_pool(contract, owner).await.context(
3705        "could not ask the chain whether this wallet already owns a pool, so refusing to \
3706             open one blind — rerun, or check the RPC endpoint",
3707    )?
3708    else {
3709        return Ok(None);
3710    };
3711    let token =
3712        contract.usdc().call().await.with_context(|| {
3713            format!("read PaymentPool.usdc() while adopting live pool {pool_id}")
3714        })?;
3715    // The row has no lanes to account for what the pool already paid out, so it
3716    // keeps `totalRedeemed`, less what each lane it seeds takes over.
3717    let state = BuyerPoolState::adopt(
3718        pool_id,
3719        deployment,
3720        owner,
3721        token,
3722        U256::from(pool.deposit),
3723        U256::from(pool.totalRedeemed),
3724    );
3725    store.record(&state).with_context(|| {
3726        format!(
3727            "found live pool {pool_id} on chain but could not record it in the local buyer \
3728             store; nothing was escrowed, so rerunning retries the adoption"
3729        )
3730    })?;
3731    tracing::info!(
3732        %pool_id,
3733        deposit = pool.deposit,
3734        redeemed = pool.totalRedeemed,
3735        "adopted a live buyer pool this wallet already owns on chain, instead of opening a \
3736         second one"
3737    );
3738    Ok(Some(state))
3739}
3740
3741/// The on-chain `(bytes, amount)` watermark for `lane` — what a lane with no
3742/// local record resumes from.
3743///
3744/// It reflects redeemed vouchers only, so a provider still holding one it has
3745/// not redeemed is ahead of it. That gap repairs itself inside the fetch: the
3746/// provider answers the first stale proof that its lane headroom cannot pay
3747/// with that watermark (the wallet-less resume, #1946), and the driver reseeds
3748/// from it — unless the resume budget is spent, where the fetch fails. Resuming below the chain
3749/// watermark has no such repair: every voucher at or below it redeems nothing
3750/// on chain, whatever the provider does.
3751async fn lane_watermark<P>(
3752    contract: &PaymentPool::PaymentPoolInstance<P>,
3753    lane: LaneKey,
3754) -> anyhow::Result<(U256, U256)>
3755where
3756    P: alloy::providers::Provider + Clone,
3757{
3758    let onchain = contract
3759        .getWatermark(lane.pool_id, lane.signer, lane.provider)
3760        .call()
3761        .await
3762        .context(
3763            "could not read this lane's on-chain watermark, so refusing to resume it from zero, \
3764             where it would stream bytes the provider cannot cash",
3765        )?;
3766    Ok((
3767        U256::from(onchain.bytesDelivered),
3768        U256::from(onchain.amount),
3769    ))
3770}
3771
3772/// The pool this buy reuses, if any, and what that pool has already paid out
3773/// beyond what its row's lanes account for.
3774///
3775/// A tracked row is reused only if it is on `deployment` — the same chain and
3776/// the same `PaymentPool` address. A row from another deployment is not this
3777/// contract's pool, whatever its id says: `pool_id` is
3778/// `keccak256(owner, ownerPoolNonce)` and a redeploy restarts that nonce, so the
3779/// id alone will eventually name an existing, unrelated pool here, and its lane
3780/// progress would seed the first voucher at a cumulative this pool has never
3781/// redeemed against. It is treated as no row.
3782///
3783/// A tracked row is reused only while its pool is open. Only the owner closes a
3784/// pool, but the owner key can close it from another machine, and anyone can
3785/// reclaim it once its dispute window ends. Nodes answer a request on a closed
3786/// pool `NotFound`, the same as a missing blob, so a fetch on it never reaches
3787/// a funding recovery step. So the first lane build of a run reads the pool
3788/// once ([`still_open`]), and a pool the chain shows `Closing` or `Closed` is
3789/// treated as no row: the run adopts or opens another.
3790///
3791/// No row is not the same as no pool. The store loses rows — a reset data dir, a
3792/// new machine, a store-format change — while the pool they named is still open
3793/// on chain with deposit in it. Where `adoption` allows, the chain is asked
3794/// before anything is opened, so a lost row adopts the live pool instead of
3795/// escrowing a second deposit beside it, which reverts when the wallet's
3796/// remaining USDC cannot cover it.
3797///
3798/// An adopted row carries the pool's `totalRedeemed` as
3799/// [`BuyerPoolState::redeemed_elsewhere`], and keeps the part no seeded lane
3800/// has taken over on every later run.
3801/// The row has no lanes to account for what the pool paid out before
3802/// adoption, and without it the refill decision reads a pool other lanes
3803/// drained as a full deposit: no top-up fires, and the provider refuses the
3804/// fetch on a balance the client believes it has.
3805async fn pool_to_reuse<P>(
3806    store: &RedbBuyerPoolStore,
3807    contract: &PaymentPool::PaymentPoolInstance<P>,
3808    funding: &RunFunding,
3809    self_address: Address,
3810    deployment: Deployment,
3811    adoption: ChainAdoption,
3812) -> anyhow::Result<Option<BuyerPoolState>>
3813where
3814    P: alloy::providers::Provider + Clone,
3815{
3816    let tracked = match store.get_by_owner(self_address)? {
3817        Some(state) if state.is_on(deployment) => Some(state),
3818        Some(state) => {
3819            tracing::warn!(
3820                pool_id = %state.pool_id,
3821                foreign_payment_pool = %state.deployment.payment_pool,
3822                foreign_chain_id = state.deployment.chain_id,
3823                configured_payment_pool = %deployment.payment_pool,
3824                configured_chain_id = deployment.chain_id,
3825                "ignoring a tracked buyer pool from another PaymentPool deployment; its deposit, \
3826                 if any, is recoverable only against `foreign_payment_pool`"
3827            );
3828            None
3829        }
3830        None => None,
3831    };
3832    // A lane of this run already pays from the pool, so its first build read it.
3833    let tracked = match tracked {
3834        Some(state) if funding.pool_id() != Some(state.pool_id) => {
3835            let kept = still_open(contract, self_address, state).await;
3836            if let Some(state) = &kept {
3837                funding.note_pool(state.pool_id);
3838            }
3839            kept
3840        }
3841        other => other,
3842    };
3843    Ok(match (tracked, adoption) {
3844        (Some(state), _) => Some(state),
3845        (None, ChainAdoption::Refused) => None,
3846        (None, ChainAdoption::Allowed) => {
3847            adopt_owned_pool(store, contract, self_address, deployment).await?
3848        }
3849    })
3850}
3851
3852/// `state` while the chain shows its pool open, or `None` once the chain shows
3853/// this owner's pool `Closing` or `Closed`. A failed read, or a pool the chain
3854/// holds no record of yet, keeps `state`: a lagging RPC can miss a pool opened
3855/// moments ago, and replacing it on that read would escrow a second deposit.
3856async fn still_open<P>(
3857    contract: &PaymentPool::PaymentPoolInstance<P>,
3858    self_address: Address,
3859    state: BuyerPoolState,
3860) -> Option<BuyerPoolState>
3861where
3862    P: alloy::providers::Provider + Clone,
3863{
3864    match contract.getPool(state.pool_id).call().await {
3865        Ok(pool)
3866            if pool.owner == self_address && !matches!(pool.status, PaymentPool::Status::Open) =>
3867        {
3868            tracing::info!(
3869                pool_id = %state.pool_id,
3870                status = super::pool::status_label(pool.status),
3871                "the tracked buyer pool no longer accepts work; a new pool replaces it"
3872            );
3873            None
3874        }
3875        Ok(_) => Some(state),
3876        Err(error) => {
3877            tracing::warn!(
3878                pool_id = %state.pool_id,
3879                error = %error,
3880                "could not read the tracked buyer pool's status; reusing it"
3881            );
3882            Some(state)
3883        }
3884    }
3885}
3886
3887/// Record `lane`'s on-chain watermark `(bytes, amount)` in the row before the
3888/// lane resumes from it ([`BuyerPoolState::seed_lane`]), and return the row
3889/// with it and the progress the lane resumes from.
3890///
3891/// The pool spend the refill and the run's funding read
3892/// ([`BuyerPoolState::pool_spend`]) then counts the watermark once. On an
3893/// adopted row it moves out of the redeemed spend no lane accounts for, so the
3894/// spend keeps counting the other lanes' redemptions as this lane advances. A
3895/// zero watermark has nothing to record. A row another run already moved past
3896/// the watermark wins: the lane resumes from the row's progress.
3897///
3898/// # Errors
3899///
3900/// The buyer store could not commit the seed, or the row is gone or names
3901/// another pool now (another run forgot or replaced it). Either way the build
3902/// fails and retries from a fresh read: going on would price the lane, or
3903/// escrow a refill, against a row the store no longer holds.
3904fn seed_lane(
3905    store: &RedbBuyerPoolStore,
3906    mut state: BuyerPoolState,
3907    lane: LaneKey,
3908    bytes: U256,
3909    amount: U256,
3910) -> anyhow::Result<(BuyerPoolState, U256, U256)> {
3911    if bytes.is_zero() && amount.is_zero() {
3912        return Ok((state, bytes, amount));
3913    }
3914    let pool_id = state.pool_id;
3915    let outcome = store
3916        .seed_progress(state.owner, pool_id, lane, bytes, amount)
3917        .with_context(|| {
3918            format!(
3919                "record the on-chain watermark of lane {} in pool {pool_id}",
3920                lane.provider
3921            )
3922        })?;
3923    match outcome {
3924        AdvanceOutcome::Advanced => {
3925            // The in-memory row has no record of the lane, so the seed takes.
3926            if let Err(err) = state.seed_lane(lane, bytes, amount) {
3927                tracing::debug!(error = %err, "lane seed did not advance the in-memory row");
3928            }
3929            Ok((state, bytes, amount))
3930        }
3931        AdvanceOutcome::Regressed(_) => {
3932            let row = store.get_by_pool_id(pool_id)?.ok_or_else(|| {
3933                anyhow::anyhow!("buyer row for pool {pool_id} vanished during a lane seed")
3934            })?;
3935            let progress = row.lane_progress(lane).ok_or_else(|| {
3936                anyhow::anyhow!(
3937                    "buyer row for pool {pool_id} lost lane {} during a seed",
3938                    lane.provider
3939                )
3940            })?;
3941            Ok((row, progress.last_bytes, progress.last_amount))
3942        }
3943        AdvanceOutcome::UnknownPool | AdvanceOutcome::PoolMismatch => Err(anyhow::anyhow!(
3944            "the buyer row for pool {pool_id} was forgotten or replaced while lane {} was built; \
3945             the build retries from the store",
3946            lane.provider
3947        )),
3948    }
3949}
3950
3951/// The error for a pool with no unspent deposit whose wallet cannot fund a
3952/// top-up: [`NoAffordableSource`], which is fatal to the command even inside a
3953/// lane build. Nothing a retry or another provider does can pay for the fetch.
3954fn cannot_pay(state: &BuyerPoolState, shortfall: &str) -> anyhow::Error {
3955    anyhow::Error::new(NoAffordableSource {
3956        deposit: state.deposit,
3957    })
3958    .context(format!(
3959        "buyer pool {} has no unspent deposit, and the wallet cannot fund a top-up: \
3960         {shortfall}",
3961        state.pool_id
3962    ))
3963}
3964
3965/// Restore `state`'s remaining deposit (`deposit - spent`) to `working_deposit`
3966/// once it falls below the low water — see [`refill_amount`] — and return the
3967/// row to build the lane on.
3968///
3969/// `spent` is the pool-wide spend from [`BuyerPoolState::pool_spend`]. The refill is an
3970/// optimisation, so a wallet that holds too little USDC for it does not fail
3971/// the lane while the pool can still pay (and ends the command, as
3972/// [`NoAffordableSource`], once it cannot): the shortfall is logged at WARN,
3973/// recorded on `funding` for the run's closing `warning:` line, and no later
3974/// lane build of the run tries the refill again. Any other failure of the
3975/// allowance or `topUp` leg fails the lane build, which the acquire loop
3976/// retries with backoff. A `topUp` that may have been broadcast but returned no
3977/// receipt ([`TopUpUnconfirmed`]) may have escrowed, and once `topUp` returns a
3978/// receipt the escrow has moved, so a failure to credit or re-read the local
3979/// row ([`escrowed_but_untracked`]) is fatal: the acquire loop ends the
3980/// command instead of retrying, because a retry escrows again.
3981#[allow(clippy::too_many_arguments)]
3982async fn refill_if_low<P>(
3983    store: &RedbBuyerPoolStore,
3984    contract: &PaymentPool::PaymentPoolInstance<P>,
3985    rpc: &P,
3986    funding: &RunFunding,
3987    state: BuyerPoolState,
3988    self_address: Address,
3989    spent: U256,
3990    working_deposit: U256,
3991    max_approve: bool,
3992) -> anyhow::Result<BuyerPoolState>
3993where
3994    P: alloy::providers::Provider + Clone,
3995{
3996    let low_water = working_deposit / U256::from(LOW_WATER_DIVISOR);
3997    let additional = refill_amount(state.deposit, spent, working_deposit, low_water);
3998    if additional.is_zero() {
3999        return Ok(state);
4000    }
4001    let remaining = state.deposit.saturating_sub(spent);
4002    if let Some(shortfall) = funding.shortfall() {
4003        // The wallet could not fund a top-up earlier in this run; asking again
4004        // costs an allowance read and a reverting `topUp` estimate per lane.
4005        if remaining.is_zero() {
4006            return Err(cannot_pay(&state, &shortfall));
4007        }
4008        return Ok(state);
4009    }
4010    tracing::info!(
4011        "buyer pool {} low on deposit ({remaining} µUSDC remaining of {} deposited); \
4012         topping up {additional} µUSDC",
4013        state.pool_id,
4014        state.deposit,
4015    );
4016    // `topUp` pulls `additional` USDC via `transferFrom`, so the pool's
4017    // standing allowance must cover it first. Ensure it in the caller's
4018    // mode: unlimited under `--max-approve`, else exactly `additional`.
4019    let escrowed = async {
4020        ensure_allowance(
4021            rpc,
4022            state.token,
4023            self_address,
4024            *contract.address(),
4025            if max_approve { None } else { Some(additional) },
4026        )
4027        .await?;
4028        top_up(contract, self_address, state.pool_id, additional).await
4029    }
4030    .await;
4031    let err = match escrowed {
4032        Ok(ToppedUpPool { credited, tx, .. }) => {
4033            // The USDC is escrowed the moment `topUp` mines. A local credit that
4034            // does not land leaves the deposit untracked, and continuing would
4035            // fetch on a `state.deposit` that understates the chain — so the
4036            // low-water check re-fires on every later fetch while nobody
4037            // reconciles the escrow. No bytes have been paid for on *this* entry
4038            // yet — `bundle pull` reaches this once per entry, through
4039            // `open_or_reuse_pool`, so earlier entries may already be paid for and
4040            // written — and only the escrow moved, so failing here strands nothing
4041            // in flight. The funding recovery step takes the same disposition
4042            // (`CliFunder::recover`).
4043            let effect = topped_up_effect(state.pool_id, credited);
4044            grade_deposit_credit(
4045                store.add_deposit(self_address, state.pool_id, credited),
4046                &effect,
4047                tx,
4048            )?;
4049            // The credit committed, so the row must be there. A `None` here
4050            // means it vanished between the write and this read — the local
4051            // record did not survive, which is the same untracked-escrow
4052            // condition, not something to paper over with the pre-top-up
4053            // snapshot.
4054            return store
4055                .get_by_pool_id(state.pool_id)?
4056                .ok_or_else(|| escrowed_but_untracked(&effect, tx, "the credited row vanished"));
4057        }
4058        Err(err) => err,
4059    };
4060    if err.downcast_ref::<TopUpUnconfirmed>().is_some() {
4061        return Err(err);
4062    }
4063    let Some(shortfall) = funding
4064        .check_wallet(
4065            rpc,
4066            state.token,
4067            self_address,
4068            state.pool_id,
4069            additional,
4070            &err,
4071        )
4072        .await
4073    else {
4074        return Err(err);
4075    };
4076    if remaining.is_zero() {
4077        return Err(cannot_pay(&state, &shortfall));
4078    }
4079    // `check_wallet` already logged the shortfall at WARN.
4080    tracing::info!(
4081        pool_id = %state.pool_id,
4082        %remaining,
4083        "continuing on the pool's remaining deposit (µUSDC) without a top-up"
4084    );
4085    Ok(state)
4086}
4087
4088/// Reuse the caller's live pool (resuming `provider`'s lane watermark), or open
4089/// and persist a new one. A reused pool whose pool-wide remaining deposit has
4090/// run low gets an on-chain `topUp` before it is returned; a wallet too short
4091/// of USDC for it still returns the pool while it can pay — see
4092/// [`refill_if_low`]. The lane then joins `funding`, the run's view of the
4093/// pool's spend outside its lanes ([`RunFunding::join_lane`]). There is no
4094/// pool expiry (ADR 003), so
4095/// there is no replace-on-expiry branch: the same pool is reused for the
4096/// caller's whole lifetime, across every provider.
4097///
4098/// `deployment` is the chain and `PaymentPool` that `contract` talks to: a
4099/// tracked row is reused only on it, a new or adopted row carries it, and the
4100/// voucher EIP-712 domain derives from it.
4101#[allow(clippy::too_many_arguments)]
4102pub(crate) async fn open_or_reuse_pool<P>(
4103    store: &RedbBuyerPoolStore,
4104    contract: &PaymentPool::PaymentPoolInstance<P>,
4105    rpc: &P,
4106    funding: &RunFunding,
4107    signer: &Arc<PrivateKeySigner>,
4108    provider: Address,
4109    self_address: Address,
4110    deployment: Deployment,
4111    working_deposit: U256,
4112    max_approve: bool,
4113    adoption: ChainAdoption,
4114) -> anyhow::Result<(PoolContext, LaneSpend)>
4115where
4116    P: alloy::providers::Provider + Clone,
4117{
4118    let payment_pool_addr = deployment.payment_pool;
4119    let tracked =
4120        pool_to_reuse(store, contract, funding, self_address, deployment, adoption).await?;
4121    if let Some(state) = tracked {
4122        let lane = LaneKey {
4123            pool_id: state.pool_id,
4124            signer: self_address,
4125            provider,
4126        };
4127        let recorded = state.lane_progress(lane).is_some();
4128        let from_chain = !state.redeemed_elsewhere().is_zero();
4129        let (state, prior_bytes, prior_amount) = if let Some(p) = state.lane_progress(lane) {
4130            (state, p.last_bytes, p.last_amount)
4131        } else {
4132            // No local record of this lane — always so for a pool just adopted, and
4133            // for a tracked pool's first contact with a provider. The chain may
4134            // still hold a watermark for it, and a voucher at or below that
4135            // watermark redeems nothing — so resuming from zero would stream bytes
4136            // the provider can never cash. Resume from the chain.
4137            let (bytes, amount) = lane_watermark(contract, lane).await?;
4138            seed_lane(store, state, lane, bytes, amount)?
4139        };
4140
4141        // Auto-refill a live pool whose remaining deposit has run low, so a
4142        // sustained series of fetches isn't stranded by a spent-down deposit.
4143        let spent = state.pool_spend();
4144        let state = refill_if_low(
4145            store,
4146            contract,
4147            rpc,
4148            funding,
4149            state,
4150            self_address,
4151            spent,
4152            working_deposit,
4153            max_approve,
4154        )
4155        .await?;
4156        let lane_spend = LaneSpend {
4157            outside: spent.saturating_sub(prior_amount),
4158            prior: prior_amount,
4159            recorded,
4160            from_chain,
4161        };
4162        return self_owned_lane_ctx(
4163            &state,
4164            signer,
4165            &deployment.voucher_domain(),
4166            provider,
4167            prior_bytes,
4168            prior_amount,
4169        )
4170        .map(|ctx| (ctx, lane_spend));
4171    }
4172
4173    // Authoritative USDC token for the pool, from the contract itself.
4174    let token = contract
4175        .usdc()
4176        .call()
4177        .await
4178        .map_err(|e| anyhow::Error::new(e).context("read PaymentPool.usdc()"))?;
4179    // Escrowed as configured — there is no on-chain floor to clamp up to,
4180    // only a non-zero requirement (`openPool` reverts `ZeroAmount`). The shared
4181    // pool is fully withdrawable, so the buyer opens at the working deposit
4182    // directly rather than a smaller first-contact lock.
4183    let deposit = working_deposit;
4184    // `max_approve` opts into an unlimited standing allowance; otherwise approve
4185    // exactly the deposit being escrowed.
4186    let approve_amount = if max_approve { None } else { Some(deposit) };
4187    ensure_allowance(rpc, token, self_address, payment_pool_addr, approve_amount).await?;
4188    let opened = open_pool(
4189        contract,
4190        Arc::clone(signer),
4191        deployment,
4192        token,
4193        self_address,
4194        deposit,
4195    )
4196    .await?;
4197    // The deposit is escrowed on-chain; a failed local record leaves it
4198    // untracked (reconcile against the tx).
4199    store
4200        .record(&opened.state)
4201        .map_err(|e| escrowed_but_untracked("buyer pool opened", opened.tx, e))?;
4202    // A fresh pool has spent nothing on any lane.
4203    let spend = LaneSpend {
4204        outside: U256::ZERO,
4205        prior: U256::ZERO,
4206        recorded: false,
4207        from_chain: false,
4208    };
4209    Ok((
4210        opened
4211            .ctx
4212            .with_provider(provider, U256::ZERO, U256::ZERO)
4213            .with_capability(opened.capability),
4214        spend,
4215    ))
4216}
4217
4218/// A unique `O_CREAT|O_EXCL` temp file in `target`'s directory, ready to be
4219/// `persist`ed over it. Staging beside the destination is what makes the final
4220/// rename atomic (same filesystem) — a temp in `/tmp` would not be.
4221pub(crate) fn temp_in_parent(target: &Path) -> std::io::Result<tempfile::NamedTempFile> {
4222    match target.parent().filter(|p| !p.as_os_str().is_empty()) {
4223        Some(p) => tempfile::NamedTempFile::new_in(p),
4224        None => tempfile::NamedTempFile::new_in("."),
4225    }
4226}
4227
4228#[cfg(test)]
4229#[allow(
4230    clippy::unwrap_used,
4231    clippy::expect_used,
4232    clippy::indexing_slicing,
4233    clippy::panic
4234)]
4235mod tests;
4236
4237/// `open_or_reuse_pool` against a mocked `PaymentPool`: what a client does when
4238/// its store has no row for a pool the chain says it owns.
4239#[cfg(test)]
4240#[allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)]
4241mod adoption_tests;
4242
4243/// Probe-shaped fixtures shared by the fetch and source tests.
4244#[cfg(test)]
4245pub(crate) mod tests_support;