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;