Skip to main content

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;