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