Skip to main content

decdn_cli/commands/
fetch.rs

1//! The blob route of `decdn get` (`decdn:<hash>`): the client-side paid pull
2//! of a single 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//! [`decdn_incentive::buyer_pool_redb::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//! Discovery, lane building and pool funding are `decdn-client`'s engine
28//! seams ([`decdn_client::targets`], [`decdn_client::lanes`],
29//! [`decdn_client::funding`], [`decdn_client::lane_sources`]), which
30//! the bundle route ([`super::bundle_pull`]) drives too. This module resolves the command's flags
31//! and config into their inputs, and renders what the fetch did.
32
33use std::path::{Path, PathBuf};
34use std::pin::Pin;
35use std::str::FromStr;
36use std::sync::{Arc, Mutex, PoisonError};
37use std::task::Poll;
38use std::time::{Duration, Instant};
39
40use alloy::primitives::U256;
41use anyhow::Context as _;
42use decdn_client::bundle::BundleManifest;
43use decdn_client::buyer_pool::{OwnedPayer, UnconfirmedEscrows};
44use decdn_client::funding::{RunFunding, cached_pool_token};
45use decdn_client::lane_sources::{FaultContext, LaneSources};
46use decdn_client::lanes::{LaneDeps, LanePayer, STALL_WINDOW, SettleOnDrop};
47use decdn_client::session::{
48    ChainAdoption, ChainConfig, DepositPolicy, DiscoveryOptions, PinnedNode, ProxyWarmingParams,
49    PullSite,
50};
51use decdn_client::targets::{
52    ProbeOpts, ProbeRound, ResolveConfigFault, holders_or_none, resolve_target_node,
53};
54use decdn_client::{
55    Connections, CredentialSlot, DownloadTarget, Downloader, Holder, LaneLedgers,
56    NoAffordableSource, NoCache, NoSourceHasBlob, PeerHealth, PoolContext, PoolReplaced,
57    ProgressClock, PullConfig, PullDeadlines, RecoveryGate, StopPolicy, Streamer, UpstreamRefused,
58    UpstreamVoucherRejected,
59};
60use decdn_common::cli::{self, common::expand_tilde};
61use decdn_common::config::{DEFAULT_CHAIN_ID, FileConfig, load_file_config};
62use decdn_incentive::buyer_pool::BuyerPoolStore;
63use decdn_incentive::eth_identity::{self, PasswordUse, load_signer};
64use decdn_incentive::payment_pool::PaymentPool;
65use decdn_incentive::rate::min_payment;
66use decdn_incentive::{CapabilityGrant, PoolId, slash_judge_domain, voucher_domain};
67use decdn_protocol::client::StreamError;
68use iroh::{Endpoint, PublicKey, RelayUrl};
69
70use decdn_client::endpoint as client_endpoint;
71use decdn_client::provider;
72
73use super::buyer_store::{DataDirSource, chain_adoption_for_buy, open_client_store_for_buy};
74use super::fetch_timings::{FetchTimings, Mark};
75use super::interrupt::{Interrupt, Interrupted, name_held_escrows};
76use super::tab_progress::TabProgress;
77use decdn_client::ordered_writes::OrderedWrites;
78
79/// Parse a manifest entry's BLAKE3 hash: 64 hex chars, optionally `0x`- or
80/// `b3:`-prefixed (the `b3:` form is what bundle manifests carry).
81pub(crate) use decdn_client::bundle::parse_hash;
82use decdn_client::uri::{DecdnUri, UriTarget};
83
84/// Build the blob `decdn get` delivery progress bar (#1118). Drawn to stderr and
85/// TTY-aware — `indicatif` hides it automatically when stderr is not a terminal,
86/// so a piped/redirected fetch emits no bar. A steady tick animates the spinner
87/// during the pre-byte connect/handshake so the command never looks hung. The
88/// bar counts verified **content** bytes against the blob's `total_bytes` — the
89/// unit the [`decdn_client::ProgressCallback`] reports — so length and
90/// position share one unit and the bar fills to exactly 100%. The acquire loop
91/// reports one monotonic total the lanes fold their per-leg deltas into, so
92/// the bar never jumps between lanes' divergent local positions.
93fn new_progress_bar() -> indicatif::ProgressBar {
94    let style = indicatif::ProgressStyle::with_template(
95        // Rate/ETA come from `{msg}` (see `delivery_progress`), not the built-in
96        // `{bytes_per_sec}`/`{eta}` — those swing wildly on bursty chunk arrival.
97        "{spinner:.green} {bytes}/{total_bytes} {msg}[{wide_bar:.cyan/blue}]",
98    )
99    // A bad template is a programming error, not a runtime one; fall back to the
100    // built-in bar rather than panic (clippy forbids `unwrap`/`expect`).
101    .unwrap_or_else(|_| indicatif::ProgressStyle::default_bar())
102    .progress_chars("=>-");
103    // When client logging is enabled, share stderr with the tracing subscriber
104    // through its `MultiProgress` so log lines do not corrupt the bar; a no-op
105    // (returns the bar unchanged) on the default no-subscriber path. The bar is
106    // attached before it is styled or ticked: either would draw a detached bar
107    // straight to stderr, leaving an orphan line the container never clears.
108    // `AndClear`: a bar dropped unfinished (a Ctrl-C) clears its line too.
109    let bar = crate::logging::attach_progress_bar(
110        indicatif::ProgressBar::new(0).with_finish(indicatif::ProgressFinish::AndClear),
111    );
112    bar.set_style(style);
113    bar.enable_steady_tick(Duration::from_millis(120));
114    bar
115}
116
117/// Chain coordinates resolved flag > `[blockchain]`/`[identity]` config >
118/// default. Pure (parse-only) so the precedence is unit-testable.
119#[derive(Debug)]
120pub(crate) struct ResolvedChain {
121    /// The chain the command pays on. Its `capacity_bond` is `None` when
122    /// neither the flag nor `blockchain.capacity_bond_address` is set — only
123    /// an error on the discovery path, never on the explicit-node path.
124    pub(crate) config: ChainConfig,
125    /// Where the command keeps its state, and the client's region. The data
126    /// dir holds the buyer-pool redb store and (by default) the keystore. It
127    /// is client-scoped (`~/.decdn/client`) unless an explicit
128    /// `--data-dir`/`identity.data_dir` is given. The region is `--region` >
129    /// `identity.region`. The region allowlist is `[client] region_allowlist`;
130    /// invalid entries (fail `Region::parse`) are dropped with a
131    /// `tracing::warn!` in [`resolve_chain`] rather than failing config
132    /// resolution — a typo'd region code shrinks the filter, it does not
133    /// break the fetch.
134    pub(crate) site: PullSite,
135    pub(crate) keystore: PathBuf,
136    /// File holding the keystore password, consulted after the
137    /// `DECDN_KEYSTORE_PASSWORD` env var and before a prompt. CLI/env-only, like
138    /// the daemon's `ResolvedBlockchain::keystore_password_file` — passwords do
139    /// not belong in a config file even by reference.
140    pub(crate) keystore_password_file: Option<PathBuf>,
141    /// Which step of the ladder produced the data dir. A node's data dir
142    /// is acceptable when the operator named it and not when it merely fell out
143    /// of the config file, so the guard needs the provenance, not just the path
144    /// (#2082).
145    pub(crate) data_dir_source: DataDirSource,
146    /// Deposit to escrow when OPENING a pool, and the target a reused pool's
147    /// proactive refill restores toward once it has served verified bytes.
148    pub(crate) working_deposit: U256,
149    pub(crate) max_approve: bool,
150}
151
152impl ResolvedChain {
153    /// How the command funds its pool. A delegated command (`--capability`)
154    /// opens no pool, so it adopts none either; a self-owned one adopts the
155    /// wallet's live pool only when neither the data dir nor the keystore
156    /// belongs to a node ([`chain_adoption_for_buy`]).
157    ///
158    /// # Errors
159    ///
160    /// The owner of the data dir or the keystore's dir cannot be told.
161    pub(crate) fn deposit_policy(&self, delegated: bool) -> anyhow::Result<DepositPolicy> {
162        let adoption = if delegated {
163            ChainAdoption::Refused
164        } else {
165            chain_adoption_for_buy(&self.site.data_dir, &self.keystore)?
166        };
167        Ok(DepositPolicy {
168            working_deposit: self.working_deposit,
169            max_approve: self.max_approve,
170            adoption,
171        })
172    }
173}
174
175/// Parse `[client] region_allowlist` into [`decdn_protocol::Region`]s.
176/// Parsed here — not carried as raw strings — so an invalid code is reported
177/// once at config-resolution time rather than on every fetch's discovery
178/// path. An entry that fails `Region::parse` is dropped, not fatal: it costs
179/// the filter one entry, not the whole fetch. Absent `[client]` or an absent
180/// `region_allowlist` both yield an empty `Vec`, which is a no-op filter.
181fn parse_region_allowlist(file: &FileConfig) -> Vec<decdn_protocol::Region> {
182    file.client
183        .as_ref()
184        .and_then(|c| c.region_allowlist.as_ref())
185        .into_iter()
186        .flatten()
187        .filter_map(|code| {
188            let parsed = decdn_protocol::Region::parse(code);
189            if parsed.is_none() {
190                tracing::warn!(
191                    "client.region_allowlist entry {code:?} is not a recognized \
192                     region code; dropping it from the filter"
193                );
194            }
195            parsed
196        })
197        .collect()
198}
199
200pub(crate) fn resolve_chain(
201    args: &cli::ClientFetchArgs,
202    file: &FileConfig,
203) -> anyhow::Result<ResolvedChain> {
204    let bc = file.blockchain.as_ref();
205    let rpc_url = args
206        .rpc_url
207        .clone()
208        .or_else(|| bc.and_then(|b| b.rpc_url.clone()))
209        .ok_or_else(|| anyhow::anyhow!("rpc_url not set (--rpc-url or blockchain.rpc_url)"))?;
210
211    let pp_raw = args
212        .payment_pool_address
213        .clone()
214        .or_else(|| bc.and_then(|b| b.payment_pool_address.clone()))
215        .ok_or_else(|| {
216            anyhow::anyhow!(
217                "payment_pool_address not set (--payment-pool-address or \
218                 blockchain.payment_pool_address)"
219            )
220        })?;
221    let payment_pool = super::chain_ctx::parse_nonzero_address(&pp_raw, "payment_pool_address")?;
222
223    let sj_raw = args
224        .slash_judge_address
225        .clone()
226        .or_else(|| bc.and_then(|b| b.slash_judge_address.clone()))
227        .ok_or_else(|| {
228            anyhow::anyhow!(
229                "slash_judge_address not set (--slash-judge-address or \
230                 blockchain.slash_judge_address)"
231            )
232        })?;
233    let slash_judge = super::chain_ctx::parse_nonzero_address(&sj_raw, "slash_judge_address")?;
234
235    // Optional: only the auto-discovery path reads it, and it errors there if
236    // unset rather than failing every explicit-node fetch.
237    let capacity_bond = args
238        .capacity_bond_address
239        .clone()
240        .or_else(|| bc.and_then(|b| b.capacity_bond_address.clone()))
241        .map(|raw| super::chain_ctx::parse_nonzero_address(&raw, "capacity_bond_address"))
242        .transpose()?;
243
244    let region = args
245        .region
246        .clone()
247        .or_else(|| file.identity.as_ref().and_then(|i| i.region.clone()));
248
249    // The `[client] region_allowlist` pre-filters discovery/probing.
250    let region_allowlist = parse_region_allowlist(file);
251
252    let chain_id = args
253        .chain_id
254        .or_else(|| bc.and_then(|b| b.chain_id))
255        .unwrap_or(DEFAULT_CHAIN_ID);
256
257    // Client data dir: an explicit `--data-dir`/`identity.data_dir` wins,
258    // otherwise the client-scoped `~/.decdn/client` (not the node-shaped
259    // `~/.decdn`, so a pure client install doesn't masquerade as a node).
260    // Which step won is carried forward: on a node host `identity.data_dir`
261    // points at the daemon's dir, and buying from there under the node's own
262    // keystore is a refusal unless the operator asked for it by name (#2082).
263    let (data_dir, data_dir_source) = args
264        .data_dir
265        .clone()
266        .map(|p| (p, DataDirSource::Flag))
267        .or_else(|| {
268            file.identity
269                .as_ref()
270                .and_then(|i| i.data_dir.clone())
271                .map(|p| (p, DataDirSource::Config))
272        })
273        .map(|(p, source)| (expand_tilde(&p), source))
274        .or_else(|| cli::default_client_data_dir().map(|p| (p, DataDirSource::Default)))
275        .ok_or_else(|| {
276            anyhow::anyhow!("data_dir not set and no default available (pass --data-dir)")
277        })?;
278
279    // Keystore: an explicit `--keystore`/`blockchain.eth_keystore` wins; otherwise
280    // `keystore.json` under the (client-scoped) data dir.
281    let keystore = args
282        .keystore
283        .clone()
284        .or_else(|| bc.and_then(|b| b.eth_keystore.clone()))
285        .map_or_else(
286            || eth_identity::keystore_path(&data_dir),
287            |p| expand_tilde(&p),
288        );
289
290    // The deposit knob carries the same invariant the daemon resolver
291    // (`resolve_blockchain_into`) enforces, and it must be checked HERE too: this
292    // path resolves the raw `[blockchain]` table plus the CLI flags without going
293    // through that resolver, so without this the client would accept a config file
294    // `decdn config validate` rejects, and `--working-deposit-micro-usdc 0` would
295    // surface as an opaque `openPool` `ZeroAmount` revert instead of a load-time
296    // message. Keep the wording in step with `resolve_blockchain_into`.
297    let working_deposit_micro_usdc = args
298        .working_deposit_micro_usdc
299        .or_else(|| bc.and_then(|b| b.buyer_working_deposit_micro_usdc))
300        .unwrap_or(decdn_common::config::DEFAULT_BUYER_WORKING_DEPOSIT_MICRO_USDC);
301    anyhow::ensure!(
302        working_deposit_micro_usdc > 0,
303        "buyer_working_deposit_micro_usdc must be > 0 (openPool reverts ZeroAmount on a \
304         zero deposit) — set --working-deposit-micro-usdc or \
305         blockchain.buyer_working_deposit_micro_usdc"
306    );
307    let working_deposit = U256::from(working_deposit_micro_usdc);
308    // Client default: exact (deposit-sized) USDC approval, not an unlimited
309    // standing allowance. `buyer_max_approve = true` opts a power user back into
310    // the node/operator posture. (The daemon's own default stays unlimited.)
311    let max_approve = bc.and_then(|b| b.buyer_max_approve).unwrap_or(false);
312
313    Ok(ResolvedChain {
314        config: ChainConfig {
315            rpc_url,
316            chain_id,
317            payment_pool,
318            slash_judge,
319            capacity_bond,
320        },
321        site: PullSite {
322            data_dir,
323            region,
324            region_allowlist,
325        },
326        keystore,
327        keystore_password_file: args.keystore_password_file.as_deref().map(expand_tilde),
328        data_dir_source,
329        working_deposit,
330        max_approve,
331    })
332}
333
334/// Client-side proxy-warming knobs (#1174, ADR 037), distilled from
335/// [`cli::ClientFetchArgs`]. RTT thresholds are held as `f64` ms to compare
336/// directly against probe RTTs.
337pub(crate) fn proxy_warming(args: &cli::ClientFetchArgs) -> ProxyWarmingParams {
338    // Widen through u32 so the u64 → f64 cast is lossless (ms knobs are
339    // small); saturate the implausible overflow rather than lose precision.
340    let ms = |v: u64| f64::from(u32::try_from(v).unwrap_or(u32::MAX));
341    ProxyWarmingParams {
342        enabled: args.proxy_warming,
343        rtt_threshold_ms: ms(args.proxy_warming_rtt_threshold_ms),
344        margin_ms: ms(args.proxy_warming_margin_ms),
345    }
346}
347
348/// How the command finds a blob's holders, from its flags: the `--node-id`
349/// it pins (with `--provider-address` and any `--addr`), or discovery.
350///
351/// # Errors
352///
353/// A [`ResolveConfigFault`] for a malformed `--node-id` or
354/// `--provider-address`, or a `--node-id` without `--provider-address`.
355pub(crate) fn discovery_options(args: &cli::ClientFetchArgs) -> anyhow::Result<DiscoveryOptions> {
356    Ok(DiscoveryOptions {
357        pinned: pinned_node(args)?,
358        rediscover: args.rediscover,
359        max_sources: args.max_sources,
360        registry_timeout: args.discovery_cap(),
361        proxy_warming: proxy_warming(args),
362    })
363}
364
365/// The node `--node-id` pins, or `None` to discover. `clap` guarantees
366/// `--provider-address` is present alongside `--node-id`.
367fn pinned_node(args: &cli::ClientFetchArgs) -> anyhow::Result<Option<PinnedNode>> {
368    let Some(raw) = &args.node_id else {
369        return Ok(None);
370    };
371    let config = |e: anyhow::Error| anyhow::Error::new(ResolveConfigFault(format!("{e:#}")));
372    let node_id = PublicKey::from_str(raw)
373        .map_err(|e| config(anyhow::anyhow!("invalid --node-id {raw:?}: {e}")))?;
374    let provider_raw = args.provider_address.as_deref().ok_or_else(|| {
375        config(anyhow::anyhow!(
376            "--provider-address is required with --node-id"
377        ))
378    })?;
379    let provider =
380        super::chain_ctx::parse_address(provider_raw, "--provider-address").map_err(config)?;
381    Ok(Some(PinnedNode {
382        node_id,
383        provider,
384        addr: args.addr,
385    }))
386}
387
388/// Reconnect an opaque `delivery refused: NotFound` to its likely cause(s).
389///
390/// Two causes are checked, and BOTH may be attached: the pool cannot cover what
391/// the node reserves before serving, and/or the request carried no ADR 005
392/// client binding. They are independent — an unbound fetch on a drained pool is
393/// both, and fixing only the one we happened to name first leaves the retry
394/// failing identically. An unbound request (no `blockchain.capacity_bond_address`,
395/// so nothing to sign) cannot authorize the node to reactively pull a
396/// cache-missed blob from its origin, so the node returns a bare `NotFound` —
397/// indistinguishable, without this, from a genuinely absent/blacklisted/wrong
398/// hash. Scoped to `NotFound`, and to [`NoSourceHasBlob`] (every source said
399/// `NotFound`) — a size or blacklist refusal is fixed by neither cause — so
400/// every other error is returned verbatim.
401pub(crate) fn annotate_unbound_cache_miss(err: anyhow::Error, ctx: &PoolContext) -> anyhow::Error {
402    let refused = err
403        .downcast_ref::<UpstreamRefused>()
404        .filter(|refused| matches!(refused.error(), StreamError::NotFound));
405    if refused.is_none() && err.downcast_ref::<NoSourceHasBlob>().is_none() {
406        return err;
407    }
408
409    // Both causes are checked and BOTH may attach. They are independent — an
410    // unbound fetch on a drained pool is both — and naming only the first
411    // leaves the user fixing one and retrying into the other.
412    let mut causes: Vec<String> = Vec::new();
413
414    // Cause (a): our pool cannot cover what the node reserves before serving.
415    // The signed refusal carries the node's quoted `rate_per_mb`, and the
416    // pool's own deposit and cumulative paid amount on this lane are right
417    // here, so the comparison uses only OUR pool and data the node already
418    // sent us — it does not weaken the deliberate collapse of seven reject
419    // reasons onto `NotFound` (which exists so a prober cannot map other
420    // clients' balances).
421    //
422    // The node's pre-flight reservation is one chunk (the ramp floor); the window
423    // only widens as this pool pays, so one chunk is the true lower bound on what
424    // it reserves before serving. Estimated with the fixed `CHUNK_BYTES`, since we
425    // cannot read the node's config — and because `CHUNK_BYTES == BYTES_PER_MB`,
426    // that estimate is exactly the quoted per-MB rate. The miss direction is "we
427    // stay silent when we could have spoken" — never a fabricated shortfall — so
428    // it is phrased as a possibility and as a lower bound.
429    if let Some(quoted_rate) = refused
430        .and_then(UpstreamRefused::evidence)
431        .map(|resp| resp.body.rate_per_mb)
432    {
433        let headroom = ctx.deposit.saturating_sub(ctx.prior_amount);
434        let estimate = min_payment(decdn_protocol::client::CHUNK_BYTES, quoted_rate);
435        if quoted_rate > 0 && headroom < estimate {
436            causes.push(format!(
437                "this pool's remaining deposit ({headroom}) is below the ~{estimate} the node \
438                 reserves before serving at its quoted rate of {quoted_rate} per MB. The fetch \
439                 path already auto-refills below a low-water mark, so reaching this means the \
440                 configured working deposit is itself too small: raise \
441                 `--working-deposit-micro-usdc` (or `blockchain.buyer_working_deposit_micro_usdc`) \
442                 and retry"
443            ));
444        }
445    }
446
447    // Cause (b): no binding, so the node would not reactively pull for us.
448    if ctx.client_binding.is_none() {
449        causes.push(
450            "no client identity binding was sent because \
451             blockchain.capacity_bond_address is unset, so the node could not \
452             reactively pull this cache-missed blob from its origin; set \
453             blockchain.capacity_bond_address to enable reactive pull-through"
454                .to_string(),
455        );
456    }
457
458    match causes.len() {
459        0 => err,
460        1 => err.context(causes.concat()),
461        // Numbered rather than joined with "and": each clause is a full sentence
462        // with its own remedy, so a reader needs to see they are separate fixes.
463        n => err.context(format!(
464            "{n} possible causes: {}",
465            causes
466                .iter()
467                .enumerate()
468                .map(|(i, c)| format!("({}) {c}", i + 1))
469                .collect::<Vec<_>>()
470                .join("; ")
471        )),
472    }
473}
474
475/// The largest bundle manifest that the gateway and
476/// `decdn get decdn:<hash>/<path> -o -` read into memory.
477pub(crate) const MAX_MANIFEST_BYTES: usize = 64 << 20;
478
479/// [`MAX_MANIFEST_BYTES`] in MiB, the unit of `--max-blob-mb`.
480pub(crate) const MAX_MANIFEST_MB: u64 = 64;
481
482/// The `--max-blob-mb` a manifest fetch runs with, given the caller's. The cap
483/// bounds what the lanes request, and so pay for, to the largest manifest.
484/// `0` is no cap, so it takes the manifest cap too.
485pub(crate) fn manifest_cap_mb(max_blob_mb: u64) -> u64 {
486    match max_blob_mb {
487        0 => MAX_MANIFEST_MB,
488        mb => mb.min(MAX_MANIFEST_MB),
489    }
490}
491
492/// `decdn get decdn:<hash>`: fetch the blob `uri` names over `cdn/client/v1`,
493/// auto-opening/reusing the caller's `PaymentPool` deposit, and write it
494/// atomically to `output` (or stream it to stdout when `output` is `-`). `config_path`
495/// (the global `--config`) supplies relays (#935), discovery (#936), and chain
496/// coordinates.
497///
498/// `decdn get decdn:<hash>/<path> -o -` runs here too. It fetches the bundle
499/// manifest into memory, then streams the blob of the entry `<path>` names to
500/// stdout. Both fetches share one host and one payer, so the keystore unlocks
501/// once.
502///
503/// With `--node-id` the node is dialed explicitly. Without it, `fetch`
504/// auto-discovers (#936): read the active set from `CapacityBond`, probe a
505/// random, region-first sample of candidates, and derive `--provider-address` from each
506/// candidate's registry entry, striping across them (#1174).
507///
508/// # Errors
509///
510/// [`Interrupted`] on Ctrl-C, [`decdn_client::GaveUp`] once the stop policy's
511/// no-progress limit passes, or the fault that ended the fetch. A fetch that
512/// ends with an `openPool` or `topUp` that no local record credits yet, or with
513/// one that escrowed with no local record crediting it, carries
514/// [`super::interrupt::UnreconciledEscrows`], as context on any of those or as the
515/// error of a fetch that otherwise succeeded. A pending transaction the error
516/// carries, or an untracked one whose tx a failure's text names, is not
517/// repeated there. A Ctrl-C or a give-up names every untracked one, because
518/// `main` prints only its stop line.
519pub(crate) async fn fetch(
520    args: &cli::GetArgs,
521    output: &Path,
522    uri: &DecdnUri,
523    config_path: Option<&Path>,
524) -> anyhow::Result<()> {
525    let timings = FetchTimings::start();
526    let (host, grant) = FetchHost::open(&args.common, config_path, false).await?;
527    timings.mark(Mark::Endpoint);
528    let payer = Payer::keystore(grant);
529    // A Ctrl-C drops the fetch mid-transfer: each drive's drop guard queues
530    // what it paid for recording, and the `.partial` is the next run's resume
531    // prefix. The handle outlives the fetch, so an `openPool` or `topUp` that a
532    // Ctrl-C or a fault dropped before its record committed is still named.
533    let mut interrupt = Interrupt::watch(host.escrows.clone());
534    let result = tokio::select! {
535        result = fetch_target(&host, &payer, args, output, uri, &timings) => result,
536        () = interrupt.wait() => Err(Interrupted.into()),
537    };
538    // On every exit, a Ctrl-C's included: the gate lists a replacement whose
539    // fetch was dropped too.
540    print_reclaim_notes(&payer.recovery.replacements());
541    let result = name_held_escrows(result, &host.escrows);
542    let output = if wants_stdout(output) {
543        "stdout"
544    } else {
545        "file"
546    };
547    timings.log(output, result.is_ok());
548    // On every exit, a Ctrl-C's included: what the drives queued is durable
549    // before the command returns.
550    host.close().await;
551    result
552}
553
554/// Fetch what `uri` names on `host` to `output`: the blob itself, or for an
555/// entry URI the blob of that bundle entry.
556async fn fetch_target(
557    host: &FetchHost,
558    payer: &Payer,
559    args: &cli::GetArgs,
560    output: &Path,
561    uri: &DecdnUri,
562    timings: &FetchTimings,
563) -> anyhow::Result<()> {
564    let blob = |hash| BlobFetch {
565        hash,
566        namespace: uri.namespace,
567        output: output.to_path_buf(),
568        common: args.common.clone(),
569    };
570    match &uri.target {
571        UriTarget::Blob => fetch_on(host, payer, &blob(uri.hash), &mut Sink::Output, timings).await,
572        UriTarget::BundleEntry(path) => {
573            let hash = fetch_entry_hash(host, payer, args, uri, path).await?;
574            fetch_on(host, payer, &blob(hash), &mut Sink::Output, timings)
575                .await
576                .with_context(|| {
577                    format!(
578                        "fetch bundle entry {path:?} (blob {})",
579                        blake3::Hash::from_bytes(hash).to_hex()
580                    )
581                })
582        }
583        _ => anyhow::bail!("only a blob or one bundle entry streams as one blob"),
584    }
585}
586
587/// Fetch the bundle manifest `uri` names into memory, at most
588/// [`MAX_MANIFEST_BYTES`], and return the blob hash of its entry `path`. The
589/// manifest fetch logs its own `fetch timings` event, with
590/// `output = "manifest"`.
591async fn fetch_entry_hash(
592    host: &FetchHost,
593    payer: &Payer,
594    args: &cli::GetArgs,
595    uri: &DecdnUri,
596    path: &str,
597) -> anyhow::Result<[u8; 32]> {
598    let mut common = args.common.clone();
599    common.max_blob_mb = manifest_cap_mb(common.max_blob_mb);
600    let manifest = BlobFetch {
601        hash: uri.hash,
602        namespace: uri.namespace,
603        output: PathBuf::from("-"),
604        common,
605    };
606    let buf = ManifestBuf::default();
607    let mut sink = Sink::Writer(Box::new(buf.clone()));
608    let timings = FetchTimings::start();
609    let fetched = fetch_on(host, payer, &manifest, &mut sink, &timings).await;
610    timings.log("manifest", fetched.is_ok());
611    fetched.map_err(|err| {
612        if err.downcast_ref::<decdn_client::BlobTooLarge>().is_some() {
613            err.context(format!(
614                "the blob is larger than a bundle manifest can be ({MAX_MANIFEST_MB} MiB); \
615                 drop the path to fetch the raw blob"
616            ))
617        } else {
618            err.context("fetch the bundle manifest")
619        }
620    })?;
621    let bytes = std::mem::take(&mut *buf.0.lock().unwrap_or_else(PoisonError::into_inner));
622    entry_hash(&bytes, path)
623}
624
625/// The blob hash of the entry `path` in the bundle manifest `bytes`.
626fn entry_hash(bytes: &[u8], path: &str) -> anyhow::Result<[u8; 32]> {
627    let manifest = BundleManifest::parse(bytes).context(
628        "the URI's `/<path>` names a bundle entry, but this blob is not a bundle manifest; \
629         drop the path to fetch the raw blob",
630    )?;
631    let entry = manifest
632        .entries
633        .iter()
634        .find(|e| e.path == path)
635        .ok_or_else(|| {
636            anyhow::anyhow!(
637                "the bundle has no entry {path:?}; a URI path names one manifest `path` exactly"
638            )
639        })?;
640    parse_hash(&entry.hash).with_context(|| format!("bundle entry {path:?} has a malformed hash"))
641}
642
643/// An in-memory [`Sink::Writer`] for a bundle manifest. It holds at most
644/// [`MAX_MANIFEST_BYTES`] and fails a write past that.
645#[derive(Clone, Default)]
646struct ManifestBuf(Arc<Mutex<Vec<u8>>>);
647
648impl tokio::io::AsyncWrite for ManifestBuf {
649    fn poll_write(
650        self: Pin<&mut Self>,
651        _: &mut std::task::Context<'_>,
652        buf: &[u8],
653    ) -> Poll<std::io::Result<usize>> {
654        let mut bytes = self.0.lock().unwrap_or_else(PoisonError::into_inner);
655        if bytes.len().saturating_add(buf.len()) > MAX_MANIFEST_BYTES {
656            return Poll::Ready(Err(std::io::Error::other(format!(
657                "the manifest is larger than {MAX_MANIFEST_BYTES} bytes"
658            ))));
659        }
660        bytes.extend_from_slice(buf);
661        Poll::Ready(Ok(buf.len()))
662    }
663
664    fn poll_flush(
665        self: Pin<&mut Self>,
666        _: &mut std::task::Context<'_>,
667    ) -> Poll<std::io::Result<()>> {
668        Poll::Ready(Ok(()))
669    }
670
671    fn poll_shutdown(
672        self: Pin<&mut Self>,
673        _: &mut std::task::Context<'_>,
674    ) -> Poll<std::io::Result<()>> {
675        Poll::Ready(Ok(()))
676    }
677}
678
679/// One blob fetch: what `decdn get decdn:<hash>` and the gateway each hand
680/// [`fetch_on`].
681pub(crate) struct BlobFetch {
682    /// The blob's BLAKE3 hash.
683    pub(crate) hash: [u8; 32],
684    /// The namespace routing hint (ADR 002).
685    pub(crate) namespace: Option<u64>,
686    /// `-o`, or its `./<hash>` default: the destination file, or `-` for stdout. Read only for
687    /// [`Sink::Output`].
688    pub(crate) output: PathBuf,
689    /// The retrieval flags.
690    pub(crate) common: cli::ClientFetchArgs,
691}
692
693/// A delegate credential held in memory: the key that signs vouchers and the
694/// capability issued to it. Takes the place of `--keystore` and
695/// `--capability`.
696pub(crate) struct Credential {
697    /// The delegate key.
698    pub(crate) signer: Arc<alloy::signers::local::PrivateKeySigner>,
699    /// The capability the pool owner issued to `signer`.
700    pub(crate) grant: CapabilityGrant,
701}
702
703/// Where a fetch's verified bytes go.
704pub(crate) enum Sink {
705    /// `--output`: a file, or stdout for `-`.
706    Output,
707    /// A caller's byte stream, filled in order as `-o -` fills stdout. The
708    /// gateway hands one per HTTP response, and `decdn get` hands one to read
709    /// an entry URI's manifest.
710    Writer(Box<dyn tokio::io::AsyncWrite + Send + Unpin>),
711}
712
713impl Sink {
714    /// Whether the fetch streams in order (stdout or a writer) rather than
715    /// downloading to a file.
716    fn streams(&self, output: &Path) -> bool {
717        match self {
718            Self::Output => wants_stdout(output),
719            Self::Writer(_) => true,
720        }
721    }
722}
723
724/// What every blob fetch on it shares, built once: the chain, the relays, the
725/// buyer-pool store, one discovery-enabled endpoint with one connection per
726/// node, the holder health table, and the queue of peer-record and watermark
727/// writes. `decdn get` builds one for its fetch, or for the manifest fetch
728/// and the entry fetch of an entry URI to stdout; the gateway builds one for
729/// its whole run and fetches on it concurrently from one thread.
730pub(crate) struct FetchHost {
731    relays: Vec<RelayUrl>,
732    chain: ResolvedChain,
733    store: Arc<dyn BuyerPoolStore>,
734    endpoint: Endpoint,
735    connections: Connections,
736    health: Arc<PeerHealth>,
737    writes: OrderedWrites,
738    /// Each `openPool` and `topUp` no local record credits yet, and each that
739    /// escrowed with no local record crediting it, across every fetch.
740    pub(crate) escrows: UnconfirmedEscrows,
741    /// The pool's settlement token, `PaymentPool.usdc()`, read once.
742    token: tokio::sync::OnceCell<alloy::primitives::Address>,
743}
744
745impl FetchHost {
746    /// Resolve `common` and the config at `config_path`, open the buyer-pool
747    /// store and bind the endpoint. With `held_key`, every fetch pays with a
748    /// key it is handed ([`Payer::held`]), so the capability flags are not
749    /// read and no pool is funded; otherwise the returned grant is the
750    /// `--capability`/`--capability-file` delegation, if any.
751    ///
752    /// # Errors
753    ///
754    /// A flag combination clap cannot express, unresolvable chain
755    /// coordinates or relays, an unreadable capability, a refused data dir,
756    /// or an endpoint that cannot bind.
757    pub(crate) async fn open(
758        common: &cli::ClientFetchArgs,
759        config_path: Option<&Path>,
760        held_key: bool,
761    ) -> anyhow::Result<(Self, Option<CapabilityGrant>)> {
762        // Before any network or keystore work, reject a flag combination clap
763        // cannot express, naming the flags the user actually typed.
764        common.validate()?;
765
766        // Relays: `--relay-url` overrides `network.relay_urls` (#935). Discovery:
767        // `[network.discovery]` composes operator resolution legs, else N0 (#936).
768        let relays = client_endpoint::resolve_relays(common.relay_url.as_deref(), config_path)?;
769        let disc = client_endpoint::client_discovery(config_path)?;
770
771        let file = load_file_config(config_path)?;
772        let mut chain = resolve_chain(common, &file)?;
773
774        // Delegated adoption: `--capability`/`--capability-file` names a pool the
775        // caller does NOT own and a capability authorizing this client's key to
776        // spend against it. `None` => the unchanged self-owned pool path. Zeroes
777        // the working deposit in `chain` (a delegate cannot fund an owner's pool).
778        // A held key is the same delegation, with its key in hand.
779        let grant = if held_key {
780            chain.working_deposit = U256::ZERO;
781            None
782        } else {
783            resolve_delegation_grant(common, &mut chain)?
784        };
785
786        // The buyer-pool store, opened once and recorded into by open-or-reuse.
787        // The guard runs here, before the keystore password prompt: the answer
788        // depends only on the data dir, and a refusal must not cost a prompt first.
789        // Shared, so a lane's watermark write runs off the runtime thread.
790        let store: Arc<dyn BuyerPoolStore> = Arc::new(open_client_store_for_buy(
791            &chain.site.data_dir,
792            chain.data_dir_source,
793            "fetch",
794        )?);
795
796        // One discovery-enabled endpoint, reused for probing and every delivery
797        // dial.
798        let endpoint = client_endpoint::client_endpoint(&relays, &disc).await?;
799        let host = Self {
800            relays,
801            chain,
802            store,
803            connections: Connections::new(endpoint.clone()),
804            endpoint,
805            health: Arc::new(PeerHealth::default()),
806            writes: OrderedWrites::default(),
807            escrows: UnconfirmedEscrows::default(),
808            token: tokio::sync::OnceCell::new(),
809        };
810        Ok((host, grant))
811    }
812
813    /// Wait for every queued peer-record and watermark write to land, then
814    /// close the endpoint, so its open connections end cleanly instead of
815    /// being aborted on drop.
816    pub(crate) async fn close(self) {
817        self.writes.settle().await;
818        self.endpoint.close().await;
819    }
820}
821
822/// Who pays for a fetch, and what fetches under that payer share: the
823/// funding recovery gate, the open-or-reuse lock, and for a held key the
824/// per-lane voucher ledgers, so concurrent fetches under one capability
825/// advance each lane's cumulative voucher through one issuer.
826pub(crate) struct Payer {
827    key: PayerKey,
828    /// The lane ledgers concurrent fetches share; `None` for a keystore payer,
829    /// whose fetches run one at a time, each lane starting from its persisted
830    /// watermark.
831    ledgers: Option<LaneLedgers>,
832    recovery: Arc<RecoveryGate>,
833    open_lock: tokio::sync::Mutex<()>,
834}
835
836enum PayerKey {
837    /// The keystore the flags name, with the `--capability` delegation when
838    /// there is one. The first fetch under the payer loads it once its holder
839    /// resolution ends, and every later fetch under the payer reuses it.
840    Keystore {
841        grant: Option<CapabilityGrant>,
842        loaded: tokio::sync::OnceCell<(Arc<alloy::signers::local::PrivateKeySigner>, PayerKeys)>,
843    },
844    /// A key in hand and the capability issued to it.
845    Held {
846        signer: Arc<alloy::signers::local::PrivateKeySigner>,
847        slot: CredentialSlot,
848    },
849}
850
851/// What a loaded payer's lanes sign under.
852#[derive(Clone)]
853enum PayerKeys {
854    /// The buyer's own pool: the keystore key owns it, and the lanes sign
855    /// under the key [`OwnedPayer::for_store`] derives for this data dir.
856    Owned(OwnedPayer),
857    /// A pool another owner funds, under the slot's credential.
858    Delegated(CredentialSlot),
859}
860
861impl PayerKeys {
862    const fn lane_payer(&self) -> LanePayer<'_> {
863        match self {
864            Self::Owned(payer) => LanePayer::Owned(payer),
865            Self::Delegated(slot) => LanePayer::Delegated(slot),
866        }
867    }
868}
869
870impl Payer {
871    /// The fetches of one `decdn get` run, paying from the keystore the flags
872    /// name, under `grant` when it is delegated.
873    pub(crate) fn keystore(grant: Option<CapabilityGrant>) -> Self {
874        Self {
875            key: PayerKey::Keystore {
876                grant,
877                loaded: tokio::sync::OnceCell::new(),
878            },
879            ledgers: None,
880            recovery: Arc::new(RecoveryGate::new()),
881            open_lock: tokio::sync::Mutex::new(()),
882        }
883    }
884
885    /// Fetches paying with `credential`, sharing its lanes' voucher ledgers.
886    ///
887    /// # Errors
888    ///
889    /// The capability's owner signature is malformed, or it names another
890    /// signer.
891    pub(crate) fn held(credential: Credential) -> anyhow::Result<Self> {
892        let slot = delegated_slot(&credential.grant, Arc::clone(&credential.signer))?;
893        Ok(Self {
894            key: PayerKey::Held {
895                signer: credential.signer,
896                slot,
897            },
898            ledgers: Some(LaneLedgers::new()),
899            recovery: Arc::new(RecoveryGate::new()),
900            open_lock: tokio::sync::Mutex::new(()),
901        })
902    }
903
904    /// Whether the fetch pays from a pool another owner funds.
905    const fn delegated(&self) -> bool {
906        match &self.key {
907            PayerKey::Keystore { grant, .. } => grant.is_some(),
908            PayerKey::Held { .. } => true,
909        }
910    }
911}
912
913/// Fetch the blob `args` names on `host`, paid by `payer`, with its verified
914/// bytes going to `sink`: resolve the holders, take the first size claim (the
915/// probe hint, or a header-only open when there is none), then fetch it
916/// through the acquire loop. `--output` names the destination only for
917/// [`Sink::Output`]. The fetch ends on done, a fault only the user can fix,
918/// or the stop policy: a terminal waits for Ctrl-C, a script gives up after
919/// 10 minutes without progress, and `--give-up-after-secs` overrides both.
920///
921/// The future is not `Send`: run it in place, or as a local task.
922///
923/// # Errors
924///
925/// [`decdn_client::GaveUp`] once the stop policy's no-progress limit passes,
926/// or the fault that ended the fetch.
927#[allow(clippy::too_many_lines)]
928pub(crate) async fn fetch_on(
929    host: &FetchHost,
930    payer: &Payer,
931    args: &BlobFetch,
932    sink: &mut Sink,
933    timings: &FetchTimings,
934) -> anyhow::Result<()> {
935    let FetchHost {
936        relays,
937        chain,
938        store,
939        endpoint,
940        connections,
941        health,
942        writes,
943        escrows,
944        token,
945    } = host;
946    let hash = args.hash;
947    let common = &args.common;
948    let streams = sink.streams(&args.output);
949    // The holders to start from: the explicit `--node-id`, or auto-discovery.
950    // Only a configuration fault ends the fetch here; with no holder yet, the
951    // first open discovers them. The round streams: the fetch starts at the
952    // first verified holder, and the holders that answer later join it.
953    let discovery = discovery_options(common)?;
954    let probe = ProbeOpts {
955        marks: Some(timings),
956        round: ProbeRound::Stream,
957    };
958    let resolved = holders_or_none(
959        resolve_target_node(
960            &discovery,
961            (&chain.config, &chain.site),
962            endpoint,
963            relays,
964            hash,
965            probe,
966        )
967        .await,
968    )?;
969    timings.mark(Mark::Resolved);
970
971    // Buyer key: the held delegate key, else the keystore, loaded after holder
972    // resolution so a configuration fault never
973    // prompts for a keystore password. Password from env, else
974    // `--keystore-password-file`, else TTY.
975    // A delegated payer signs under one credential slot across every fetch it
976    // pays for, both passes of each fetch included: a held key brings its slot,
977    // and a keystore payer builds its slot at the first load. A keystore payer
978    // on its own pool derives this data dir's lane key at the first load.
979    let (signer, keys) = match &payer.key {
980        PayerKey::Held { signer, slot } => (Arc::clone(signer), PayerKeys::Delegated(slot.clone())),
981        PayerKey::Keystore { grant, loaded } => loaded
982            .get_or_try_init(|| async {
983                let password = super::chain_ctx::read_keystore_password(
984                    &super::chain_ctx::password_sources(
985                        chain.keystore_password_file.as_deref(),
986                        PasswordUse::Unlock,
987                    ),
988                    "eth keystore password",
989                )?
990                .into_secret();
991                let signer = Arc::new(load_signer(&chain.keystore, &password)?);
992                let keys = match grant {
993                    Some(grant) => {
994                        PayerKeys::Delegated(delegated_slot(grant, Arc::clone(&signer))?)
995                    }
996                    None => PayerKeys::Owned(OwnedPayer::for_store(
997                        Arc::clone(&signer),
998                        store.as_ref(),
999                    )?),
1000                };
1001                anyhow::Ok((signer, keys))
1002            })
1003            .await?
1004            .clone(),
1005    };
1006    timings.mark(Mark::Unlocked);
1007    let self_address = signer.address();
1008
1009    let config = &chain.config;
1010    let rpc = provider::build_provider(&config.rpc_url, &signer)?;
1011    let contract = PaymentPool::new(config.payment_pool, rpc.clone());
1012    let voucher_dom = voucher_domain(config.chain_id, config.payment_pool);
1013    let slash_dom = slash_judge_domain(config.chain_id, config.slash_judge);
1014    // The pool's token is `PaymentPool.usdc()`, immutable per contract, so the
1015    // host reads it once. A repeat fetch already holds it in the persisted pool
1016    // row, so read it there and skip the eth_call; only a first-ever pool (no
1017    // row) pays the `usdc()` round-trip. The reuse branch of
1018    // `open_or_reuse_pool` already trusts this same `state.token`, so this only
1019    // makes the top-level value consistent with it.
1020    let token = *token
1021        .get_or_try_init(|| async {
1022            match cached_pool_token(store.as_ref(), self_address, config.deployment())? {
1023                Some(token) => anyhow::Ok(token),
1024                None => contract
1025                    .usdc()
1026                    .call()
1027                    .await
1028                    .map_err(|e| anyhow::Error::new(e).context("read PaymentPool.usdc()")),
1029            }
1030        })
1031        .await?;
1032
1033    let max_blob_bytes = common.max_blob_mb.saturating_mul(1024 * 1024);
1034    // The namespace routing hint (ADR 005 § Namespace routing): the URI's
1035    // `?ns=<id>` → big-endian `uint256`; absent => `NO_NAMESPACE` (best-effort
1036    // cache/DHT).
1037    let namespace_id = args
1038        .namespace
1039        .map_or(decdn_protocol::client::NO_NAMESPACE, |n| {
1040            alloy::primitives::U256::from(n).to_be_bytes()
1041        });
1042    // A node that accepts the connection and never answers is as dead as one that
1043    // stops mid-stream, so the same window answers both (#1134). The pull carries
1044    // no overall wall-clock cap: it completes for any blob size as long as the
1045    // upstream keeps feeding it bytes.
1046    let deadlines = PullDeadlines::new(STALL_WINDOW, STALL_WINDOW, 0)?;
1047
1048    // Each fetch keeps its own progress clock, so one that stalls gives up
1049    // alone while the host's other fetches carry on.
1050    let clock = Arc::new(ProgressClock::new());
1051    let stop = StopPolicy::new(
1052        std::io::IsTerminal::is_terminal(&std::io::stderr()),
1053        common.give_up_after(),
1054        clock,
1055    );
1056    let deposit = chain.deposit_policy(payer.delegated())?;
1057    // One funding recovery state for everything the payer pays for, both passes
1058    // included.
1059    let recovery = &payer.recovery;
1060    // A replacement the gate carries from an earlier fetch under this payer (an
1061    // entry URI's manifest fetch) names the pool this fetch's lanes reuse; the
1062    // gate still lists it for the command to name. A keystore payer runs its
1063    // fetches one after another, so no fetch is still on the closed pool. A
1064    // held payer funds nothing, so its gate never holds one.
1065    recovery.leave_carried();
1066    // Set once a funding recovery step's replacement ended the first pass of a
1067    // file fetch: the second pass then runs the remaining work against the new
1068    // pool, and resumes the `.partial`.
1069    let mut replaced: Option<PoolReplaced> = None;
1070    loop {
1071        // Per pass: the funding facts, the lanes and their handles all name the
1072        // pool the pass pays from.
1073        let funding = RunFunding::reporting_escrows_to(escrows.clone());
1074        // The shared pull/funding deps every lane borrows for the whole pass.
1075        let deps = LaneDeps {
1076            endpoint,
1077            store,
1078            contract: &contract,
1079            rpc: &rpc,
1080            slash_dom: &slash_dom,
1081            self_address,
1082            token,
1083            chain: config,
1084            site: &chain.site,
1085            deposit,
1086            namespace_id,
1087            max_rate_per_mb: common.max_rate_per_mb,
1088            max_blob_bytes,
1089            deadlines,
1090            connections,
1091            writes,
1092            funding: &funding,
1093            marks: Some(timings),
1094        };
1095        // The fetch's lanes build concurrently and every one opens or reuses the
1096        // one pool on-chain, so the open-or-reuse runs one lane at a time across
1097        // every fetch the payer pays for, as it does across a bundle pull's
1098        // entries.
1099        let sources = LaneSources::new(
1100            &deps,
1101            &discovery,
1102            relays,
1103            keys.lane_payer(),
1104            &voucher_dom,
1105            Some(&payer.open_lock),
1106            payer.ledgers.as_ref(),
1107            None,
1108            recovery,
1109        );
1110        let holders = sources.holders_from(&resolved);
1111        if replaced.is_none() {
1112            timings.set_holders_start(holders.len());
1113        }
1114        // A fetch dropped by Ctrl-C queues every lane's vouchers for recording too.
1115        let on_drop = SettleOnDrop::new(|| sources.persist_watermarks_detached());
1116        let result = async {
1117            // The first size claim: the probe's hint, or a header-only open.
1118            let claim = sources.first_claim(hash, holders, health, &stop).await?;
1119            if streams {
1120                return stream_out(
1121                    &deps,
1122                    &sources,
1123                    claim.holders,
1124                    Arc::clone(health),
1125                    hash,
1126                    claim.total_bytes,
1127                    stop.clone(),
1128                    (common.max_sources, timings),
1129                    &mut *sink,
1130                )
1131                .await;
1132            }
1133            download_to_file(
1134                &deps,
1135                &sources,
1136                claim.holders,
1137                Arc::clone(health),
1138                DownloadTarget {
1139                    hash,
1140                    total_bytes: claim.total_bytes,
1141                    dest: &args.output,
1142                    ranges: None,
1143                },
1144                &stop,
1145                (common.max_sources, timings),
1146            )
1147            .await
1148        }
1149        .await;
1150        on_drop.disarm();
1151        sources.persist_watermarks().await;
1152        if let Some(shortfall) = funding.shortfall() {
1153            eprintln!("warning: {shortfall}");
1154        }
1155        let result = result.map_err(|err| annotate(err, &sources.fault_context()));
1156        match next_pass(result, &mut replaced, streams) {
1157            Some(pass_result) => return pass_result,
1158            None => recovery.start_next_pass(),
1159        }
1160    }
1161}
1162
1163/// What a fetch pass that ended with `result` does next: `None` to run the
1164/// remaining work again against the pool a funding recovery step just opened,
1165/// or the command's result (ADR 003 § Funding recovery). `replaced` is the
1166/// replacement that ended an earlier pass, which a rerun sets.
1167///
1168/// Only a pass that a [`PoolReplaced`] ended runs again, and at most once: a
1169/// second replacement in one command ends it "funding needed". A replacement
1170/// that an untracked escrow ended the pass over survives in `result` only as
1171/// text, and runs nothing again. A stream (stdout or a writer) never runs
1172/// again, because the bytes it already wrote cannot be taken back; its
1173/// replacement ends the command with the new pool named. The command names
1174/// each closed pool as it ends, from the gate ([`print_reclaim_notes`]).
1175fn next_pass(
1176    result: anyhow::Result<()>,
1177    replaced: &mut Option<PoolReplaced>,
1178    streams: bool,
1179) -> Option<anyhow::Result<()>> {
1180    let now_replaced = result
1181        .as_ref()
1182        .err()
1183        .and_then(|err| err.downcast_ref::<PoolReplaced>().copied());
1184    if let Some(new) = now_replaced
1185        && replaced.is_none()
1186        && !streams
1187    {
1188        eprintln!("note: {new}");
1189        *replaced = Some(new);
1190        return None;
1191    }
1192    Some(result.map_err(|err| match (now_replaced, *replaced) {
1193        (Some(_), Some(_)) => err.context(
1194            "funding needed: the pool this command opened accepts no more funds either; run \
1195             the command again",
1196        ),
1197        (Some(new), None) => err.context(format!(
1198            "pool {} is now the current pool; run the command again to stream from it",
1199            new.opened
1200        )),
1201        _ => err,
1202    }))
1203}
1204
1205/// Name each pool in `replacements` that a funding recovery step closed
1206/// ([`decdn_client::RecoveryGate::replacements`]): it may still hold a
1207/// balance, and the note gives the command that reclaims it.
1208pub(crate) fn print_reclaim_notes(replacements: &[PoolReplaced]) {
1209    for replaced in replacements {
1210        eprintln!(
1211            "note: pool {} accepts no more funds and may still hold a balance; once its \
1212             dispute window ends, reclaim it with: decdn pool reclaim --pool {}",
1213            replaced.closed, replaced.closed
1214        );
1215    }
1216}
1217
1218/// Reconnect a delegated fetch that ended "funding needed" (ADR 003 §Funding
1219/// recovery) to the owner-side remedy: the delegate holds no wallet on this
1220/// pool, so it cannot `topUp`, raise its own cap, or mint itself a fresh
1221/// capability. A [`decdn_client::FundingNeeded::PublisherPool`] stop names the
1222/// pool owner's top-up: the capability still covers the fetch. A
1223/// [`decdn_client::FundingNeeded::NewCapability`] stop, any other
1224/// [`NoAffordableSource`] stop, or a funding voucher rejection
1225/// (`SpendingCapExhausted`, `CapabilityExpired`, `PoolExhausted`,
1226/// `SignerCapExhausted`) names a fresh capability for a new signer key,
1227/// because a registered signer's terms are write-once. A terminal `Underpaid`
1228/// (the resync budget ran out) gets its own next step. Any other error passes
1229/// through verbatim (a stall, a transport fault, a "no node will serve this",
1230/// or a `NotFound` already annotated by [`annotate_unbound_cache_miss`]).
1231pub(crate) fn annotate_delegated_exhaustion(err: anyhow::Error) -> anyhow::Error {
1232    use decdn_protocol::client::VoucherRejectReason;
1233
1234    let funding_rejection = err
1235        .downcast_ref::<UpstreamVoucherRejected>()
1236        .is_some_and(|rejected| {
1237            matches!(
1238                rejected.reason,
1239                VoucherRejectReason::SpendingCapExhausted
1240                    | VoucherRejectReason::CapabilityExpired
1241                    | VoucherRejectReason::PoolExhausted
1242                    | VoucherRejectReason::SignerCapExhausted
1243            )
1244        });
1245    let underpaid = err
1246        .downcast_ref::<UpstreamVoucherRejected>()
1247        .is_some_and(|rejected| rejected.reason == VoucherRejectReason::Underpaid);
1248    if let Some(&decdn_client::FundingNeeded::PublisherPool { pool }) = err.downcast_ref() {
1249        return err.context(format!(
1250            "ask the owner of pool {pool} to top it up: the capability still covers the fetch, \
1251             and a delegated client cannot top up a pool it does not own. A node refuses a \
1252             signer whose registered terms are spent the same way; once this signer key is \
1253             registered on-chain its terms are write-once, so the owner then issues a \
1254             capability for a new signer key"
1255        ));
1256    }
1257    if funding_rejection
1258        || err.downcast_ref::<NoAffordableSource>().is_some()
1259        || err.downcast_ref::<decdn_client::FundingNeeded>().is_some()
1260    {
1261        err.context(
1262            "funding needed: no provider serves this capability at its current funding. Ask \
1263             the pool owner to top up the pool or issue a fresh capability; once this signer \
1264             key is registered on-chain its terms are write-once, so the fresh capability must \
1265             name a new signer key (a delegated client cannot top up a pool it does not own)",
1266        )
1267    } else if underpaid {
1268        err.context(
1269            "the node kept refusing this lane's vouchers as underpaid after the resync attempts \
1270             ran out — retry the fetch; a lane with no accepted voucher yet means the signed \
1271             price is below the node's quote",
1272        )
1273    } else {
1274        err
1275    }
1276}
1277
1278/// Reconnect `err` to what the user can do about it: an unbound or
1279/// underfunded cache miss ([`annotate_unbound_cache_miss`]), and a pool no
1280/// provider's voucher fits — the owner-side remedy on a delegated pool
1281/// ([`annotate_delegated_exhaustion`]), else the top-up command.
1282pub(crate) fn annotate(err: anyhow::Error, at: &FaultContext) -> anyhow::Error {
1283    let err = match &at.first_lane {
1284        Some(ctx) => annotate_with(err, ctx),
1285        None => err,
1286    };
1287    if at.delegated {
1288        return annotate_delegated_exhaustion(err);
1289    }
1290    with_top_up_hint(err, at.pool_id)
1291}
1292
1293/// Name the top-up command on a self-owned pool no provider's voucher fits.
1294pub(crate) fn with_top_up_hint(err: anyhow::Error, pool_id: Option<PoolId>) -> anyhow::Error {
1295    if err.downcast_ref::<NoAffordableSource>().is_none() {
1296        return err;
1297    }
1298    let pool = pool_id.map_or_else(|| "<pool id>".to_string(), |id| id.to_string());
1299    err.context(format!(
1300        "top up with: decdn pool top-up --pool {pool} --amount-micro-usdc <MICRO_USDC>"
1301    ))
1302}
1303
1304/// [`annotate_unbound_cache_miss`] against the lane context `ctx`.
1305fn annotate_with(err: anyhow::Error, ctx: &Mutex<PoolContext>) -> anyhow::Error {
1306    match ctx.lock() {
1307        Ok(guard) => annotate_unbound_cache_miss(err, &guard),
1308        Err(_) => err,
1309    }
1310}
1311
1312/// Copy a [`decdn_client::VerifiedReader`]'s verified bytes to `sink`,
1313/// feeding `on_progress` the cumulative content bytes drained. Returns the total
1314/// drained. Only bao-verified bytes ever leave the reader, so `sink` (stdout or
1315/// a writer) is safe to pipe.
1316async fn copy_verified<R, W>(
1317    reader: &mut R,
1318    sink: &mut W,
1319    total_bytes: u64,
1320    on_progress: Option<&dyn Fn(u64, u64)>,
1321) -> anyhow::Result<u64>
1322where
1323    R: tokio::io::AsyncRead + Unpin,
1324    W: tokio::io::AsyncWrite + Unpin + ?Sized,
1325{
1326    use tokio::io::{AsyncReadExt, AsyncWriteExt};
1327
1328    let mut buf = vec![0u8; 256 * 1024];
1329    let mut drained: u64 = 0;
1330    loop {
1331        let n = reader
1332            .read(&mut buf)
1333            .await
1334            .map_err(|e| anyhow::anyhow!("read verified stream: {e}"))?;
1335        if n == 0 {
1336            break;
1337        }
1338        let chunk = buf
1339            .get(..n)
1340            .ok_or_else(|| anyhow::anyhow!("read returned more than the buffer holds"))?;
1341        sink.write_all(chunk)
1342            .await
1343            .map_err(|e| anyhow::anyhow!("write verified bytes: {e}"))?;
1344        drained = drained.saturating_add(u64::try_from(n).unwrap_or(u64::MAX));
1345        if let Some(cb) = on_progress {
1346            cb(drained, total_bytes);
1347        }
1348    }
1349    sink.flush()
1350        .await
1351        .map_err(|e| anyhow::anyhow!("flush verified bytes: {e}"))?;
1352    Ok(drained)
1353}
1354
1355/// Stream `hash`'s verified bytes in order to `sink` (stdout for
1356/// [`Sink::Output`]) via the [`Streamer`] consumption face (#1848 4b). Only
1357/// bao-verified bytes ever reach the pipe — safe to `| tar x` — the fetch is
1358/// consumption-paced (a slow or early-quit consumer stops the pull and the
1359/// spend within one read-ahead window), and a source that faults mid-stream
1360/// cools while another carries on, with no re-pull or re-pay.
1361///
1362/// `max_sources` is the front lane cap. For stdout, progress draws on stderr,
1363/// only when stderr is a terminal; stdout carries nothing but the verified
1364/// bytes. The caller persists each lane's voucher watermark after the stream.
1365#[allow(clippy::too_many_arguments)]
1366async fn stream_out<P>(
1367    deps: &LaneDeps<'_, P>,
1368    sources: &LaneSources<'_, P>,
1369    holders: Vec<Holder>,
1370    health: Arc<PeerHealth>,
1371    hash: [u8; 32],
1372    total_bytes: u64,
1373    stop: StopPolicy,
1374    (max_sources, timings): (usize, &FetchTimings),
1375    sink: &mut Sink,
1376) -> anyhow::Result<()>
1377where
1378    P: alloy::providers::Provider + Clone,
1379{
1380    // Read-ahead stays the default: the outstanding-spend bound for an
1381    // abandoned pipe.
1382    let pull_config = PullConfig {
1383        streamer_lane_cap: max_sources.max(1),
1384        ..PullConfig::new()
1385    };
1386    let funder = sources.funder();
1387
1388    // The fill store's `.partial` lives here for the stream's lifetime; a streamed
1389    // blob is not kept, so a temp dir (removed on drop) under the data dir is its
1390    // natural home.
1391    let scratch = tempfile::tempdir_in(&deps.site.data_dir)
1392        .map_err(|e| anyhow::anyhow!("open stream scratch dir: {e}"))?;
1393    let mut streamer = Streamer::new(sources, funder, scratch.path())
1394        .holders(holders)
1395        .health(health)
1396        .recovery(Arc::clone(sources.recovery()))
1397        .max_blob_bytes(deps.max_blob_bytes);
1398    if let Some(slot) = sources.credentials() {
1399        streamer = streamer.credentials(slot);
1400    }
1401    let (mut reader, mut drive) = streamer
1402        .open(hash, total_bytes, &pull_config, Arc::new(NoCache), stop)
1403        .await?;
1404
1405    // The bar draws on stderr, so it shows only when stderr is a terminal and
1406    // the bytes go to stdout; stdout carries the verified bytes either way.
1407    let bar = (matches!(sink, Sink::Output)
1408        && std::io::IsTerminal::is_terminal(&std::io::stderr()))
1409    .then(delivery_progress);
1410    // `copy_verified` reports after each write, so the first report with a
1411    // byte is the first byte on stdout.
1412    let on_progress = |received: u64, expected: u64| {
1413        if received > 0 {
1414            timings.mark(Mark::FirstByte);
1415        }
1416        if let Some((_, cb, _)) = &bar {
1417            cb(received, expected);
1418        }
1419    };
1420
1421    // The drive runs beside the copy, not inside its reads: a blocked stdout
1422    // must not stop an open paid leg from paying and draining. The read-ahead
1423    // window bounds how far it runs ahead of the pipe.
1424    let mut stdout;
1425    let out: &mut (dyn tokio::io::AsyncWrite + Send + Unpin) = match sink {
1426        Sink::Writer(writer) => writer.as_mut(),
1427        Sink::Output => {
1428            stdout = tokio::io::stdout();
1429            &mut stdout
1430        }
1431    };
1432    let copy_result = drive
1433        .alongside(copy_verified(
1434            &mut reader,
1435            out,
1436            total_bytes,
1437            Some(&on_progress),
1438        ))
1439        .await;
1440
1441    if let Some((bar, _, _)) = &bar {
1442        bar.finish_and_clear();
1443    }
1444    let copied = copy_result.map_err(|read_err| stream_error(read_err, drive.take_error()))?;
1445    // Every verified byte is out, but the fetch can still be awaiting a lane
1446    // build: one that may have escrowed USDC no record credits fails the stream,
1447    // as it fails a fetch to a file.
1448    drive.finish().await?;
1449    tracing::info!("streamed {copied} verified bytes");
1450    Ok(())
1451}
1452
1453/// The error a failed stdout stream ends with: the fetch's own typed error when
1454/// the drive ended with one (so a give-up, a fatal fault or a unanimous verdict
1455/// keeps its type for the exit code and the remedy hints), else the read or
1456/// write error itself.
1457fn stream_error(read_err: anyhow::Error, fetch_err: Option<anyhow::Error>) -> anyhow::Error {
1458    fetch_err.unwrap_or(read_err)
1459}
1460
1461/// Fetch `hash` to `output` via the [`Downloader`] consumption face (#1848 4c),
1462/// striping across `holders` with at most `max_sources` lanes at once (a
1463/// holder discovery adds later can take a free lane), and print the summary
1464/// line. The `.partial` beside `output` stays in place on a failure for a
1465/// later resume. The caller persists each lane's voucher watermark after the
1466/// fetch.
1467async fn download_to_file<P>(
1468    deps: &LaneDeps<'_, P>,
1469    sources: &LaneSources<'_, P>,
1470    holders: Vec<Holder>,
1471    health: Arc<PeerHealth>,
1472    target: DownloadTarget<'_>,
1473    stop: &StopPolicy,
1474    (max_sources, timings): (usize, &FetchTimings),
1475) -> anyhow::Result<()>
1476where
1477    P: alloy::providers::Provider + Clone,
1478{
1479    let (bar, on_bar, meter) = delivery_progress();
1480    let on_progress = |received: u64, expected: u64| {
1481        if received > 0 {
1482            timings.mark(Mark::FirstByte);
1483        }
1484        on_bar(received, expected);
1485    };
1486    let mut downloader = Downloader::new(sources, sources.funder())
1487        .holders(holders)
1488        .health(health)
1489        .recovery(Arc::clone(sources.recovery()))
1490        .max_lanes(max_sources)
1491        .max_blob_bytes(deps.max_blob_bytes);
1492    if let Some(slot) = sources.credentials() {
1493        downloader = downloader.credentials(slot);
1494    }
1495    let result =
1496        Box::pin(downloader.fetch_to_paths_until(&[target], Some(&on_progress), stop)).await;
1497    bar.finish_and_clear();
1498    result?;
1499    // The finished file holds exactly the proven size, which can differ from
1500    // the first claim the fetch started from.
1501    let proven = tokio::fs::metadata(target.dest)
1502        .await
1503        .map_or(target.total_bytes, |m| m.len());
1504    print_fetch_summary(proven, &meter, target.dest);
1505    Ok(())
1506}
1507
1508/// Whether `--output` names the stdout stream rather than a file. Exactly a
1509/// single dash (`-o -`) streams verified bytes to stdout; a file literally named
1510/// `-` is written by addressing it as `./-`, the usual convention (#1848 4b).
1511fn wants_stdout(output: &Path) -> bool {
1512    output == Path::new("-")
1513}
1514
1515/// Time constant for the smoothed delivery rate (seconds). Larger holds the
1516/// readout steadier across bursty arrival; smaller tracks real speed changes
1517/// faster. A few seconds keeps the number legible without lagging a genuine
1518/// slowdown for long.
1519const RATE_SMOOTHING_TAU_SECS: f64 = 3.0;
1520
1521/// Rolling delivery-rate estimate behind a progress bar's `{msg}`, and the
1522/// running totals the end-of-fetch summary reads back. A blob `get` feeds it
1523/// from the delivery bar; a bundle `get` feeds it from its bottom total bar, so
1524/// the rate there is whole-download throughput rather than one file's.
1525///
1526/// The bar's positions are cumulative verified **content** bytes — what the
1527/// [`decdn_client::ProgressCallback`] reports — so the rate is content
1528/// throughput (the interleaved bao proof nodes the wire also carries are not
1529/// counted).
1530///
1531/// Each `set_position` on the bar is bursty — many chunks land in one instant,
1532/// then a gap — so a naive `delta / dt` per callback spikes and collapses. This
1533/// holds a time-weighted exponential moving average instead: each sample folds
1534/// in with weight `1 - exp(-dt / tau)`, so the estimate is stable regardless of
1535/// how unevenly callbacks are spaced.
1536#[derive(Default)]
1537pub(crate) struct SpeedState {
1538    /// Instant and cumulative-byte position at the first observed sample. The
1539    /// summary measures elapsed and bytes-moved from here, so a resumed fetch
1540    /// (which starts at a non-zero `base_present`) reports only what this run
1541    /// actually transferred rather than dividing already-present bytes by this
1542    /// run's short window.
1543    started: Option<(Instant, u64)>,
1544    /// Instant and cumulative received content bytes at the previous sample.
1545    last: Option<(Instant, u64)>,
1546    /// Smoothed rate in bytes/sec. `None` until the second sample gives a `dt`.
1547    ewma_bps: Option<f64>,
1548}
1549
1550impl SpeedState {
1551    /// Fold one sample — cumulative content bytes `received` as of `now` — into
1552    /// the estimate and return the current smoothed rate in bytes/sec (`0.0`
1553    /// until a second sample gives a `dt`).
1554    pub(crate) fn observe(&mut self, now: Instant, received: u64) -> f64 {
1555        self.started.get_or_insert((now, received));
1556        if let Some((prev_at, prev_bytes)) = self.last {
1557            let dt = now.saturating_duration_since(prev_at).as_secs_f64();
1558            // Skip same-instant callbacks (a burst): they carry no usable `dt`
1559            // and would divide by ~zero into a spike.
1560            if dt > 0.0 {
1561                let inst = bytes_as_f64(received.saturating_sub(prev_bytes)) / dt;
1562                let alpha = 1.0 - (-dt / RATE_SMOOTHING_TAU_SECS).exp();
1563                // Seed from 0, not `inst`: on the first sample a tiny `dt` makes
1564                // `inst` huge, but `alpha * inst = (1 - exp(-dt/tau)) * (delta/dt)
1565                // -> delta/tau` as `dt -> 0`, so the estimate stays bounded
1566                // instead of spiking, then converges upward.
1567                let prev_bps = self.ewma_bps.unwrap_or(0.0);
1568                self.ewma_bps = Some(prev_bps + alpha * (inst - prev_bps));
1569            }
1570        }
1571        self.last = Some((now, received));
1572        self.ewma_bps.unwrap_or(0.0)
1573    }
1574}
1575
1576/// Widen a byte count to `f64` for rate arithmetic. A single transfer never
1577/// approaches 2^53 bytes, so the precision the cast lint guards against is not
1578/// at risk here.
1579#[expect(
1580    clippy::cast_precision_loss,
1581    reason = "byte counts stay far below f64's 2^53 exact-integer ceiling"
1582)]
1583pub(crate) const fn bytes_as_f64(n: u64) -> f64 {
1584    n as f64
1585}
1586
1587/// Format a non-negative bytes/sec rate as e.g. `12.3 MiB/s`. A rate at or
1588/// below zero (no data yet, or a stall) renders as `--`.
1589pub(crate) fn fmt_rate(bps: f64) -> String {
1590    if bps.is_finite() && bps >= 1.0 {
1591        // The rate is a small non-negative value; clamp before the cast so the
1592        // `HumanBytes` argument can never wrap or lose sign.
1593        #[expect(
1594            clippy::cast_possible_truncation,
1595            clippy::cast_sign_loss,
1596            reason = "bps is finite and >= 1.0 here; the cast cannot wrap or go negative"
1597        )]
1598        let whole = bps.min(bytes_as_f64(u64::MAX)) as u64;
1599        format!("{}/s", indicatif::HumanBytes(whole))
1600    } else {
1601        "--".to_string()
1602    }
1603}
1604
1605/// Estimate remaining time from the smoothed rate, formatted like `ETA 8s`.
1606/// Below a usable rate it reports `ETA --` rather than a divide-by-tiny blowup.
1607pub(crate) fn fmt_eta(remaining_bytes: u64, bps: f64) -> String {
1608    if bps >= 1.0 {
1609        // Clamp the projection so `from_secs_f64` never overflows `Duration`.
1610        let secs = (bytes_as_f64(remaining_bytes) / bps).clamp(0.0, 8.64e7);
1611        format!(
1612            "ETA {}",
1613            indicatif::HumanDuration(Duration::from_secs_f64(secs))
1614        )
1615    } else {
1616        "ETA --".to_string()
1617    }
1618}
1619
1620/// Reads the transfer duration and bytes moved this run back from a
1621/// [`SpeedState`] after the bar finishes, for the end-of-fetch summary.
1622#[derive(Clone)]
1623pub(crate) struct DeliveryMeter {
1624    state: Arc<Mutex<SpeedState>>,
1625}
1626
1627impl DeliveryMeter {
1628    /// `(elapsed across this run, content bytes this run transferred)`, or
1629    /// `None` if no byte ever arrived (a failure before delivery) or the lock is
1630    /// poisoned. Both are measured from the first observed sample, so a resumed
1631    /// fetch excludes the already-present `base_present` bytes it did not move.
1632    fn summary(&self) -> Option<(Duration, u64)> {
1633        let state = self.state.lock().ok()?;
1634        let (start_at, start_bytes) = state.started?;
1635        let (last_at, last_bytes) = state.last?;
1636        Some((
1637            last_at.saturating_duration_since(start_at),
1638            last_bytes.saturating_sub(start_bytes),
1639        ))
1640    }
1641}
1642
1643/// The blob `decdn get` delivery progress bar, the callback that drives it, and a
1644/// [`DeliveryMeter`] the caller reads after `finish_and_clear` for the terminal
1645/// summary (#1118). The callback advances the bar, folds each update into the
1646/// shared [`SpeedState`] so `{msg}` shows a steady rate/ETA, and mirrors the
1647/// percent into the terminal's tab bar ([`TabProgress`]) where supported; the
1648/// tab indicator is removed when the callback drops.
1649fn delivery_progress() -> (
1650    indicatif::ProgressBar,
1651    impl Fn(u64, u64) + 'static,
1652    DeliveryMeter,
1653) {
1654    let bar = new_progress_bar();
1655    // `ProgressBar` is `Arc`-backed, so the clone the callback owns drives the
1656    // same bar the caller clears.
1657    let (on_progress, meter) = bar_callback(bar.clone(), TabProgress::detect(true));
1658    (bar, on_progress, meter)
1659}
1660
1661/// Build the callback that drives `bar` (it sets the bar's length to the
1662/// fetch's current size bound whenever it changes, advances its position to
1663/// the cumulative verified content-byte count, and folds each update into a
1664/// [`SpeedState`] for the rate/ETA `{msg}`), and return it with the
1665/// [`DeliveryMeter`] the caller reads after the bar finishes. `tab`, when set,
1666/// shows the same percent in the terminal's tab bar.
1667///
1668/// The callback's `received`/`expected` are content bytes, per
1669/// [`decdn_client::ProgressCallback`]. `expected` is the size bound: a size
1670/// claim the fetch grows or shrinks until a leg proves the size. Both the bar
1671/// length and its position are in the same unit.
1672fn bar_callback(
1673    bar: indicatif::ProgressBar,
1674    tab: Option<Arc<TabProgress>>,
1675) -> (impl Fn(u64, u64) + 'static, DeliveryMeter) {
1676    // Setting the length takes a write lock, so it is set only when the bound
1677    // moves, not on every chunk in the hot receive loop. `u64::MAX` is never a
1678    // bound, so the first report always sets it.
1679    let length = std::sync::atomic::AtomicU64::new(u64::MAX);
1680    let state = Arc::new(Mutex::new(SpeedState::default()));
1681    let cb_state = Arc::clone(&state);
1682    let on_progress = move |received: u64, expected: u64| {
1683        if length.swap(expected, std::sync::atomic::Ordering::Relaxed) != expected {
1684            bar.set_length(expected);
1685        }
1686        bar.set_position(received);
1687        if let Some(tab) = &tab {
1688            tab.update(received, expected);
1689        }
1690
1691        // A poisoned lock only costs this one rate update; the bar still advances.
1692        if let Ok(mut s) = cb_state.lock() {
1693            let bps = s.observe(Instant::now(), received);
1694            bar.set_message(format!(
1695                "({}, {}) ",
1696                fmt_rate(bps),
1697                fmt_eta(expected.saturating_sub(received), bps)
1698            ));
1699        }
1700    };
1701    (on_progress, DeliveryMeter { state })
1702}
1703
1704/// Print the terminal line after a successful fetch: content bytes, and — when
1705/// the [`DeliveryMeter`] captured any delivery — the elapsed time and average
1706/// transfer rate over the content bytes this run moved. Falls back to the bare
1707/// byte/output line when nothing was delivered on this leg (e.g. a fully
1708/// resumed transfer that re-pulled no bytes).
1709fn print_fetch_summary(content_bytes: u64, meter: &DeliveryMeter, output: &Path) {
1710    match meter.summary() {
1711        Some((elapsed, moved_bytes)) if elapsed > Duration::ZERO && moved_bytes > 0 => {
1712            let avg_bps = bytes_as_f64(moved_bytes) / elapsed.as_secs_f64();
1713            println!(
1714                "fetched {content_bytes} bytes in {} (avg {}) -> {}",
1715                indicatif::HumanDuration(elapsed),
1716                fmt_rate(avg_bps),
1717                output.display()
1718            );
1719        }
1720        _ => println!("fetched {content_bytes} bytes -> {}", output.display()),
1721    }
1722}
1723
1724/// The credential slot of a delegated pull (`--capability`): the grant's
1725/// capability for the loaded delegate key. The CLI swaps in no new
1726/// credential, so the slot waits for none, and an exhausted capability ends
1727/// the command at once with a typed [`decdn_client::FundingNeeded`].
1728///
1729/// # Errors
1730///
1731/// The grant's owner signature is malformed, or the loaded key is not the
1732/// signer the capability authorizes: the client can sign vouchers only as
1733/// that key, and a node would refuse every one as `WrongSigner`.
1734pub(crate) fn delegated_slot(
1735    grant: &CapabilityGrant,
1736    signer: Arc<alloy::signers::local::PrivateKeySigner>,
1737) -> anyhow::Result<CredentialSlot> {
1738    let capability = grant.to_signed_capability().map_err(|e| {
1739        anyhow::anyhow!("capability token carries a malformed owner signature: {e}")
1740    })?;
1741    let slot = CredentialSlot::new(signer, capability).map_err(|mismatch| {
1742        let (authorized, loaded) = (mismatch.capability_signer, mismatch.key);
1743        anyhow::anyhow!(
1744            "this capability authorizes signer {authorized}, but the loaded key is {loaded} — \
1745             load the delegate keystore the pool owner assigned (--keystore / \
1746             blockchain.eth_keystore), or ask for a capability issued to {loaded}"
1747        )
1748    })?;
1749    Ok(slot.with_swap_wait(std::time::Duration::ZERO))
1750}
1751
1752/// Resolve the delegated `--capability`/`--capability-file` token into a
1753/// [`CapabilityGrant`], and, when one is present, zero the working deposit
1754/// in `chain`: a delegate owns no pool it could `topUp`, so its funding
1755/// recovery step has nothing to add ([`decdn_client::funding::PoolFunder`]). `None` selects the
1756/// unchanged self-owned pool path. Shared by the blob and bundle routes of `decdn get`.
1757///
1758/// # Errors
1759///
1760/// When `--capability-file` cannot be read, or the token fails to decode.
1761pub(crate) fn resolve_delegation_grant(
1762    common: &cli::ClientFetchArgs,
1763    chain: &mut ResolvedChain,
1764) -> anyhow::Result<Option<CapabilityGrant>> {
1765    let grant = common
1766        .resolve_capability_token()?
1767        .map(|token| CapabilityGrant::from_token(&token))
1768        .transpose()
1769        .map_err(|e| anyhow::anyhow!("invalid --capability token: {e}"))?;
1770    if grant.is_some() {
1771        chain.working_deposit = U256::ZERO;
1772    }
1773    Ok(grant)
1774}
1775
1776#[cfg(test)]
1777#[allow(
1778    clippy::unwrap_used,
1779    clippy::expect_used,
1780    clippy::indexing_slicing,
1781    clippy::panic
1782)]
1783mod tests;