decdn_common/cli/fetch.rs
1//! `decdn fetch` — the standalone client-side paid pull of a single
2//! content-addressed blob over `cdn/client/v1` (issues #391, #940).
3//!
4//! The paying sibling of [`super::probe::ProbeArgs`]: a one-shot client command
5//! that dials a node by explicit `--node-id`/`--addr`/`--relay-url` (or
6//! auto-discovers one, #936) and pays per-MB from the caller's own
7//! `PaymentPool` deposit. The pool is **auto-opened and reused**: `fetch`
8//! looks up the caller's live pool in its persistent buyer-pool store, resuming
9//! its per-`(signer, provider)` lane watermark for the chosen provider; if none
10//! exists it opens (and funds) one on-chain and records it. One pool fans out
11//! to every provider the caller pays (ADR 003) — there is no per-provider open.
12//!
13//! The on-chain coordinates (RPC, contract addresses, chain id, keystore, data
14//! dir) resolve **flag > `[blockchain]`/`[identity]` config > default**, so a
15//! configured client runs `decdn fetch --node-id … --addr … --provider-address
16//! … --hash … -o …` with no chain flags. Fields are kept as `String`/`Option`
17//! (mirroring [`super::probe::ProbeArgs`]); the `cli` binary parses addresses
18//! and merges the config.
19//!
20//! The network/chain/target/limit flags live in [`ClientFetchArgs`], shared
21//! (via `#[command(flatten)]`) with `decdn bundle pull` (#391) so both client
22//! commands resolve their coordinates and select a node identically.
23
24use std::net::SocketAddr;
25use std::path::PathBuf;
26use std::time::Duration;
27
28use clap::Args;
29
30/// Validate `--region` at parse time, returning the NORMALIZED code so the
31/// value that reaches discovery is already canonical.
32///
33/// Only the flag goes through this — a `[identity] region` from the config file
34/// does not, so `select_candidates` still parses defensively. Rejecting there
35/// too would turn a stale config key into a hard failure of every fetch, which
36/// is a bigger behavior change than this fix is for.
37///
38/// The error names the accepted set rather than echoing the input back, matching
39/// `decdn_protocol::InvalidRegion` (a region code reaches log fields and metric
40/// labels).
41fn parse_region_flag(raw: &str) -> Result<String, String> {
42 decdn_protocol::Region::parse(raw)
43 .map(|r| r.to_string())
44 .ok_or_else(|| decdn_protocol::InvalidRegion.to_string())
45}
46
47/// The network, chain, target, and per-blob-limit flags shared by the paid
48/// client commands (`decdn fetch` and `decdn bundle pull`, #391). Flattened into
49/// each command's args so the resolution (`flag > config > default`) and
50/// node-selection logic have one definition.
51///
52/// Reachability and the required chain coordinates are validated at runtime
53/// rather than by clap, because the relay (`network.relay_urls`, #935) and the
54/// chain coordinates (`[blockchain]`) can come from config, which clap cannot
55/// see. `--node-id` requires `--provider-address` at the clap layer; the reverse
56/// pairing (`--provider-address` needs `--node-id`) is enforced in `validate()`.
57/// `--addr` requires `--node-id` at the clap layer.
58#[derive(Debug, Clone, Args)]
59pub struct ClientFetchArgs {
60 /// Target node id (iroh `EndpointId`, z-base32). Omit it to auto-discover:
61 /// the active node set is read from `CapacityBond`, the region-nearest
62 /// candidates probed, and one that holds the blob picked (#936). When set,
63 /// `--provider-address` is required (they pair).
64 #[arg(long, value_name = "ID", requires = "provider_address")]
65 pub node_id: Option<String>,
66
67 /// Ignore the persisted peer store for selection this run and re-probe/discover
68 /// afresh; the store is still updated from what this fetch learns.
69 #[arg(long, action = clap::ArgAction::SetTrue)]
70 pub rediscover: bool,
71
72 /// Direct socket address of the target node (e.g. `127.0.0.1:4433`). Only
73 /// meaningful with an explicit `--node-id`; auto-discovery resolves the
74 /// address itself, so this requires `--node-id`.
75 #[arg(long, value_name = "HOST:PORT", requires = "node_id")]
76 pub addr: Option<SocketAddr>,
77
78 /// iroh relay URL to use for discovery-based resolution. Overrides
79 /// `network.relay_urls` from config when set (#935).
80 #[arg(long, value_name = "URL")]
81 pub relay_url: Option<String>,
82
83 /// The delivering node's Ethereum address (0x-prefixed hex). The caller's
84 /// pool lane for this provider is opened/reused against it, and the
85 /// response `slash_sig` must recover to it (ADR 014 §1); a mismatch aborts
86 /// the pull. Omit it when auto-discovering (no `--node-id`): it is derived
87 /// from the selected node's registry entry.
88 ///
89 /// Requires `--node-id` (enforced in `validate()`, not by clap) — on its
90 /// own it names no node to dial.
91 #[arg(long, value_name = "0xADDR")]
92 pub provider_address: Option<String>,
93
94 /// JSON-RPC endpoint for on-chain pool open. Overrides
95 /// `blockchain.rpc_url` from config.
96 #[arg(long, value_name = "URL")]
97 pub rpc_url: Option<String>,
98
99 /// `PaymentPool` contract address (0x hex) — the voucher EIP-712
100 /// `verifyingContract` and the `openPool` target. Overrides
101 /// `blockchain.payment_pool_address`.
102 #[arg(long, value_name = "0xADDR")]
103 pub payment_pool_address: Option<String>,
104
105 /// `SlashJudge` contract address (0x hex) — the `slash_sig` EIP-712
106 /// `verifyingContract`. Overrides `blockchain.slash_judge_address`.
107 #[arg(long, value_name = "0xADDR")]
108 pub slash_judge_address: Option<String>,
109
110 /// `CapacityBond` contract address (0x hex) — the active-node registry read
111 /// when auto-discovering (no `--node-id`). Overrides
112 /// `blockchain.capacity_bond_address`. Unused on the explicit-node path.
113 #[arg(long, value_name = "0xADDR")]
114 pub capacity_bond_address: Option<String>,
115
116 /// Client region used to prefer same-region nodes when auto-discovering
117 /// (#936). Matched against each node's on-chain self-attested region
118 /// (`CapacityBond` `regionHint`, ISO 3166-1 alpha-2 per ADR 030 — e.g. `US`,
119 /// `DE`); case and surrounding whitespace are normalized away, so any
120 /// spelling of a valid code works. Overrides `identity.region`; when unset
121 /// (and no config region), discovery skips the region-first ordering.
122 ///
123 /// Rejected at parse time if it is not an accepted code, so a request for
124 /// locality never silently degrades to round-robin selection with the flag
125 /// discarded.
126 #[arg(long, value_name = "REGION", value_parser = parse_region_flag)]
127 pub region: Option<String>,
128
129 /// Latency-driven proxy warming (#1174, ADR 037): on a cache miss whose only
130 /// holders are distant, route the paid request through a nearer bonded
131 /// non-holder so it caches the blob and becomes the first regional copy.
132 /// Engages only when it strictly helps (see `--proxy-warming-rtt-threshold-ms`
133 /// / `--proxy-warming-margin-ms`); otherwise routes direct.
134 ///
135 /// **Defaults on (ADR 037 § Fallback).** A chosen proxy that declines or
136 /// stalls is not a regression: it cools, and the fetch carries on from the
137 /// other candidates and the direct holder over the SAME shared pool (each
138 /// provider is its own lane, so no new on-chain deposit is escrowed),
139 /// resuming the partial it already has. The only client-observable cost is a bounded
140 /// one-request latency premium the first time a locale warms a given blob.
141 /// Pass `--proxy-warming false` to route direct and never warm.
142 #[arg(long, value_name = "BOOL", default_value_t = true, action = clap::ArgAction::Set)]
143 pub proxy_warming: bool,
144
145 /// Proxy warming engages only when the best holder's RTT exceeds this many
146 /// milliseconds (the holders are all distant). Below it, route direct.
147 #[arg(long, value_name = "MS", default_value_t = 150)]
148 pub proxy_warming_rtt_threshold_ms: u64,
149
150 /// A candidate proxy must beat the best holder's RTT by at least this many
151 /// milliseconds to be chosen — proxy warming is never a gamble.
152 #[arg(long, value_name = "MS", default_value_t = 30)]
153 pub proxy_warming_margin_ms: u64,
154
155 /// Cap on concurrently-used holders (ADR 039): a fetch stripes a blob
156 /// across at most this many holders at once. Enough to saturate typical
157 /// downlinks without paying for marginal lanes. It also caps how many
158 /// holders a `bundle pull` range-dedup entry stripes its ranges across.
159 #[arg(long, value_name = "N", default_value_t = 4)]
160 pub max_sources: usize,
161
162 /// Stop after this many seconds with no verified progress. Without it, a
163 /// terminal waits until Ctrl-C and a script stops after 10 minutes.
164 #[arg(long, value_name = "SECS", value_parser = clap::value_parser!(u64).range(1..))]
165 pub give_up_after_secs: Option<u64>,
166
167 /// EIP-712 `chainId` for both domains. Overrides `blockchain.chain_id`;
168 /// defaults to Arbitrum Sepolia.
169 #[arg(long, value_name = "ID")]
170 pub chain_id: Option<u64>,
171
172 /// Path to the buyer's Ethereum keystore that signs vouchers and the
173 /// `openChannel` tx. Overrides `blockchain.eth_keystore`; when unset defaults
174 /// to `keystore.json` under the (client-scoped) data dir. Password from
175 /// `$DECDN_KEYSTORE_PASSWORD`, else `--keystore-password-file`, else a TTY
176 /// prompt.
177 #[arg(long, value_name = "PATH")]
178 pub keystore: Option<PathBuf>,
179
180 /// File whose contents are the keystore password. Consulted after the
181 /// `DECDN_KEYSTORE_PASSWORD` env var and before an interactive prompt on a
182 /// TTY. A single trailing newline is stripped; a file that is empty after
183 /// that strip is a deliberate empty password, not an absent source. A path
184 /// that does not exist falls through; one that exists but cannot be read is
185 /// an error.
186 #[arg(long, value_name = "PATH", env = "DECDN_KEYSTORE_PASSWORD_FILE")]
187 pub keystore_password_file: Option<PathBuf>,
188
189 /// Data dir holding the persistent buyer-channel store (and the default
190 /// keystore). Overrides `identity.data_dir`; when unset defaults to the
191 /// client-scoped `~/.decdn/client` (not the node-shaped `~/.decdn`).
192 #[arg(long, value_name = "PATH")]
193 pub data_dir: Option<PathBuf>,
194
195 /// Deposit (`µUSDC`) to escrow when OPENING a pool, and the target each
196 /// top-up refills the pool toward once it has served verified bytes.
197 /// Overrides `blockchain.buyer_working_deposit_micro_usdc`; default
198 /// 10 USDC. Must be nonzero (openPool reverts on a zero deposit).
199 #[arg(long, value_name = "MICRO_USDC")]
200 pub working_deposit_micro_usdc: Option<u64>,
201
202 /// Client-side size ceiling: refuse a delivery whose claimed total size
203 /// exceeds this many MiB before pulling any bytes. Defaults to 1 TiB
204 /// (1,048,576 MiB) — far above any real blob, so `decdn fetch` and
205 /// `bundle pull` download normal content by its hash without tuning, yet low
206 /// enough to bound what a provider's over-claimed `total_bytes` can cost
207 /// locally. Lower it as a tighter budget guard, or set `0` to disable the
208 /// ceiling entirely. For `bundle pull` this is the per-entry ceiling.
209 ///
210 /// The cost it bounds is on disk, not memory: the streaming pull buffers only
211 /// received bytes in RAM, so RAM stays bounded whatever the claim, and a
212 /// provider that over-claims `total_bytes` fails bao verification against the
213 /// requested hash regardless of this value. But the ranged store pre-sizes a
214 /// sparse bao outboard sidecar (~0.4% of `total_bytes`) via `set_len` at
215 /// creation, so without a ceiling an absurd claim could surface a local disk
216 /// or quota error before verification rejects it. This gate caps that pre-size
217 /// (1 TiB → ~4 GiB) by refusing the claim first.
218 #[arg(long, value_name = "MB", default_value_t = 1_048_576)]
219 pub max_blob_mb: u64,
220
221 /// Refuse a provider that quotes a per-MB rate above this many USDC base units
222 /// **before** paying any voucher (#1375). Guards against a provider that quotes
223 /// low on the probe then high on the stream. `0` (the default) = no ceiling.
224 /// For `bundle pull` this applies per entry. Note: unlike the node's automatic
225 /// node-to-node pull (which also caps at the candidate's own probe rate), the
226 /// CLI applies only this absolute value — set it to opt into buyer rate
227 /// protection on the client path.
228 #[arg(long, value_name = "UNITS", default_value_t = 0)]
229 pub max_rate_per_mb: u64,
230
231 /// Hard cap on the total wall-clock time of the `CapacityBond` registry read
232 /// (discovery), in milliseconds. Defaults to 1 hour.
233 ///
234 /// It does NOT bound the blob fetch: a progressing pull carries no overall wall-clock
235 /// cap, so a healthy transfer of any size completes as long as the upstream keeps
236 /// feeding it bytes (#1134). `--give-up-after-secs` bounds a fetch that stops making
237 /// progress.
238 ///
239 /// Exceeding this budget on the registry read (including its retry schedule) is treated
240 /// as a read failure, so a cached peer list is still used if one is present. It does NOT
241 /// bound the probe fan-out that ranks candidates either — probing is bounded per node by
242 /// the probe timeout, not by this flag.
243 #[arg(long, value_name = "MS", default_value_t = 3_600_000, value_parser = clap::value_parser!(u64).range(1..))]
244 pub timeout_ms: u64,
245
246 /// Adopt a delegated pool + capability instead of opening/reusing the
247 /// caller's OWN pool. Pass the `dcap1:` token printed by `decdn pool assign`:
248 /// it names the pool and authorizes THIS client's loaded key to spend against
249 /// it up to the owner-set cap. The loaded keystore MUST be the delegate signer
250 /// the token authorizes (a mismatch aborts). The delegate does not own the
251 /// pool, so its funding recovery step cannot top up: an exhausted cap or a
252 /// drained pool needs the owner to top up or issue a capability for a new
253 /// signer key.
254 /// Mutually exclusive with `--capability-file`.
255 #[arg(long, value_name = "TOKEN", conflicts_with = "capability_file")]
256 pub capability: Option<String>,
257
258 /// Read the `dcap1:` capability token from a file (its whole trimmed
259 /// contents) rather than the command line, keeping it out of shell history
260 /// and the process table. Mutually exclusive with `--capability`.
261 #[arg(long, value_name = "PATH", conflicts_with = "capability")]
262 pub capability_file: Option<PathBuf>,
263}
264
265impl ClientFetchArgs {
266 /// Wall-clock cap on the `CapacityBond` registry read, including its ADR 012 retry
267 /// schedule (#1349): the `--timeout-ms` value.
268 ///
269 /// A separate budget, not a shared one: sharing would make `bundle pull`'s per-entry
270 /// fetch depend on how long the read took.
271 ///
272 /// # What this does NOT bound
273 ///
274 /// The probe fan-out. `fetch` probes `SELECT_K` candidates after the read, and
275 /// `bundle pull` probes per entry; neither is inside this budget. Named here because
276 /// the obvious reading of "discovery" includes probing, and it does not.
277 #[must_use]
278 pub const fn discovery_cap(&self) -> Duration {
279 Duration::from_millis(self.timeout_ms)
280 }
281
282 /// The `--give-up-after-secs` override of the no-progress limit, or `None`
283 /// for the default (a terminal waits, a script stops after 10 minutes).
284 #[must_use]
285 pub const fn give_up_after(&self) -> Option<Duration> {
286 match self.give_up_after_secs {
287 Some(secs) => Some(Duration::from_secs(secs)),
288 None => None,
289 }
290 }
291
292 /// Reject a flag combination clap cannot express.
293 ///
294 /// # Errors
295 ///
296 /// When `--provider-address` is set without `--node-id`.
297 pub fn validate(&self) -> anyhow::Result<()> {
298 // `--provider-address` is the delivering node's address on the
299 // explicit-node path, where it pairs with `--node-id`. Standing alone it
300 // names no node to dial, so it is meaningless.
301 anyhow::ensure!(
302 self.provider_address.is_none() || self.node_id.is_some(),
303 "--provider-address requires --node-id (the delivering node to dial); alone it \
304 names no node",
305 );
306 Ok(())
307 }
308
309 /// The `dcap1:` capability token this fetch adopts, if any: the inline
310 /// `--capability` value, or the trimmed contents of `--capability-file`.
311 /// `None` selects the self-owned pool path (unchanged). Clap's
312 /// `conflicts_with` guarantees at most one of the two is set.
313 ///
314 /// # Errors
315 ///
316 /// When `--capability-file` is set but the file cannot be read.
317 pub fn resolve_capability_token(&self) -> anyhow::Result<Option<String>> {
318 if let Some(token) = &self.capability {
319 return Ok(Some(token.clone()));
320 }
321 match &self.capability_file {
322 Some(path) => {
323 let raw = std::fs::read_to_string(path).map_err(|e| {
324 anyhow::anyhow!("read --capability-file {}: {e}", path.display())
325 })?;
326 Ok(Some(raw.trim().to_string()))
327 }
328 None => Ok(None),
329 }
330 }
331}
332
333/// Arguments for `decdn fetch` — one blob to a file, atop [`ClientFetchArgs`].
334#[derive(Debug, Clone, Args)]
335pub struct FetchArgs {
336 /// BLAKE3 hash of the blob to fetch: 64 hex chars (optional `0x` or `b3:`
337 /// prefix — the `b3:` form is what bundle manifests carry).
338 #[arg(long, value_name = "HASH")]
339 pub hash: String,
340
341 /// Destination path for the fetched blob. Written atomically
342 /// (temp-in-dir then rename); an existing file is replaced.
343 #[arg(short = 'o', long, value_name = "PATH")]
344 pub output: PathBuf,
345
346 /// Namespace the content is published under (ADR 002 § Retrieval by
347 /// namespace). When set, the serving node routes a cache-miss origin pull to
348 /// that namespace's DAO-authorized origins. Absent => no namespace: no
349 /// routing to a namespace's authorized origin set — served from cache, from
350 /// peers, or from the serving node's OWN configured origin, which stays a
351 /// serve target either way (`namespace_id` is a routing hint, not a serve
352 /// gate — ADR 002).
353 #[arg(long, value_name = "ID", value_parser = parse_fetch_namespace_id)]
354 pub namespace: Option<u64>,
355
356 /// Retrieval flags shared with `decdn bundle pull`: peer discovery, chain
357 /// and payment-pool coordinates, the source cap and the give-up limit.
358 #[command(flatten)]
359 pub common: ClientFetchArgs,
360}
361
362/// Parse a `--namespace` id (rejecting the reserved `0` — namespace 0 is "no
363/// namespace"; omit the flag for best-effort retrieval). Shared by `decdn fetch`
364/// and `decdn bundle pull`.
365pub(crate) fn parse_fetch_namespace_id(s: &str) -> Result<u64, String> {
366 let id: u64 = s
367 .parse()
368 .map_err(|_| format!("invalid namespace id: {s}"))?;
369 if id == 0 {
370 return Err("namespace id must be >= 1 (omit --namespace for no namespace)".to_string());
371 }
372 Ok(id)
373}
374
375#[cfg(test)]
376#[allow(
377 clippy::unwrap_used,
378 clippy::expect_used,
379 clippy::indexing_slicing,
380 clippy::panic
381)]
382mod tests;