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