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 so a
2292/// reachable node is probed relay-free (ADR 001 § Node Discovery).
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).
2305pub(crate) fn lane_target(
2306    candidate: &NodeCandidate,
2307    addr: Option<std::net::SocketAddr>,
2308    relays: &[RelayUrl],
2309) -> EndpointAddr {
2310    let mut target = EndpointAddr::new(candidate.node_id);
2311    if let Some(addr) = addr {
2312        target = target.with_ip_addr(addr);
2313    }
2314    if let Some(url) = relays.first() {
2315        target = target.with_relay_url(url.clone());
2316    }
2317    // Registry multiaddrs as direct-address hints: a reachable lane provider
2318    // connects without a relay (ADR 001 § Node Discovery).
2319    discovery::with_dial_addrs(target, candidate)
2320}
2321
2322/// Build one [`MultiLane`] for `provider` at `target`: open/reuse the pool,
2323/// seed a ledger from that lane's persisted cumulative, and wrap a
2324/// [`PeerSource`] over it.
2325///
2326/// `open_lock`, when `Some`, serializes the pool open-or-reuse inside
2327/// [`build_ctx_for_fetch`]: across one fetch's concurrently built lanes, which
2328/// all open or reuse the one on-chain pool, and across other fetches sharing
2329/// that pool (bundle pull's cross-entry concurrency, #1774); the guard is dropped before
2330/// any streaming, and skipped entirely on the delegated path, which opens
2331/// nothing on-chain. A caller that holds a provider's stream permit takes it
2332/// before `open_lock`, so the lock order stays permit → `open_lock` and no
2333/// hold-and-wait cycle can form.
2334#[allow(clippy::too_many_arguments)]
2335pub(crate) async fn build_multi_lane<'a, P>(
2336    deps: &DriveFetchDeps<'a, P>,
2337    grant: Option<&CapabilityGrant>,
2338    signer: &Arc<PrivateKeySigner>,
2339    voucher_dom: &Eip712Domain,
2340    provider: Address,
2341    target: EndpointAddr,
2342    open_lock: Option<&tokio::sync::Mutex<()>>,
2343    ledgers: Option<&LaneLedgers>,
2344) -> anyhow::Result<MultiLane<'a>>
2345where
2346    P: alloy::providers::Provider + Clone,
2347{
2348    let (ctx, spend) = {
2349        // Bundle pull funnels every entry — and every lane of a multi-source
2350        // entry — through the one shared `PaymentPool` deposit, so the
2351        // open-or-reuse serializes across the whole bundle. The delegated path
2352        // opens nothing on-chain (`build_delegated_pool_ctx` adopts the owner's
2353        // pool), so it skips the guard: holding the bundle-wide lock there
2354        // would serialize unrelated fetches for no mutual-exclusion win.
2355        let _open_guard = match (open_lock, grant) {
2356            (Some(lock), None) => Some(lock.lock().await),
2357            _ => None,
2358        };
2359        build_ctx_for_fetch(
2360            grant,
2361            deps.store,
2362            deps.contract,
2363            deps.rpc,
2364            signer,
2365            voucher_dom,
2366            provider,
2367            deps.self_address,
2368            deps.chain,
2369            deps.endpoint,
2370            deps.funding,
2371        )
2372        .await?
2373    };
2374    let pool_id = ctx.pool_id;
2375    let prior_amount = ctx.prior_amount;
2376    let lane = LaneKey {
2377        pool_id,
2378        signer: deps.self_address,
2379        provider,
2380    };
2381    // The lane's ledger + context, seeded from its persisted `(signer,
2382    // provider)` cumulative so the first voucher continues the lane (a restart
2383    // from zero is rejected as a regression) — from the run registry when
2384    // `ledgers` is `Some`, else a fresh pair.
2385    let (ledger, ctx) = lane_ledger(ledgers, lane, ctx);
2386    // The run's view of the pool's spend counts this lane through its ledger
2387    // from now on, whether or not the acquire loop ever starts it. A delegated
2388    // lane draws on a pool this caller does not own, and joins nothing.
2389    if let Some(spend) = spend {
2390        deps.funding.join_lane(lane, spend, &ledger);
2391    }
2392    let source = lane_source(
2393        deps,
2394        target,
2395        Arc::clone(&ctx),
2396        Arc::clone(&ledger),
2397        provider,
2398    );
2399    if let Some(timings) = deps.timings {
2400        timings.mark(Mark::Lane);
2401    }
2402    Ok(MultiLane {
2403        provider,
2404        pool_id,
2405        prior_amount,
2406        ctx,
2407        ledger,
2408        source,
2409    })
2410}
2411
2412/// `provider`'s lane source at `target`, paying through `ctx` and `ledger`.
2413/// Every open takes a stream on the command's connection to `target`
2414/// ([`DriveFetchDeps::connections`]).
2415fn lane_source<'a, P>(
2416    deps: &DriveFetchDeps<'a, P>,
2417    target: EndpointAddr,
2418    ctx: Arc<Mutex<PoolContext>>,
2419    ledger: Arc<PoolLedger>,
2420    provider: Address,
2421) -> PeerSource<'a> {
2422    PeerSource::new(
2423        deps.endpoint,
2424        target,
2425        ctx,
2426        ledger,
2427        deps.slash_dom,
2428        provider,
2429        deps.namespace_id,
2430        deps.max_blob_bytes,
2431        deps.max_rate_per_mb,
2432        deps.deadlines,
2433        Some(deps.connections.clone()),
2434    )
2435}
2436
2437/// One lane's watermark-persistence handle: what [`stream_lane_watermarks`]
2438/// needs to settle the lane AFTER the fetch, kept alive after the lane's owned
2439/// [`PeerSource`] has moved into its [`decdn_client::StreamCandidate`]. The
2440/// `ledger` and
2441/// `ctx` are the SAME `Arc`s the fetch paid through, so the lane's settlement
2442/// is read here once the fetch ends.
2443pub(crate) struct FaceLaneHandle {
2444    pub(crate) pool_id: PoolId,
2445    pub(crate) provider: Address,
2446    pub(crate) prior_amount: U256,
2447    pub(crate) ledger: Arc<PoolLedger>,
2448    /// The lane's buyer context, read to annotate an unbound cache miss.
2449    pub(crate) ctx: Arc<Mutex<PoolContext>>,
2450}
2451
2452/// The per-lane [`LaneWatermark`]s to persist after a face fetch, read from the
2453/// retained [`FaceLaneHandle`]s — the same settle-at-armed-cumulative rule
2454/// [`multi_lane_watermarks`] then keys for persistence.
2455///
2456/// One watermark per ledger: a lane rebuilt on the run's shared ledger leaves a
2457/// second handle on it, and the first read takes the ledger's unsaved rebase,
2458/// so a second write for it would only repeat the first. Each rebuild read its
2459/// own baseline from the store, and a sibling entry's rebase can have moved
2460/// that baseline down since the first build, so the watermark takes the lowest
2461/// one: the advance past it still persists.
2462fn stream_lane_watermarks(lanes: &[FaceLaneHandle]) -> Vec<LaneWatermark> {
2463    let mut per_ledger: Vec<(&FaceLaneHandle, U256)> = Vec::new();
2464    for l in lanes {
2465        match per_ledger
2466            .iter_mut()
2467            .find(|(first, _)| Arc::ptr_eq(&first.ledger, &l.ledger))
2468        {
2469            Some((_, prior)) => *prior = (*prior).min(l.prior_amount),
2470            None => per_ledger.push((l, l.prior_amount)),
2471        }
2472    }
2473    per_ledger
2474        .into_iter()
2475        .map(|(l, prior_amount)| LaneWatermark {
2476            pool_id: l.pool_id,
2477            provider: l.provider,
2478            prior_amount,
2479            // Taken before the settlement read, so the settlement is at or above it.
2480            rebase_anchor: l.ledger.take_unsaved_rebase(),
2481            settlement: l.ledger.settlement(),
2482        })
2483        .collect()
2484}
2485
2486/// Copy a [`decdn_client::VerifiedReader`]'s verified bytes to `sink`,
2487/// feeding `on_progress` the cumulative content bytes drained. Returns the total
2488/// drained. Only bao-verified bytes ever leave the reader, so `sink` (stdout) is
2489/// safe to pipe.
2490async fn copy_verified<R, W>(
2491    reader: &mut R,
2492    sink: &mut W,
2493    total_bytes: u64,
2494    on_progress: Option<&dyn Fn(u64, u64)>,
2495) -> anyhow::Result<u64>
2496where
2497    R: tokio::io::AsyncRead + Unpin,
2498    W: tokio::io::AsyncWrite + Unpin,
2499{
2500    use tokio::io::{AsyncReadExt, AsyncWriteExt};
2501
2502    let mut buf = vec![0u8; 256 * 1024];
2503    let mut drained: u64 = 0;
2504    loop {
2505        let n = reader
2506            .read(&mut buf)
2507            .await
2508            .map_err(|e| anyhow::anyhow!("read verified stream: {e}"))?;
2509        if n == 0 {
2510            break;
2511        }
2512        let chunk = buf
2513            .get(..n)
2514            .ok_or_else(|| anyhow::anyhow!("read returned more than the buffer holds"))?;
2515        sink.write_all(chunk)
2516            .await
2517            .map_err(|e| anyhow::anyhow!("write to stdout: {e}"))?;
2518        drained = drained.saturating_add(u64::try_from(n).unwrap_or(u64::MAX));
2519        if let Some(cb) = on_progress {
2520            cb(drained, total_bytes);
2521        }
2522    }
2523    sink.flush()
2524        .await
2525        .map_err(|e| anyhow::anyhow!("flush stdout: {e}"))?;
2526    Ok(drained)
2527}
2528
2529/// One lane's buyer-store watermark write: the lane and the progress to record.
2530type LaneWrite = (LaneKey, VoucherProgress);
2531
2532/// The watermark write every face lane calls for, read from the retained
2533/// handles now — the same settle-at-armed-cumulative rule the file path
2534/// applies, one write per lane ledger.
2535fn face_watermark_writes(self_address: Address, handles: &[FaceLaneHandle]) -> Vec<LaneWrite> {
2536    multi_lane_watermarks(self_address, &stream_lane_watermarks(handles))
2537}
2538
2539/// Apply every write in `writes`. Blocking: each write that moves the row is a
2540/// redb commit.
2541fn write_watermarks(store: &RedbBuyerPoolStore, self_address: Address, writes: Vec<LaneWrite>) {
2542    for (lane, vprogress) in writes {
2543        persist_watermark(store, self_address, lane.pool_id, lane, &vprogress);
2544    }
2545}
2546
2547/// Read every face lane's voucher watermark from its ledger now
2548/// ([`face_watermark_writes`]), queue the writes on the command's ordered
2549/// `writes`, and return a receiver that resolves once they land. The faces do
2550/// not persist, so the thin CLI layer does it after the fetch, before surfacing
2551/// any error: the bytes each lane delivered are paid for whatever the outcome.
2552///
2553/// Each write that moves the row is a durable redb commit, and a `bundle
2554/// pull`'s lanes share the caller's task, so a commit inline would stop every
2555/// lane of the run while it syncs (#2211). The queue keeps the writes in the
2556/// order the ledgers were read, across every entry: a rebase replaces the lane
2557/// row outright, so a rebase landing after a newer advance, or a stale advance
2558/// landing after a rebase, would leave the next run signing from the wrong
2559/// progress. The read and the queueing are one synchronous call for that
2560/// reason, and the order holds only while every caller polls on one task, as
2561/// a fetch and a `bundle pull` do.
2562///
2563/// A drop guard, which cannot wait, drops the receiver. The command then waits
2564/// for the queue before it returns ([`OrderedWrites::settle`]), on a Ctrl-C
2565/// too. A second Ctrl-C exits the process at once, and a write still queued or
2566/// running then is lost; the next run may re-sign a stale watermark, which its
2567/// provider rejects. A runtime that shuts down cancels blocking tasks that have
2568/// not started, and the queue then drains on the shutting-down thread.
2569pub(crate) fn queue_face_watermarks(
2570    writes: &OrderedWrites,
2571    store: &Arc<RedbBuyerPoolStore>,
2572    self_address: Address,
2573    handles: &[FaceLaneHandle],
2574) -> tokio::sync::oneshot::Receiver<()> {
2575    queue_watermark_writes(
2576        writes,
2577        store,
2578        self_address,
2579        face_watermark_writes(self_address, handles),
2580    )
2581}
2582
2583/// Queue `lane_writes` on `writes` ([`queue_face_watermarks`]).
2584fn queue_watermark_writes(
2585    writes: &OrderedWrites,
2586    store: &Arc<RedbBuyerPoolStore>,
2587    self_address: Address,
2588    lane_writes: Vec<LaneWrite>,
2589) -> tokio::sync::oneshot::Receiver<()> {
2590    let store = Arc::clone(store);
2591    writes.queue_awaitable(move || write_watermarks(&store, self_address, lane_writes))
2592}
2593
2594/// Stream `hash`'s verified bytes to STDOUT via the [`Streamer`] consumption face
2595/// (#1848 4b). Only bao-verified bytes ever reach the pipe — safe to `| tar x` —
2596/// the fetch is consumption-paced (a slow or early-quit consumer stops the pull
2597/// and the spend within one read-ahead window), and a source that faults
2598/// mid-stream cools while another carries on, with no re-pull or re-pay.
2599///
2600/// `max_sources` is the front lane cap. Progress draws on stderr, only when
2601/// stderr is a terminal; stdout carries nothing but the verified bytes. The
2602/// caller persists each lane's voucher watermark after the stream.
2603#[allow(clippy::too_many_arguments)]
2604async fn stream_to_stdout<P>(
2605    deps: &DriveFetchDeps<'_, P>,
2606    sources: &CliSources<'_, P>,
2607    holders: Vec<Holder>,
2608    health: Arc<PeerHealth>,
2609    hash: [u8; 32],
2610    total_bytes: u64,
2611    stop: StopPolicy,
2612    max_sources: usize,
2613) -> anyhow::Result<()>
2614where
2615    P: alloy::providers::Provider + Clone,
2616{
2617    // Read-ahead stays the default: the outstanding-spend bound for an
2618    // abandoned pipe.
2619    let pull_config = PullConfig {
2620        streamer_lane_cap: max_sources.max(1),
2621        ..PullConfig::new()
2622    };
2623    let funder = sources.funder();
2624
2625    // The fill store's `.partial` lives here for the stream's lifetime; a streamed
2626    // blob is not kept, so a temp dir (removed on drop) under the data dir is its
2627    // natural home.
2628    let scratch = tempfile::tempdir_in(&deps.chain.data_dir)
2629        .map_err(|e| anyhow::anyhow!("open stream scratch dir: {e}"))?;
2630    let mut streamer = Streamer::new(sources, funder, scratch.path())
2631        .holders(holders)
2632        .health(health)
2633        .recovery(Arc::clone(sources.recovery()))
2634        .max_blob_bytes(deps.max_blob_bytes);
2635    if let Some(slot) = sources.credentials() {
2636        streamer = streamer.credentials(slot);
2637    }
2638    let (mut reader, mut drive) = streamer
2639        .open(hash, total_bytes, &pull_config, Arc::new(NoCache), stop)
2640        .await?;
2641
2642    // The bar draws on stderr, so it shows only when stderr is a terminal; stdout
2643    // carries the verified bytes either way.
2644    let bar = std::io::IsTerminal::is_terminal(&std::io::stderr()).then(delivery_progress);
2645    // `copy_verified` reports after each write, so the first report with a
2646    // byte is the first byte on stdout.
2647    let on_progress = |received: u64, expected: u64| {
2648        if received > 0
2649            && let Some(timings) = deps.timings
2650        {
2651            timings.mark(Mark::FirstByte);
2652        }
2653        if let Some((_, cb, _)) = &bar {
2654            cb(received, expected);
2655        }
2656    };
2657
2658    // The drive runs beside the copy, not inside its reads: a blocked stdout
2659    // must not stop an open paid leg from paying and draining. The read-ahead
2660    // window bounds how far it runs ahead of the pipe.
2661    let mut stdout = tokio::io::stdout();
2662    let copy_result = drive
2663        .alongside(copy_verified(
2664            &mut reader,
2665            &mut stdout,
2666            total_bytes,
2667            Some(&on_progress),
2668        ))
2669        .await;
2670
2671    if let Some((bar, _, _)) = &bar {
2672        bar.finish_and_clear();
2673    }
2674    let copied = copy_result.map_err(|read_err| stream_error(read_err, drive.take_error()))?;
2675    tracing::info!("streamed {copied} verified bytes to stdout");
2676    Ok(())
2677}
2678
2679/// The error a failed stdout stream ends with: the fetch's own typed error when
2680/// the drive ended with one (so a give-up, a fatal fault or a unanimous verdict
2681/// keeps its type for the exit code and the remedy hints), else the read or
2682/// write error itself.
2683fn stream_error(read_err: anyhow::Error, fetch_err: Option<anyhow::Error>) -> anyhow::Error {
2684    fetch_err.unwrap_or(read_err)
2685}
2686
2687/// Fetch `hash` to `output` via the [`Downloader`] consumption face (#1848 4c),
2688/// striping across `holders` with at most `max_sources` lanes at once (a
2689/// holder discovery adds later can take a free lane), and print the summary
2690/// line. The `.partial` beside `output` stays in place on a failure for a
2691/// later resume. The caller persists each lane's voucher watermark after the
2692/// fetch.
2693async fn download_to_file<P>(
2694    deps: &DriveFetchDeps<'_, P>,
2695    sources: &CliSources<'_, P>,
2696    holders: Vec<Holder>,
2697    health: Arc<PeerHealth>,
2698    target: DownloadTarget<'_>,
2699    stop: &StopPolicy,
2700    max_sources: usize,
2701) -> anyhow::Result<()>
2702where
2703    P: alloy::providers::Provider + Clone,
2704{
2705    let (bar, on_bar, meter) = delivery_progress();
2706    let on_progress = |received: u64, expected: u64| {
2707        if received > 0
2708            && let Some(timings) = deps.timings
2709        {
2710            timings.mark(Mark::FirstByte);
2711        }
2712        on_bar(received, expected);
2713    };
2714    let mut downloader = Downloader::new(sources, sources.funder())
2715        .holders(holders)
2716        .health(health)
2717        .recovery(Arc::clone(sources.recovery()))
2718        .max_lanes(max_sources)
2719        .max_blob_bytes(deps.max_blob_bytes);
2720    if let Some(slot) = sources.credentials() {
2721        downloader = downloader.credentials(slot);
2722    }
2723    let result =
2724        Box::pin(downloader.fetch_to_paths_until(&[target], Some(&on_progress), stop)).await;
2725    bar.finish_and_clear();
2726    result?;
2727    // The finished file holds exactly the proven size, which can differ from
2728    // the first claim the fetch started from.
2729    let proven = tokio::fs::metadata(target.dest)
2730        .await
2731        .map_or(target.total_bytes, |m| m.len());
2732    print_fetch_summary(proven, &meter, target.dest);
2733    Ok(())
2734}
2735
2736/// One lane's persisted-watermark inputs, lifted out of [`MultiLane`] so the
2737/// per-lane settlement rule is a pure function the tests can drive.
2738#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2739struct LaneWatermark {
2740    pool_id: PoolId,
2741    provider: Address,
2742    /// The lane's persisted prior amount — the baseline the advance is computed
2743    /// against.
2744    prior_amount: U256,
2745    /// The lane's ARMED cumulative: what the lane owes if every voucher it put
2746    /// on the wire was received.
2747    settlement: Cumulative,
2748    /// The watermark the lane's ledger rebased down to and has not persisted.
2749    rebase_anchor: Option<Cumulative>,
2750}
2751
2752/// Pair each lane's own `LaneKey` with the watermark to persist for it.
2753///
2754/// Every lane settles at its ARMED cumulative rather than branching on the
2755/// fetch's outcome. The fetch result is ONE outcome shared by
2756/// every lane, but "did this lane's last voucher land?" is a PER-LANE question,
2757/// and on the multi-source path a successful fetch can leave a lane
2758/// armed-above-committed: a cancelled or stalled leg drops its `fill_gap`
2759/// future wherever it is parked, including inside the voucher exchange that
2760/// `issue` deliberately arms before sending, and a leg stopped at a steal
2761/// split keeps a closing voucher that failed to send armed. Settling that lane at `committed` on the
2762/// fetch's `Ok` persists a cumulative BELOW what the node can redeem, and the
2763/// next fetch on that lane signs a cumulative the upstream already holds —
2764/// rejected as a regression.
2765///
2766/// Settling high costs nothing on the clean path: with nothing armed,
2767/// `settlement()` equals `committed()`.
2768fn multi_lane_watermarks(
2769    signer: Address,
2770    lanes: &[LaneWatermark],
2771) -> Vec<(LaneKey, VoucherProgress)> {
2772    lanes
2773        .iter()
2774        .map(|l| {
2775            (
2776                LaneKey {
2777                    pool_id: l.pool_id,
2778                    signer,
2779                    provider: l.provider,
2780                },
2781                VoucherProgress::from_cumulative(l.settlement, l.prior_amount)
2782                    .with_rebase_anchor(l.rebase_anchor),
2783            )
2784        })
2785        .collect()
2786}
2787
2788/// What the lane builds of one run learn about funding its pool, shared by
2789/// every fetch of the run: one `decdn fetch`, or every entry of a `bundle pull`.
2790///
2791/// It holds two facts. The first is the pool's spend across every lane: a
2792/// baseline for the lanes the run does not drive, plus what each lane the run
2793/// built has committed since. The deposit gate reads it
2794/// ([`Funder::pool_spent`]), so a pool shared across many providers gates on its
2795/// true remaining deposit whichever lanes the acquire loop starts. The second
2796/// is a wallet that holds too little USDC to top the pool up: once seen, no
2797/// later lane build of the run tries the proactive refill again, a funding
2798/// recovery step fails without a transaction, and the command ends with one
2799/// `warning:` line naming it. It also names the pool the run's first built
2800/// lane pays from, which a funding recovery step tops up: every entry of a
2801/// bundle pull shares it, the entries that have built no lane yet included.
2802#[derive(Debug, Default)]
2803pub(crate) struct RunFunding {
2804    spend: Mutex<RunSpend>,
2805    shortfall: Mutex<Option<String>>,
2806    pool_id: OnceLock<PoolId>,
2807}
2808
2809/// What a lane build learned about the pool's spend, for
2810/// [`RunFunding::join_lane`].
2811#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2812pub(crate) struct LaneSpend {
2813    /// The pool's spend on every other lane, as the build saw it.
2814    pub(crate) outside: U256,
2815    /// The amount the lane resumes from.
2816    pub(crate) prior: U256,
2817    /// Whether the pool row recorded the lane before this build seeded it from
2818    /// chain, so that an earlier lane's `outside` counts its prior.
2819    pub(crate) recorded: bool,
2820    /// Whether `outside` includes redeemed spend no tracked lane accounts for
2821    /// ([`BuyerPoolState::redeemed_elsewhere`], from an adopted row's
2822    /// `totalRedeemed`), which counts the watermark of every lane the row has
2823    /// not seeded.
2824    pub(crate) from_chain: bool,
2825}
2826
2827/// The pool's spend as a run's lanes join it.
2828#[derive(Debug, Default)]
2829struct RunSpend {
2830    /// The spend on lanes the run does not drive. `None` until the first lane
2831    /// joins.
2832    baseline: Option<U256>,
2833    /// Whether the first lane's `outside` came from the chain.
2834    from_chain: bool,
2835    /// Every ledger each joined lane has paid through. A lane rebuilt with a
2836    /// fresh ledger keeps its earlier ones: vouchers on a lane are cumulative,
2837    /// so the lane's spend is the largest of them.
2838    lanes: HashMap<LaneKey, Vec<Arc<PoolLedger>>>,
2839}
2840
2841impl RunFunding {
2842    /// Name `pool_id` as the pool the run's lanes pay from. The first lane
2843    /// built names it; every later lane pays from the same pool.
2844    pub(crate) fn note_pool(&self, pool_id: PoolId) {
2845        let _ = self.pool_id.set(pool_id);
2846    }
2847
2848    /// The pool the run's lanes pay from, once a lane is built.
2849    pub(crate) fn pool_id(&self) -> Option<PoolId> {
2850        self.pool_id.get().copied()
2851    }
2852
2853    /// Record that `lane` joins the run, paying through `ledger`.
2854    ///
2855    /// The first lane sets the baseline to its `outside`. Each later lane
2856    /// moves its prior out of the baseline when the baseline counts it: when
2857    /// the row recorded the lane, or when the baseline came from the chain. Its
2858    /// ledger counts that amount from now on. A lane that has joined before
2859    /// only adds `ledger`, if it is a new one.
2860    pub(crate) fn join_lane(&self, lane: LaneKey, spend: LaneSpend, ledger: &Arc<PoolLedger>) {
2861        let mut run = self.spend.lock().unwrap_or_else(PoisonError::into_inner);
2862        if let Some(ledgers) = run.lanes.get_mut(&lane) {
2863            if !ledgers.iter().any(|known| Arc::ptr_eq(known, ledger)) {
2864                ledgers.push(Arc::clone(ledger));
2865            }
2866            return;
2867        }
2868        run.lanes.insert(lane, vec![Arc::clone(ledger)]);
2869        run.baseline = Some(match run.baseline {
2870            None => {
2871                run.from_chain = spend.from_chain;
2872                spend.outside
2873            }
2874            Some(baseline) if spend.recorded || run.from_chain => {
2875                baseline.saturating_sub(spend.prior)
2876            }
2877            Some(baseline) => baseline,
2878        });
2879    }
2880
2881    /// The pool's spend across every lane: the baseline plus each joined
2882    /// lane's committed amount. `None` before any lane joins.
2883    pub(crate) fn pool_spent(&self) -> Option<U256> {
2884        let run = self.spend.lock().unwrap_or_else(PoisonError::into_inner);
2885        let baseline = run.baseline?;
2886        Some(run.lanes.values().fold(baseline, |total, ledgers| {
2887            let lane = ledgers
2888                .iter()
2889                .map(|ledger| ledger.committed().amount)
2890                .max()
2891                .unwrap_or(U256::ZERO);
2892            total.saturating_add(lane)
2893        }))
2894    }
2895
2896    /// The warning for a wallet seen to hold too little USDC for a top-up in
2897    /// this run, if one was.
2898    pub(crate) fn shortfall(&self) -> Option<String> {
2899        self.shortfall
2900            .lock()
2901            .unwrap_or_else(PoisonError::into_inner)
2902            .clone()
2903    }
2904
2905    /// Diagnose a failed top-up leg (`err`) for `pool_id` by reading the
2906    /// wallet's USDC balance. When the wallet holds less than `additional`,
2907    /// which no retry fixes, record the shortfall and return its warning; it
2908    /// is logged at WARN the first time. Otherwise return `None`, after a WARN
2909    /// that names the failure, the balance, or why the balance could not be
2910    /// read.
2911    pub(crate) async fn check_wallet<P>(
2912        &self,
2913        rpc: &P,
2914        token: Address,
2915        owner: Address,
2916        pool_id: PoolId,
2917        additional: U256,
2918        err: &anyhow::Error,
2919    ) -> Option<String>
2920    where
2921        P: alloy::providers::Provider + Clone,
2922    {
2923        let error = decdn_common::redact::sanitize_err_chain(err);
2924        let balance = match decdn_incentive::Erc20::new(token, rpc.clone())
2925            .balanceOf(owner)
2926            .call()
2927            .await
2928        {
2929            Ok(balance) => balance,
2930            Err(read) => {
2931                let read = decdn_common::redact::sanitize_err_chain(&anyhow::Error::new(read));
2932                tracing::warn!(
2933                    %pool_id,
2934                    %additional,
2935                    %error,
2936                    wallet_usdc_error = %read,
2937                    "buyer pool top-up failed, and the wallet's USDC balance could not be read"
2938                );
2939                return None;
2940            }
2941        };
2942        if balance >= additional {
2943            tracing::warn!(
2944                %pool_id,
2945                %additional,
2946                wallet_usdc = %balance,
2947                %error,
2948                "buyer pool top-up failed although the wallet holds enough USDC"
2949            );
2950            return None;
2951        }
2952        let warning = format!(
2953            "wallet {owner} holds {balance} µUSDC, less than the {additional} µUSDC top-up \
2954             buyer pool {pool_id} needs; fund the wallet so the pool can be topped up"
2955        );
2956        let mut shortfall = self
2957            .shortfall
2958            .lock()
2959            .unwrap_or_else(PoisonError::into_inner);
2960        if shortfall.is_none() {
2961            tracing::warn!(
2962                %pool_id,
2963                %additional,
2964                wallet_usdc = %balance,
2965                %error,
2966                "the wallet holds too little USDC to top up the buyer pool"
2967            );
2968            *shortfall = Some(warning.clone());
2969        }
2970        Some(warning)
2971    }
2972}
2973
2974/// The CLI's [`Funder`]: the funding recovery step (ADR 003 § Funding
2975/// recovery). A self-funded owner tops its current pool up toward the working
2976/// deposit through the same `ensure_allowance -> top_up -> add_deposit` path
2977/// as the proactive low-water refill ([`refill_if_low`]). A pool that is
2978/// `Closing` or `Closed` reverts that `topUp` with `PoolNotOpen`; the owner
2979/// then opens a new pool at the working deposit and records it as the current
2980/// pool in the buyer store, and the step reports [`Recovery::Replaced`]. The
2981/// old pool is not reclaimed here.
2982///
2983/// A delegated signer (`--capability`) has no funder this command can ask, so
2984/// its working deposit is zero and every step reports
2985/// [`Recovery::Unavailable`]: the fetch ends "funding needed" with the
2986/// new-signer-key remedy ([`annotate_delegated_exhaustion`]).
2987///
2988/// The acquire loop decides WHETHER to fund (its candidate set is exhausted
2989/// and the progress rule allows a step); this only executes the on-chain move.
2990/// Every failure returns to the loop, which ends the fetch "funding needed",
2991/// or, when the escrow may have moved, fatally.
2992pub(crate) struct CliFunder<'a, P> {
2993    pub(crate) contract: &'a PaymentPool::PaymentPoolInstance<P>,
2994    pub(crate) rpc: &'a P,
2995    pub(crate) store: &'a RedbBuyerPoolStore,
2996    pub(crate) owner: Address,
2997    /// Signs a replacement pool's `openPool` and its self-issued capability.
2998    pub(crate) signer: &'a Arc<PrivateKeySigner>,
2999    /// The chain and `PaymentPool` a replacement pool opens on.
3000    pub(crate) deployment: Deployment,
3001    pub(crate) token: Address,
3002    pub(crate) payment_pool_addr: Address,
3003    /// The deposit a step tops up toward and a replacement pool opens at.
3004    /// Zero for a delegated signer, which has no funding path.
3005    pub(crate) working_deposit: U256,
3006    pub(crate) max_approve: bool,
3007    /// The run's funding facts: its outside spend, a wallet shortfall that
3008    /// makes a further top-up pointless, and the pool to top up
3009    /// ([`RunFunding::pool_id`]).
3010    pub(crate) funding: &'a RunFunding,
3011}
3012
3013impl<P> Funder for CliFunder<'_, P>
3014where
3015    P: alloy::providers::Provider + Clone,
3016{
3017    fn pool_spent(&self) -> Option<U256> {
3018        self.funding.pool_spent()
3019    }
3020
3021    fn recover(&self, remaining: U256) -> SourceFuture<'_, Recovery> {
3022        Box::pin(async move {
3023            // A delegated signer owns no pool it could fund.
3024            if self.working_deposit.is_zero() {
3025                return Ok(Recovery::Unavailable);
3026            }
3027            let pool_id = self
3028                .funding
3029                .pool_id()
3030                .ok_or_else(|| anyhow::anyhow!("no payment lane is built, so no pool to top up"))?;
3031            let additional = self.working_deposit.saturating_sub(remaining);
3032            if additional.is_zero() {
3033                return self.settle_or_replace(pool_id, remaining).await;
3034            }
3035            if let Some(shortfall) = self.funding.shortfall() {
3036                return Err(
3037                    anyhow::anyhow!("the wallet cannot fund a top-up: {shortfall}")
3038                        .context(WalletShortfall),
3039                );
3040            }
3041            // `topUp` pulls `additional` USDC via `transferFrom`, so the
3042            // standing allowance must cover it first: unlimited under
3043            // `--max-approve`, else exactly `additional`.
3044            let escrowed = async {
3045                ensure_allowance(
3046                    self.rpc,
3047                    self.token,
3048                    self.owner,
3049                    self.payment_pool_addr,
3050                    if self.max_approve {
3051                        None
3052                    } else {
3053                        Some(additional)
3054                    },
3055                )
3056                .await?;
3057                top_up(self.contract, self.owner, pool_id, additional).await
3058            }
3059            .await;
3060            // A failure before any receipt names its cause once: a wallet
3061            // short of USDC is recorded for the run's closing warning.
3062            let ToppedUpPool { credited, tx, .. } = match escrowed {
3063                Ok(topped_up) => topped_up,
3064                Err(err) if err.downcast_ref::<TopUpUnconfirmed>().is_some() => return Err(err),
3065                // The gas estimate reverted `PoolNotOpen`: the pool is closing
3066                // or closed, and a pool is never reopened. Open its successor.
3067                Err(err) if err.downcast_ref::<PoolNotOpen>().is_some() => {
3068                    tracing::info!(
3069                        %pool_id,
3070                        "the current buyer pool no longer accepts funds; opening a new pool"
3071                    );
3072                    let opened = self.open_replacement().await?;
3073                    return Ok(Recovery::Replaced(PoolReplaced {
3074                        closed: pool_id,
3075                        opened,
3076                    }));
3077                }
3078                Err(err) => {
3079                    let short = self
3080                        .funding
3081                        .check_wallet(self.rpc, self.token, self.owner, pool_id, additional, &err)
3082                        .await;
3083                    return Err(match short {
3084                        Some(_) => err.context(WalletShortfall),
3085                        None => err,
3086                    });
3087                }
3088            };
3089            // The escrow has moved: a credit that does not land names the tx
3090            // and the pool, and is fatal ([`grade_deposit_credit`]).
3091            let effect = topped_up_effect(pool_id, credited);
3092            let new_deposit = grade_deposit_credit(
3093                self.store.add_deposit(self.owner, pool_id, credited),
3094                &effect,
3095                tx,
3096            )?;
3097            Ok(Recovery::ToppedUp(new_deposit))
3098        })
3099    }
3100}
3101
3102impl<P> CliFunder<'_, P>
3103where
3104    P: alloy::providers::Provider + Clone,
3105{
3106    /// The recovery step for a pool that already holds its working deposit,
3107    /// so a top-up has nothing to add. A `topUp` gas estimate (no transaction)
3108    /// tells a closing or closed pool, which a new pool replaces. An open pool
3109    /// is the nodes' stale view of a deposit they have not yet seen: the step
3110    /// settles, so the gate's settle window holds those nodes' refusals and
3111    /// asks them again ([`Recovery::ToppedUp`] at the current deposit).
3112    ///
3113    /// # Errors
3114    ///
3115    /// The replacement open fails.
3116    async fn settle_or_replace(
3117        &self,
3118        pool_id: PoolId,
3119        remaining: U256,
3120    ) -> anyhow::Result<Recovery> {
3121        if !pool_accepts_funds(self.contract, self.owner, pool_id).await {
3122            tracing::info!(
3123                %pool_id,
3124                "the current buyer pool holds its working deposit but accepts no more funds; \
3125                 opening a new pool"
3126            );
3127            let opened = self.open_replacement().await?;
3128            return Ok(Recovery::Replaced(PoolReplaced {
3129                closed: pool_id,
3130                opened,
3131            }));
3132        }
3133        // The settle step escrows nothing, so an estimate is safe here: it only
3134        // names the deposit the nodes are asked again at.
3135        let estimate = || remaining.saturating_add(self.funding.pool_spent().unwrap_or(U256::ZERO));
3136        let deposit = match self.store.get_by_pool_id(pool_id) {
3137            Ok(Some(row)) => row.deposit,
3138            Ok(None) => estimate(),
3139            Err(error) => {
3140                tracing::warn!(
3141                    %pool_id,
3142                    %error,
3143                    "could not read the buyer pool's row; estimating its deposit from the run"
3144                );
3145                estimate()
3146            }
3147        };
3148        tracing::info!(
3149            %pool_id,
3150            %deposit,
3151            "the buyer pool already holds its working deposit; waiting for the nodes to see it"
3152        );
3153        Ok(Recovery::ToppedUp(deposit))
3154    }
3155
3156    /// Open a new pool at the working deposit and record it as the owner's
3157    /// current pool in the buyer store, where the next lane build reuses it.
3158    /// The closed pool's row stays in the store, so `decdn pool reclaim` and
3159    /// `pool list` still find it.
3160    ///
3161    /// # Errors
3162    ///
3163    /// The allowance or `openPool` fails (nothing escrowed), or the opened
3164    /// pool cannot be recorded (escrowed and untracked: fatal).
3165    async fn open_replacement(&self) -> anyhow::Result<PoolId> {
3166        let deposit = self.working_deposit;
3167        ensure_allowance(
3168            self.rpc,
3169            self.token,
3170            self.owner,
3171            self.payment_pool_addr,
3172            if self.max_approve {
3173                None
3174            } else {
3175                Some(deposit)
3176            },
3177        )
3178        .await?;
3179        let opened = open_pool(
3180            self.contract,
3181            Arc::clone(self.signer),
3182            self.deployment,
3183            self.token,
3184            self.owner,
3185            deposit,
3186        )
3187        .await?;
3188        self.store
3189            .record(&opened.state)
3190            .map_err(|e| escrowed_but_untracked("buyer pool opened", opened.tx, e))?;
3191        tracing::info!(pool_id = %opened.state.pool_id, %deposit, "opened a new buyer pool");
3192        Ok(opened.state.pool_id)
3193    }
3194}
3195
3196/// Whether `--output` names the stdout stream rather than a file. Exactly a
3197/// single dash (`-o -`) streams verified bytes to stdout; a file literally named
3198/// `-` is written by addressing it as `./-`, the usual convention (#1848 4b).
3199fn wants_stdout(output: &Path) -> bool {
3200    output == Path::new("-")
3201}
3202
3203/// Where the [`decdn_client::ClientRangedStore`] for `--output` lives: its directory (the
3204/// output's parent, or the current dir) and its stem (the output's own file
3205/// name). Keying the store by the output name makes its promoted final path IS
3206/// `--output` (no post-finalize rename), and its `.partial` sits beside the
3207/// destination exactly like `<output>.partial`, so promotion is a
3208/// same-filesystem atomic rename.
3209pub(crate) fn ranged_store_location(output: &Path) -> anyhow::Result<(PathBuf, String)> {
3210    let dir = match output.parent() {
3211        Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
3212        _ => PathBuf::from("."),
3213    };
3214    let stem = output
3215        .file_name()
3216        .and_then(|n| n.to_str())
3217        .ok_or_else(|| anyhow::anyhow!("--output {} has no usable file name", output.display()))?
3218        .to_string();
3219    Ok((dir, stem))
3220}
3221
3222/// Time constant for the smoothed delivery rate (seconds). Larger holds the
3223/// readout steadier across bursty arrival; smaller tracks real speed changes
3224/// faster. A few seconds keeps the number legible without lagging a genuine
3225/// slowdown for long.
3226const RATE_SMOOTHING_TAU_SECS: f64 = 3.0;
3227
3228/// Rolling delivery-rate estimate behind a progress bar's `{msg}`, and the
3229/// running totals the end-of-fetch summary reads back. Single-blob `fetch` feeds
3230/// it from the delivery bar; `bundle pull` feeds it from its bottom total bar, so
3231/// the rate there is whole-download throughput rather than one file's.
3232///
3233/// The bar's positions are cumulative verified **content** bytes — what the
3234/// [`decdn_client::ProgressCallback`] reports — so the rate is content
3235/// throughput (the interleaved bao proof nodes the wire also carries are not
3236/// counted).
3237///
3238/// Each `set_position` on the bar is bursty — many chunks land in one instant,
3239/// then a gap — so a naive `delta / dt` per callback spikes and collapses. This
3240/// holds a time-weighted exponential moving average instead: each sample folds
3241/// in with weight `1 - exp(-dt / tau)`, so the estimate is stable regardless of
3242/// how unevenly callbacks are spaced.
3243#[derive(Default)]
3244pub(crate) struct SpeedState {
3245    /// Instant and cumulative-byte position at the first observed sample. The
3246    /// summary measures elapsed and bytes-moved from here, so a resumed fetch
3247    /// (which starts at a non-zero `base_present`) reports only what this run
3248    /// actually transferred rather than dividing already-present bytes by this
3249    /// run's short window.
3250    started: Option<(Instant, u64)>,
3251    /// Instant and cumulative received content bytes at the previous sample.
3252    last: Option<(Instant, u64)>,
3253    /// Smoothed rate in bytes/sec. `None` until the second sample gives a `dt`.
3254    ewma_bps: Option<f64>,
3255}
3256
3257impl SpeedState {
3258    /// Fold one sample — cumulative content bytes `received` as of `now` — into
3259    /// the estimate and return the current smoothed rate in bytes/sec (`0.0`
3260    /// until a second sample gives a `dt`).
3261    pub(crate) fn observe(&mut self, now: Instant, received: u64) -> f64 {
3262        self.started.get_or_insert((now, received));
3263        if let Some((prev_at, prev_bytes)) = self.last {
3264            let dt = now.saturating_duration_since(prev_at).as_secs_f64();
3265            // Skip same-instant callbacks (a burst): they carry no usable `dt`
3266            // and would divide by ~zero into a spike.
3267            if dt > 0.0 {
3268                let inst = bytes_as_f64(received.saturating_sub(prev_bytes)) / dt;
3269                let alpha = 1.0 - (-dt / RATE_SMOOTHING_TAU_SECS).exp();
3270                // Seed from 0, not `inst`: on the first sample a tiny `dt` makes
3271                // `inst` huge, but `alpha * inst = (1 - exp(-dt/tau)) * (delta/dt)
3272                // -> delta/tau` as `dt -> 0`, so the estimate stays bounded
3273                // instead of spiking, then converges upward.
3274                let prev_bps = self.ewma_bps.unwrap_or(0.0);
3275                self.ewma_bps = Some(prev_bps + alpha * (inst - prev_bps));
3276            }
3277        }
3278        self.last = Some((now, received));
3279        self.ewma_bps.unwrap_or(0.0)
3280    }
3281}
3282
3283/// Widen a byte count to `f64` for rate arithmetic. A single transfer never
3284/// approaches 2^53 bytes, so the precision the cast lint guards against is not
3285/// at risk here.
3286#[expect(
3287    clippy::cast_precision_loss,
3288    reason = "byte counts stay far below f64's 2^53 exact-integer ceiling"
3289)]
3290pub(crate) const fn bytes_as_f64(n: u64) -> f64 {
3291    n as f64
3292}
3293
3294/// Format a non-negative bytes/sec rate as e.g. `12.3 MiB/s`. A rate at or
3295/// below zero (no data yet, or a stall) renders as `--`.
3296pub(crate) fn fmt_rate(bps: f64) -> String {
3297    if bps.is_finite() && bps >= 1.0 {
3298        // The rate is a small non-negative value; clamp before the cast so the
3299        // `HumanBytes` argument can never wrap or lose sign.
3300        #[expect(
3301            clippy::cast_possible_truncation,
3302            clippy::cast_sign_loss,
3303            reason = "bps is finite and >= 1.0 here; the cast cannot wrap or go negative"
3304        )]
3305        let whole = bps.min(bytes_as_f64(u64::MAX)) as u64;
3306        format!("{}/s", indicatif::HumanBytes(whole))
3307    } else {
3308        "--".to_string()
3309    }
3310}
3311
3312/// Estimate remaining time from the smoothed rate, formatted like `ETA 8s`.
3313/// Below a usable rate it reports `ETA --` rather than a divide-by-tiny blowup.
3314pub(crate) fn fmt_eta(remaining_bytes: u64, bps: f64) -> String {
3315    if bps >= 1.0 {
3316        // Clamp the projection so `from_secs_f64` never overflows `Duration`.
3317        let secs = (bytes_as_f64(remaining_bytes) / bps).clamp(0.0, 8.64e7);
3318        format!(
3319            "ETA {}",
3320            indicatif::HumanDuration(Duration::from_secs_f64(secs))
3321        )
3322    } else {
3323        "ETA --".to_string()
3324    }
3325}
3326
3327/// Reads the transfer duration and bytes moved this run back from a
3328/// [`SpeedState`] after the bar finishes, for the end-of-fetch summary.
3329#[derive(Clone)]
3330pub(crate) struct DeliveryMeter {
3331    state: Arc<Mutex<SpeedState>>,
3332}
3333
3334impl DeliveryMeter {
3335    /// `(elapsed across this run, content bytes this run transferred)`, or
3336    /// `None` if no byte ever arrived (a failure before delivery) or the lock is
3337    /// poisoned. Both are measured from the first observed sample, so a resumed
3338    /// fetch excludes the already-present `base_present` bytes it did not move.
3339    fn summary(&self) -> Option<(Duration, u64)> {
3340        let state = self.state.lock().ok()?;
3341        let (start_at, start_bytes) = state.started?;
3342        let (last_at, last_bytes) = state.last?;
3343        Some((
3344            last_at.saturating_duration_since(start_at),
3345            last_bytes.saturating_sub(start_bytes),
3346        ))
3347    }
3348}
3349
3350/// The `decdn fetch` delivery progress bar, the callback that drives it, and a
3351/// [`DeliveryMeter`] the caller reads after `finish_and_clear` for the terminal
3352/// summary (#1118). The callback advances the bar, folds each update into the
3353/// shared [`SpeedState`] so `{msg}` shows a steady rate/ETA, and mirrors the
3354/// percent into the terminal's tab bar ([`TabProgress`]) where supported; the
3355/// tab indicator is removed when the callback drops.
3356fn delivery_progress() -> (
3357    indicatif::ProgressBar,
3358    impl Fn(u64, u64) + 'static,
3359    DeliveryMeter,
3360) {
3361    let bar = new_progress_bar();
3362    // `ProgressBar` is `Arc`-backed, so the clone the callback owns drives the
3363    // same bar the caller clears.
3364    let (on_progress, meter) = bar_callback(bar.clone(), TabProgress::detect(true));
3365    (bar, on_progress, meter)
3366}
3367
3368/// Build the callback that drives `bar` (it sets the bar's length to the
3369/// fetch's current size bound whenever it changes, advances its position to
3370/// the cumulative verified content-byte count, and folds each update into a
3371/// [`SpeedState`] for the rate/ETA `{msg}`), and return it with the
3372/// [`DeliveryMeter`] the caller reads after the bar finishes. `tab`, when set,
3373/// shows the same percent in the terminal's tab bar.
3374///
3375/// The callback's `received`/`expected` are content bytes, per
3376/// [`decdn_client::ProgressCallback`]. `expected` is the size bound: a size
3377/// claim the fetch grows or shrinks until a leg proves the size. Both the bar
3378/// length and its position are in the same unit.
3379fn bar_callback(
3380    bar: indicatif::ProgressBar,
3381    tab: Option<Arc<TabProgress>>,
3382) -> (impl Fn(u64, u64) + 'static, DeliveryMeter) {
3383    // Setting the length takes a write lock, so it is set only when the bound
3384    // moves, not on every chunk in the hot receive loop. `u64::MAX` is never a
3385    // bound, so the first report always sets it.
3386    let length = std::sync::atomic::AtomicU64::new(u64::MAX);
3387    let state = Arc::new(Mutex::new(SpeedState::default()));
3388    let cb_state = Arc::clone(&state);
3389    let on_progress = move |received: u64, expected: u64| {
3390        if length.swap(expected, std::sync::atomic::Ordering::Relaxed) != expected {
3391            bar.set_length(expected);
3392        }
3393        bar.set_position(received);
3394        if let Some(tab) = &tab {
3395            tab.update(received, expected);
3396        }
3397
3398        // A poisoned lock only costs this one rate update; the bar still advances.
3399        if let Ok(mut s) = cb_state.lock() {
3400            let bps = s.observe(Instant::now(), received);
3401            bar.set_message(format!(
3402                "({}, {}) ",
3403                fmt_rate(bps),
3404                fmt_eta(expected.saturating_sub(received), bps)
3405            ));
3406        }
3407    };
3408    (on_progress, DeliveryMeter { state })
3409}
3410
3411/// Print the terminal line after a successful fetch: content bytes, and — when
3412/// the [`DeliveryMeter`] captured any delivery — the elapsed time and average
3413/// transfer rate over the content bytes this run moved. Falls back to the bare
3414/// byte/output line when nothing was delivered on this leg (e.g. a fully
3415/// resumed transfer that re-pulled no bytes).
3416fn print_fetch_summary(content_bytes: u64, meter: &DeliveryMeter, output: &Path) {
3417    match meter.summary() {
3418        Some((elapsed, moved_bytes)) if elapsed > Duration::ZERO && moved_bytes > 0 => {
3419            let avg_bps = bytes_as_f64(moved_bytes) / elapsed.as_secs_f64();
3420            println!(
3421                "fetched {content_bytes} bytes in {} (avg {}) -> {}",
3422                indicatif::HumanDuration(elapsed),
3423                fmt_rate(avg_bps),
3424                output.display()
3425            );
3426        }
3427        _ => println!("fetched {content_bytes} bytes -> {}", output.display()),
3428    }
3429}
3430
3431/// Resolve the delegated `--capability`/`--capability-file` token into a
3432/// [`CapabilityGrant`], and, when one is present, zero the working deposit
3433/// in `chain`: a delegate owns no pool it could `topUp`, so its funding
3434/// recovery step has nothing to add ([`CliFunder`]). `None` selects the
3435/// unchanged self-owned pool path. Shared by `decdn fetch` and `bundle pull`.
3436///
3437/// # Errors
3438///
3439/// When `--capability-file` cannot be read, or the token fails to decode.
3440pub(crate) fn resolve_delegation_grant(
3441    common: &cli::ClientFetchArgs,
3442    chain: &mut ResolvedChain,
3443) -> anyhow::Result<Option<CapabilityGrant>> {
3444    let grant = common
3445        .resolve_capability_token()?
3446        .map(|token| CapabilityGrant::from_token(&token))
3447        .transpose()
3448        .map_err(|e| anyhow::anyhow!("invalid --capability token: {e}"))?;
3449    if grant.is_some() {
3450        chain.working_deposit = U256::ZERO;
3451    }
3452    Ok(grant)
3453}
3454
3455/// Select the pool context for one fetch: the delegated adoption path when a
3456/// `--capability` [`CapabilityGrant`] is present (adopt the named pool, present
3457/// the owner's capability, no open), else the self-owned open-or-reuse path.
3458/// Extracted so `fetch()` stays one readable pass over its stages.
3459#[allow(clippy::too_many_arguments)]
3460async fn build_ctx_for_fetch<P>(
3461    grant: Option<&CapabilityGrant>,
3462    store: &RedbBuyerPoolStore,
3463    contract: &PaymentPool::PaymentPoolInstance<P>,
3464    rpc: &P,
3465    signer: &Arc<PrivateKeySigner>,
3466    voucher_dom: &Eip712Domain,
3467    provider: Address,
3468    self_address: Address,
3469    chain: &ResolvedChain,
3470    endpoint: &Endpoint,
3471    funding: &RunFunding,
3472) -> anyhow::Result<(PoolContext, Option<LaneSpend>)>
3473where
3474    P: alloy::providers::Provider + Clone,
3475{
3476    if let Some(grant) = grant {
3477        build_delegated_pool_ctx(
3478            store,
3479            contract,
3480            signer,
3481            voucher_dom,
3482            provider,
3483            self_address,
3484            chain,
3485            endpoint,
3486            grant,
3487        )
3488        .await
3489        .map(|ctx| (ctx, None))
3490    } else {
3491        build_pool_ctx(
3492            store,
3493            contract,
3494            rpc,
3495            signer,
3496            provider,
3497            self_address,
3498            chain,
3499            endpoint,
3500            funding,
3501        )
3502        .await
3503        .map(|(ctx, spend)| (ctx, Some(spend)))
3504    }
3505}
3506
3507/// [`open_or_reuse_pool`] plus the ADR 005 client identity binding (#1115):
3508/// sign our OWN iroh `NodeId` with the buyer key so the serving node can prove
3509/// we own the pool and reactively pull a cache-missed blob from its configured
3510/// origin.
3511///
3512/// The bind domain's verifying contract is the `CapacityBond`; without one
3513/// configured we can't sign, so the request goes out unbound and the node serves
3514/// only content it already holds (a cache miss is refused). No warning is
3515/// emitted for that — it would fire on every successful cached fetch too,
3516/// training users to ignore it; the refusal is explained at the point of failure
3517/// by [`annotate_unbound_cache_miss`].
3518#[allow(clippy::too_many_arguments)]
3519async fn build_pool_ctx<P>(
3520    store: &RedbBuyerPoolStore,
3521    contract: &PaymentPool::PaymentPoolInstance<P>,
3522    rpc: &P,
3523    signer: &Arc<PrivateKeySigner>,
3524    provider: Address,
3525    self_address: Address,
3526    chain: &ResolvedChain,
3527    endpoint: &Endpoint,
3528    funding: &RunFunding,
3529) -> anyhow::Result<(PoolContext, LaneSpend)>
3530where
3531    P: alloy::providers::Provider + Clone,
3532{
3533    let (ctx, spend) = open_or_reuse_pool(
3534        store,
3535        contract,
3536        rpc,
3537        funding,
3538        signer,
3539        provider,
3540        self_address,
3541        chain.deployment(),
3542        chain.working_deposit,
3543        chain.max_approve,
3544        // The store this fetch writes was opened from `chain.data_dir`, and it
3545        // signs with `chain.keystore`; the verdict covers both.
3546        ChainAdoption::for_buy(&chain.data_dir, &chain.keystore)?,
3547    )
3548    .await?;
3549    Ok((attach_client_binding(ctx, chain, endpoint, signer)?, spend))
3550}
3551
3552/// Reject a delegated fetch whose loaded key is not the signer the capability
3553/// authorizes. The client can only sign vouchers as `self_address`, and a
3554/// capability scoped to a different signer would have every voucher rejected as
3555/// `WrongSigner` — so fail fast, before any network or on-chain work.
3556fn ensure_delegate_signer(self_address: Address, authorized: Address) -> anyhow::Result<()> {
3557    anyhow::ensure!(
3558        self_address == authorized,
3559        "this capability authorizes signer {authorized}, but the loaded key is {self_address} — \
3560         load the delegate keystore the pool owner assigned (--keystore / \
3561         blockchain.eth_keystore), or ask for a capability issued to {self_address}",
3562    );
3563    Ok(())
3564}
3565
3566/// Build a [`PoolContext`] that adopts a pool the caller does NOT own, from a
3567/// delegated [`CapabilityGrant`] (`decdn pool assign` → `--capability`).
3568///
3569/// Unlike [`build_pool_ctx`] this opens nothing on-chain and signs no
3570/// self-capability: it presents the OWNER's capability (rebuilt from the token)
3571/// so the serving node registers this client's signer on its first redemption.
3572/// It hard-fails when the loaded keystore is not the delegate the capability
3573/// authorizes — the client cannot sign vouchers under a capability scoped to a
3574/// different signer, and a node would reject them as `WrongSigner`.
3575///
3576/// The pool's on-chain row is read once for the informational `deposit` and to
3577/// confirm the pool exists (a zero-owner row means it was never opened on this
3578/// contract). The lane resumes from any locally-tracked watermark for
3579/// `(pool, this signer, provider)`; a delegate that has never streamed this lane
3580/// starts at zero. The ADR 005 client binding is attached the same as the
3581/// self-owned path — it only authorizes reactive origin pull-through when the
3582/// binding owner also owns the pool, which a delegate does not, so a cache miss
3583/// still refuses (already-cached content serves and is paid via the capability).
3584#[allow(clippy::too_many_arguments)]
3585pub(crate) async fn build_delegated_pool_ctx<P>(
3586    store: &RedbBuyerPoolStore,
3587    contract: &PaymentPool::PaymentPoolInstance<P>,
3588    signer: &Arc<PrivateKeySigner>,
3589    voucher_dom: &Eip712Domain,
3590    provider: Address,
3591    self_address: Address,
3592    chain: &ResolvedChain,
3593    endpoint: &Endpoint,
3594    grant: &CapabilityGrant,
3595) -> anyhow::Result<PoolContext>
3596where
3597    P: alloy::providers::Provider + Clone,
3598{
3599    ensure_delegate_signer(self_address, grant.signer)?;
3600
3601    let signed_capability = grant.to_signed_capability().map_err(|e| {
3602        anyhow::anyhow!("capability token carries a malformed owner signature: {e}")
3603    })?;
3604
3605    let pool_id = grant.pool_id;
3606    let pool = contract.getPool(pool_id).call().await.map_err(|e| {
3607        anyhow::Error::new(e).context(format!("read on-chain pool {pool_id} for the capability"))
3608    })?;
3609    anyhow::ensure!(
3610        pool.owner != Address::ZERO,
3611        "pool {pool_id} named by the capability does not exist on this PaymentPool contract \
3612         ({}) — wrong --payment-pool-address/chain, or a stale token",
3613        chain.payment_pool,
3614    );
3615
3616    // Resume this delegate's lane watermark if a local record exists; a delegate
3617    // that has never streamed this lane starts at zero (a fresh lane).
3618    let lane = LaneKey {
3619        pool_id,
3620        signer: self_address,
3621        provider,
3622    };
3623    let (prior_bytes, prior_amount) = store
3624        .get_by_pool_id(pool_id)?
3625        .and_then(|state| state.lane_progress(lane))
3626        .map_or((U256::ZERO, U256::ZERO), |p| (p.last_bytes, p.last_amount));
3627
3628    // A transient state carrying the informational pool facts the context reads
3629    // (`pool_id`, `deposit`); it is not persisted (the delegate owns no pool row).
3630    // The pool does not store its token — it is the contract's immutable
3631    // `usdc()`, so read it there rather than duplicating it per pool.
3632    let token = contract
3633        .usdc()
3634        .call()
3635        .await
3636        .map_err(|e| anyhow::Error::new(e).context("read PaymentPool.usdc()"))?;
3637    let state = BuyerPoolState::new(
3638        pool_id,
3639        chain.deployment(),
3640        pool.owner,
3641        token,
3642        U256::from(pool.deposit),
3643    );
3644    let ctx = PoolContext::for_pool(&state, Arc::clone(signer), voucher_dom.clone())
3645        .with_provider(provider, prior_bytes, prior_amount)
3646        .with_capability(signed_capability);
3647    attach_client_binding(ctx, chain, endpoint, signer)
3648}
3649
3650/// Attach the ADR 005 client identity binding to an already-built
3651/// [`PoolContext`] (#1115): sign our OWN iroh `NodeId` with the buyer key so
3652/// the serving node can prove we own the pool and reactively pull a
3653/// cache-missed blob from its configured origin. Separate from
3654/// [`build_pool_ctx`] so `decdn bundle pull` — which takes the `open_lock`
3655/// itself around [`open_or_reuse_pool`] — can attach the same binding outside
3656/// that critical section.
3657///
3658/// No binding when `chain.capacity_bond` is unset — see [`build_pool_ctx`]'s
3659/// docs for why that is silent.
3660pub(crate) fn attach_client_binding(
3661    ctx: PoolContext,
3662    chain: &ResolvedChain,
3663    endpoint: &Endpoint,
3664    signer: &Arc<PrivateKeySigner>,
3665) -> anyhow::Result<PoolContext> {
3666    let Some(capacity_bond) = chain.capacity_bond else {
3667        return Ok(ctx);
3668    };
3669    let bind_dom = bind_node_id_domain(chain.chain_id, capacity_bond);
3670    let own_node_id = B256::from(*endpoint.id().as_bytes());
3671    Ok(ctx.with_client_binding(sign_client_binding(signer, own_node_id, &bind_dom)?))
3672}
3673
3674/// Adopt the live pool `owner` already holds on `deployment`, recording it in
3675/// the client store, or return `None` if the chain lists no `Open` pool with
3676/// deposit left to spend. The adopted row carries the pool's `totalRedeemed`
3677/// as [`BuyerPoolState::redeemed_elsewhere`].
3678///
3679/// The node adopts by the same rule at bootstrap (the newest `Open`, solvent
3680/// pool); the client does it on demand, because a client is a one-shot process
3681/// with no bootstrap to hang it on. The two differ on failure, deliberately. The
3682/// node fails open: an unreadable enumeration leaves the first miss to open a
3683/// fresh pool, because a daemon that could not buy for the rest of its life is
3684/// worse than an occasional stranded deposit. The client fails closed: a read
3685/// that faults errors rather than answering `None`, because a one-shot fetch can
3686/// simply be rerun, and "could not tell" must not become "go open another pool".
3687/// Making the two consistent would mean weakening one of them.
3688async fn adopt_owned_pool<P>(
3689    store: &RedbBuyerPoolStore,
3690    contract: &PaymentPool::PaymentPoolInstance<P>,
3691    owner: Address,
3692    deployment: Deployment,
3693) -> anyhow::Result<Option<BuyerPoolState>>
3694where
3695    P: alloy::providers::Provider + Clone,
3696{
3697    let Some((pool_id, pool)) = newest_solvent_owned_pool(contract, owner).await.context(
3698        "could not ask the chain whether this wallet already owns a pool, so refusing to \
3699             open one blind — rerun, or check the RPC endpoint",
3700    )?
3701    else {
3702        return Ok(None);
3703    };
3704    let token =
3705        contract.usdc().call().await.with_context(|| {
3706            format!("read PaymentPool.usdc() while adopting live pool {pool_id}")
3707        })?;
3708    // The row has no lanes to account for what the pool already paid out, so it
3709    // keeps `totalRedeemed`, less what each lane it seeds takes over.
3710    let state = BuyerPoolState::adopt(
3711        pool_id,
3712        deployment,
3713        owner,
3714        token,
3715        U256::from(pool.deposit),
3716        U256::from(pool.totalRedeemed),
3717    );
3718    store.record(&state).with_context(|| {
3719        format!(
3720            "found live pool {pool_id} on chain but could not record it in the local buyer \
3721             store; nothing was escrowed, so rerunning retries the adoption"
3722        )
3723    })?;
3724    tracing::info!(
3725        %pool_id,
3726        deposit = pool.deposit,
3727        redeemed = pool.totalRedeemed,
3728        "adopted a live buyer pool this wallet already owns on chain, instead of opening a \
3729         second one"
3730    );
3731    Ok(Some(state))
3732}
3733
3734/// The on-chain `(bytes, amount)` watermark for `lane` — what a lane with no
3735/// local record resumes from.
3736///
3737/// It reflects redeemed vouchers only, so a provider still holding one it has
3738/// not redeemed is ahead of it. That gap repairs itself inside the fetch: the
3739/// provider answers the first stale proof that its lane headroom cannot pay
3740/// with that watermark (the wallet-less resume, #1946), and the driver reseeds
3741/// from it — unless the resume budget is spent, where the fetch fails. Resuming below the chain
3742/// watermark has no such repair: every voucher at or below it redeems nothing
3743/// on chain, whatever the provider does.
3744async fn lane_watermark<P>(
3745    contract: &PaymentPool::PaymentPoolInstance<P>,
3746    lane: LaneKey,
3747) -> anyhow::Result<(U256, U256)>
3748where
3749    P: alloy::providers::Provider + Clone,
3750{
3751    let onchain = contract
3752        .getWatermark(lane.pool_id, lane.signer, lane.provider)
3753        .call()
3754        .await
3755        .context(
3756            "could not read this lane's on-chain watermark, so refusing to resume it from zero, \
3757             where it would stream bytes the provider cannot cash",
3758        )?;
3759    Ok((
3760        U256::from(onchain.bytesDelivered),
3761        U256::from(onchain.amount),
3762    ))
3763}
3764
3765/// The pool this buy reuses, if any, and what that pool has already paid out
3766/// beyond what its row's lanes account for.
3767///
3768/// A tracked row is reused only if it is on `deployment` — the same chain and
3769/// the same `PaymentPool` address. A row from another deployment is not this
3770/// contract's pool, whatever its id says: `pool_id` is
3771/// `keccak256(owner, ownerPoolNonce)` and a redeploy restarts that nonce, so the
3772/// id alone will eventually name an existing, unrelated pool here, and its lane
3773/// progress would seed the first voucher at a cumulative this pool has never
3774/// redeemed against. It is treated as no row.
3775///
3776/// A tracked row is reused only while its pool is open. Only the owner closes a
3777/// pool, but the owner key can close it from another machine, and anyone can
3778/// reclaim it once its dispute window ends. Nodes answer a request on a closed
3779/// pool `NotFound`, the same as a missing blob, so a fetch on it never reaches
3780/// a funding recovery step. So the first lane build of a run reads the pool
3781/// once ([`still_open`]), and a pool the chain shows `Closing` or `Closed` is
3782/// treated as no row: the run adopts or opens another.
3783///
3784/// No row is not the same as no pool. The store loses rows — a reset data dir, a
3785/// new machine, a store-format change — while the pool they named is still open
3786/// on chain with deposit in it. Where `adoption` allows, the chain is asked
3787/// before anything is opened, so a lost row adopts the live pool instead of
3788/// escrowing a second deposit beside it, which reverts when the wallet's
3789/// remaining USDC cannot cover it.
3790///
3791/// An adopted row carries the pool's `totalRedeemed` as
3792/// [`BuyerPoolState::redeemed_elsewhere`], and keeps the part no seeded lane
3793/// has taken over on every later run.
3794/// The row has no lanes to account for what the pool paid out before
3795/// adoption, and without it the refill decision reads a pool other lanes
3796/// drained as a full deposit: no top-up fires, and the provider refuses the
3797/// fetch on a balance the client believes it has.
3798async fn pool_to_reuse<P>(
3799    store: &RedbBuyerPoolStore,
3800    contract: &PaymentPool::PaymentPoolInstance<P>,
3801    funding: &RunFunding,
3802    self_address: Address,
3803    deployment: Deployment,
3804    adoption: ChainAdoption,
3805) -> anyhow::Result<Option<BuyerPoolState>>
3806where
3807    P: alloy::providers::Provider + Clone,
3808{
3809    let tracked = match store.get_by_owner(self_address)? {
3810        Some(state) if state.is_on(deployment) => Some(state),
3811        Some(state) => {
3812            tracing::warn!(
3813                pool_id = %state.pool_id,
3814                foreign_payment_pool = %state.deployment.payment_pool,
3815                foreign_chain_id = state.deployment.chain_id,
3816                configured_payment_pool = %deployment.payment_pool,
3817                configured_chain_id = deployment.chain_id,
3818                "ignoring a tracked buyer pool from another PaymentPool deployment; its deposit, \
3819                 if any, is recoverable only against `foreign_payment_pool`"
3820            );
3821            None
3822        }
3823        None => None,
3824    };
3825    // A lane of this run already pays from the pool, so its first build read it.
3826    let tracked = match tracked {
3827        Some(state) if funding.pool_id() != Some(state.pool_id) => {
3828            let kept = still_open(contract, self_address, state).await;
3829            if let Some(state) = &kept {
3830                funding.note_pool(state.pool_id);
3831            }
3832            kept
3833        }
3834        other => other,
3835    };
3836    Ok(match (tracked, adoption) {
3837        (Some(state), _) => Some(state),
3838        (None, ChainAdoption::Refused) => None,
3839        (None, ChainAdoption::Allowed) => {
3840            adopt_owned_pool(store, contract, self_address, deployment).await?
3841        }
3842    })
3843}
3844
3845/// `state` while the chain shows its pool open, or `None` once the chain shows
3846/// this owner's pool `Closing` or `Closed`. A failed read, or a pool the chain
3847/// holds no record of yet, keeps `state`: a lagging RPC can miss a pool opened
3848/// moments ago, and replacing it on that read would escrow a second deposit.
3849async fn still_open<P>(
3850    contract: &PaymentPool::PaymentPoolInstance<P>,
3851    self_address: Address,
3852    state: BuyerPoolState,
3853) -> Option<BuyerPoolState>
3854where
3855    P: alloy::providers::Provider + Clone,
3856{
3857    match contract.getPool(state.pool_id).call().await {
3858        Ok(pool)
3859            if pool.owner == self_address && !matches!(pool.status, PaymentPool::Status::Open) =>
3860        {
3861            tracing::info!(
3862                pool_id = %state.pool_id,
3863                status = super::pool::status_label(pool.status),
3864                "the tracked buyer pool no longer accepts work; a new pool replaces it"
3865            );
3866            None
3867        }
3868        Ok(_) => Some(state),
3869        Err(error) => {
3870            tracing::warn!(
3871                pool_id = %state.pool_id,
3872                error = %error,
3873                "could not read the tracked buyer pool's status; reusing it"
3874            );
3875            Some(state)
3876        }
3877    }
3878}
3879
3880/// Record `lane`'s on-chain watermark `(bytes, amount)` in the row before the
3881/// lane resumes from it ([`BuyerPoolState::seed_lane`]), and return the row
3882/// with it and the progress the lane resumes from.
3883///
3884/// The pool spend the refill and the run's funding read
3885/// ([`BuyerPoolState::pool_spend`]) then counts the watermark once. On an
3886/// adopted row it moves out of the redeemed spend no lane accounts for, so the
3887/// spend keeps counting the other lanes' redemptions as this lane advances. A
3888/// zero watermark has nothing to record. A row another run already moved past
3889/// the watermark wins: the lane resumes from the row's progress.
3890///
3891/// # Errors
3892///
3893/// The buyer store could not commit the seed, or the row is gone or names
3894/// another pool now (another run forgot or replaced it). Either way the build
3895/// fails and retries from a fresh read: going on would price the lane, or
3896/// escrow a refill, against a row the store no longer holds.
3897fn seed_lane(
3898    store: &RedbBuyerPoolStore,
3899    mut state: BuyerPoolState,
3900    lane: LaneKey,
3901    bytes: U256,
3902    amount: U256,
3903) -> anyhow::Result<(BuyerPoolState, U256, U256)> {
3904    if bytes.is_zero() && amount.is_zero() {
3905        return Ok((state, bytes, amount));
3906    }
3907    let pool_id = state.pool_id;
3908    let outcome = store
3909        .seed_progress(state.owner, pool_id, lane, bytes, amount)
3910        .with_context(|| {
3911            format!(
3912                "record the on-chain watermark of lane {} in pool {pool_id}",
3913                lane.provider
3914            )
3915        })?;
3916    match outcome {
3917        AdvanceOutcome::Advanced => {
3918            // The in-memory row has no record of the lane, so the seed takes.
3919            if let Err(err) = state.seed_lane(lane, bytes, amount) {
3920                tracing::debug!(error = %err, "lane seed did not advance the in-memory row");
3921            }
3922            Ok((state, bytes, amount))
3923        }
3924        AdvanceOutcome::Regressed(_) => {
3925            let row = store.get_by_pool_id(pool_id)?.ok_or_else(|| {
3926                anyhow::anyhow!("buyer row for pool {pool_id} vanished during a lane seed")
3927            })?;
3928            let progress = row.lane_progress(lane).ok_or_else(|| {
3929                anyhow::anyhow!(
3930                    "buyer row for pool {pool_id} lost lane {} during a seed",
3931                    lane.provider
3932                )
3933            })?;
3934            Ok((row, progress.last_bytes, progress.last_amount))
3935        }
3936        AdvanceOutcome::UnknownPool | AdvanceOutcome::PoolMismatch => Err(anyhow::anyhow!(
3937            "the buyer row for pool {pool_id} was forgotten or replaced while lane {} was built; \
3938             the build retries from the store",
3939            lane.provider
3940        )),
3941    }
3942}
3943
3944/// The error for a pool with no unspent deposit whose wallet cannot fund a
3945/// top-up: [`NoAffordableSource`], which is fatal to the command even inside a
3946/// lane build. Nothing a retry or another provider does can pay for the fetch.
3947fn cannot_pay(state: &BuyerPoolState, shortfall: &str) -> anyhow::Error {
3948    anyhow::Error::new(NoAffordableSource {
3949        deposit: state.deposit,
3950    })
3951    .context(format!(
3952        "buyer pool {} has no unspent deposit, and the wallet cannot fund a top-up: \
3953         {shortfall}",
3954        state.pool_id
3955    ))
3956}
3957
3958/// Restore `state`'s remaining deposit (`deposit - spent`) to `working_deposit`
3959/// once it falls below the low water — see [`refill_amount`] — and return the
3960/// row to build the lane on.
3961///
3962/// `spent` is the pool-wide spend from [`BuyerPoolState::pool_spend`]. The refill is an
3963/// optimisation, so a wallet that holds too little USDC for it does not fail
3964/// the lane while the pool can still pay (and ends the command, as
3965/// [`NoAffordableSource`], once it cannot): the shortfall is logged at WARN,
3966/// recorded on `funding` for the run's closing `warning:` line, and no later
3967/// lane build of the run tries the refill again. Any other failure of the
3968/// allowance or `topUp` leg fails the lane build, which the acquire loop
3969/// retries with backoff. A `topUp` that may have been broadcast but returned no
3970/// receipt ([`TopUpUnconfirmed`]) may have escrowed, and once `topUp` returns a
3971/// receipt the escrow has moved, so a failure to credit or re-read the local
3972/// row ([`escrowed_but_untracked`]) is fatal: the acquire loop ends the
3973/// command instead of retrying, because a retry escrows again.
3974#[allow(clippy::too_many_arguments)]
3975async fn refill_if_low<P>(
3976    store: &RedbBuyerPoolStore,
3977    contract: &PaymentPool::PaymentPoolInstance<P>,
3978    rpc: &P,
3979    funding: &RunFunding,
3980    state: BuyerPoolState,
3981    self_address: Address,
3982    spent: U256,
3983    working_deposit: U256,
3984    max_approve: bool,
3985) -> anyhow::Result<BuyerPoolState>
3986where
3987    P: alloy::providers::Provider + Clone,
3988{
3989    let low_water = working_deposit / U256::from(LOW_WATER_DIVISOR);
3990    let additional = refill_amount(state.deposit, spent, working_deposit, low_water);
3991    if additional.is_zero() {
3992        return Ok(state);
3993    }
3994    let remaining = state.deposit.saturating_sub(spent);
3995    if let Some(shortfall) = funding.shortfall() {
3996        // The wallet could not fund a top-up earlier in this run; asking again
3997        // costs an allowance read and a reverting `topUp` estimate per lane.
3998        if remaining.is_zero() {
3999            return Err(cannot_pay(&state, &shortfall));
4000        }
4001        return Ok(state);
4002    }
4003    tracing::info!(
4004        "buyer pool {} low on deposit ({remaining} µUSDC remaining of {} deposited); \
4005         topping up {additional} µUSDC",
4006        state.pool_id,
4007        state.deposit,
4008    );
4009    // `topUp` pulls `additional` USDC via `transferFrom`, so the pool's
4010    // standing allowance must cover it first. Ensure it in the caller's
4011    // mode: unlimited under `--max-approve`, else exactly `additional`.
4012    let escrowed = async {
4013        ensure_allowance(
4014            rpc,
4015            state.token,
4016            self_address,
4017            *contract.address(),
4018            if max_approve { None } else { Some(additional) },
4019        )
4020        .await?;
4021        top_up(contract, self_address, state.pool_id, additional).await
4022    }
4023    .await;
4024    let err = match escrowed {
4025        Ok(ToppedUpPool { credited, tx, .. }) => {
4026            // The USDC is escrowed the moment `topUp` mines. A local credit that
4027            // does not land leaves the deposit untracked, and continuing would
4028            // fetch on a `state.deposit` that understates the chain — so the
4029            // low-water check re-fires on every later fetch while nobody
4030            // reconciles the escrow. No bytes have been paid for on *this* entry
4031            // yet — `bundle pull` reaches this once per entry, through
4032            // `open_or_reuse_pool`, so earlier entries may already be paid for and
4033            // written — and only the escrow moved, so failing here strands nothing
4034            // in flight. The funding recovery step takes the same disposition
4035            // (`CliFunder::recover`).
4036            let effect = topped_up_effect(state.pool_id, credited);
4037            grade_deposit_credit(
4038                store.add_deposit(self_address, state.pool_id, credited),
4039                &effect,
4040                tx,
4041            )?;
4042            // The credit committed, so the row must be there. A `None` here
4043            // means it vanished between the write and this read — the local
4044            // record did not survive, which is the same untracked-escrow
4045            // condition, not something to paper over with the pre-top-up
4046            // snapshot.
4047            return store
4048                .get_by_pool_id(state.pool_id)?
4049                .ok_or_else(|| escrowed_but_untracked(&effect, tx, "the credited row vanished"));
4050        }
4051        Err(err) => err,
4052    };
4053    if err.downcast_ref::<TopUpUnconfirmed>().is_some() {
4054        return Err(err);
4055    }
4056    let Some(shortfall) = funding
4057        .check_wallet(
4058            rpc,
4059            state.token,
4060            self_address,
4061            state.pool_id,
4062            additional,
4063            &err,
4064        )
4065        .await
4066    else {
4067        return Err(err);
4068    };
4069    if remaining.is_zero() {
4070        return Err(cannot_pay(&state, &shortfall));
4071    }
4072    // `check_wallet` already logged the shortfall at WARN.
4073    tracing::info!(
4074        pool_id = %state.pool_id,
4075        %remaining,
4076        "continuing on the pool's remaining deposit (µUSDC) without a top-up"
4077    );
4078    Ok(state)
4079}
4080
4081/// Reuse the caller's live pool (resuming `provider`'s lane watermark), or open
4082/// and persist a new one. A reused pool whose pool-wide remaining deposit has
4083/// run low gets an on-chain `topUp` before it is returned; a wallet too short
4084/// of USDC for it still returns the pool while it can pay — see
4085/// [`refill_if_low`]. The lane then joins `funding`, the run's view of the
4086/// pool's spend outside its lanes ([`RunFunding::join_lane`]). There is no
4087/// pool expiry (ADR 003), so
4088/// there is no replace-on-expiry branch: the same pool is reused for the
4089/// caller's whole lifetime, across every provider.
4090///
4091/// `deployment` is the chain and `PaymentPool` that `contract` talks to: a
4092/// tracked row is reused only on it, a new or adopted row carries it, and the
4093/// voucher EIP-712 domain derives from it.
4094#[allow(clippy::too_many_arguments)]
4095pub(crate) async fn open_or_reuse_pool<P>(
4096    store: &RedbBuyerPoolStore,
4097    contract: &PaymentPool::PaymentPoolInstance<P>,
4098    rpc: &P,
4099    funding: &RunFunding,
4100    signer: &Arc<PrivateKeySigner>,
4101    provider: Address,
4102    self_address: Address,
4103    deployment: Deployment,
4104    working_deposit: U256,
4105    max_approve: bool,
4106    adoption: ChainAdoption,
4107) -> anyhow::Result<(PoolContext, LaneSpend)>
4108where
4109    P: alloy::providers::Provider + Clone,
4110{
4111    let payment_pool_addr = deployment.payment_pool;
4112    let tracked =
4113        pool_to_reuse(store, contract, funding, self_address, deployment, adoption).await?;
4114    if let Some(state) = tracked {
4115        let lane = LaneKey {
4116            pool_id: state.pool_id,
4117            signer: self_address,
4118            provider,
4119        };
4120        let recorded = state.lane_progress(lane).is_some();
4121        let from_chain = !state.redeemed_elsewhere().is_zero();
4122        let (state, prior_bytes, prior_amount) = if let Some(p) = state.lane_progress(lane) {
4123            (state, p.last_bytes, p.last_amount)
4124        } else {
4125            // No local record of this lane — always so for a pool just adopted, and
4126            // for a tracked pool's first contact with a provider. The chain may
4127            // still hold a watermark for it, and a voucher at or below that
4128            // watermark redeems nothing — so resuming from zero would stream bytes
4129            // the provider can never cash. Resume from the chain.
4130            let (bytes, amount) = lane_watermark(contract, lane).await?;
4131            seed_lane(store, state, lane, bytes, amount)?
4132        };
4133
4134        // Auto-refill a live pool whose remaining deposit has run low, so a
4135        // sustained series of fetches isn't stranded by a spent-down deposit.
4136        let spent = state.pool_spend();
4137        let state = refill_if_low(
4138            store,
4139            contract,
4140            rpc,
4141            funding,
4142            state,
4143            self_address,
4144            spent,
4145            working_deposit,
4146            max_approve,
4147        )
4148        .await?;
4149        let lane_spend = LaneSpend {
4150            outside: spent.saturating_sub(prior_amount),
4151            prior: prior_amount,
4152            recorded,
4153            from_chain,
4154        };
4155        return self_owned_lane_ctx(
4156            &state,
4157            signer,
4158            &deployment.voucher_domain(),
4159            provider,
4160            prior_bytes,
4161            prior_amount,
4162        )
4163        .map(|ctx| (ctx, lane_spend));
4164    }
4165
4166    // Authoritative USDC token for the pool, from the contract itself.
4167    let token = contract
4168        .usdc()
4169        .call()
4170        .await
4171        .map_err(|e| anyhow::Error::new(e).context("read PaymentPool.usdc()"))?;
4172    // Escrowed as configured — there is no on-chain floor to clamp up to,
4173    // only a non-zero requirement (`openPool` reverts `ZeroAmount`). The shared
4174    // pool is fully withdrawable, so the buyer opens at the working deposit
4175    // directly rather than a smaller first-contact lock.
4176    let deposit = working_deposit;
4177    // `max_approve` opts into an unlimited standing allowance; otherwise approve
4178    // exactly the deposit being escrowed.
4179    let approve_amount = if max_approve { None } else { Some(deposit) };
4180    ensure_allowance(rpc, token, self_address, payment_pool_addr, approve_amount).await?;
4181    let opened = open_pool(
4182        contract,
4183        Arc::clone(signer),
4184        deployment,
4185        token,
4186        self_address,
4187        deposit,
4188    )
4189    .await?;
4190    // The deposit is escrowed on-chain; a failed local record leaves it
4191    // untracked (reconcile against the tx).
4192    store
4193        .record(&opened.state)
4194        .map_err(|e| escrowed_but_untracked("buyer pool opened", opened.tx, e))?;
4195    // A fresh pool has spent nothing on any lane.
4196    let spend = LaneSpend {
4197        outside: U256::ZERO,
4198        prior: U256::ZERO,
4199        recorded: false,
4200        from_chain: false,
4201    };
4202    Ok((
4203        opened
4204            .ctx
4205            .with_provider(provider, U256::ZERO, U256::ZERO)
4206            .with_capability(opened.capability),
4207        spend,
4208    ))
4209}
4210
4211/// A unique `O_CREAT|O_EXCL` temp file in `target`'s directory, ready to be
4212/// `persist`ed over it. Staging beside the destination is what makes the final
4213/// rename atomic (same filesystem) — a temp in `/tmp` would not be.
4214pub(crate) fn temp_in_parent(target: &Path) -> std::io::Result<tempfile::NamedTempFile> {
4215    match target.parent().filter(|p| !p.as_os_str().is_empty()) {
4216        Some(p) => tempfile::NamedTempFile::new_in(p),
4217        None => tempfile::NamedTempFile::new_in("."),
4218    }
4219}
4220
4221#[cfg(test)]
4222#[allow(
4223    clippy::unwrap_used,
4224    clippy::expect_used,
4225    clippy::indexing_slicing,
4226    clippy::panic
4227)]
4228mod tests;
4229
4230/// `open_or_reuse_pool` against a mocked `PaymentPool`: what a client does when
4231/// its store has no row for a pool the chain says it owns.
4232#[cfg(test)]
4233#[allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)]
4234mod adoption_tests;
4235
4236/// Probe-shaped fixtures shared by the fetch and source tests.
4237#[cfg(test)]
4238pub(crate) mod tests_support;