Skip to main content

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. On the buyer's own pool this key
169    /// owns the pool and signs its transactions, and the vouchers sign under a
170    /// key derived from it and the data dir, so each data dir pays on its own
171    /// lanes. Under `--capability` it is the delegate key, which signs the
172    /// vouchers itself. Overrides `blockchain.eth_keystore`; when unset defaults
173    /// to `keystore.json` under the (client-scoped) data dir. Password from
174    /// `$DECDN_KEYSTORE_PASSWORD`, else `--keystore-password-file`, else a TTY
175    /// prompt.
176    #[arg(long, value_name = "PATH")]
177    pub keystore: Option<PathBuf>,
178
179    /// File whose contents are the keystore password. Consulted after the
180    /// `DECDN_KEYSTORE_PASSWORD` env var and before an interactive prompt on a
181    /// TTY. A single trailing newline is stripped; a file that is empty after
182    /// that strip is a deliberate empty password, not an absent source. A path
183    /// that does not exist falls through; one that exists but cannot be read is
184    /// an error.
185    #[arg(long, value_name = "PATH", env = "DECDN_KEYSTORE_PASSWORD_FILE")]
186    pub keystore_password_file: Option<PathBuf>,
187
188    /// Data dir holding the persistent buyer pool store (and the default
189    /// keystore). Overrides `identity.data_dir`; when unset defaults to the
190    /// client-scoped `~/.decdn/client` (not the node-shaped `~/.decdn`).
191    #[arg(long, value_name = "PATH")]
192    pub data_dir: Option<PathBuf>,
193
194    /// Deposit (`µUSDC`) to escrow when OPENING a pool, and the target each
195    /// top-up refills the pool toward once it has served verified bytes.
196    /// Overrides `blockchain.buyer_working_deposit_micro_usdc`; default
197    /// 10 USDC. Must be nonzero (openPool reverts on a zero deposit).
198    #[arg(long, value_name = "MICRO_USDC")]
199    pub working_deposit_micro_usdc: Option<u64>,
200
201    /// Client-side cap on the bytes of one blob this fetch holds, in MiB. A
202    /// range anywhere in a larger blob is fetched while the bytes held fit; a
203    /// fetch that would hold more ends with `BlobTooLarge`. Defaults to 1 TiB
204    /// (1,048,576 MiB) — far above any real blob, so `decdn get` downloads
205    /// normal content by its hash without tuning, yet low enough to bound what a
206    /// provider's over-claimed `total_bytes` can cost locally. Lower it as a
207    /// tighter budget guard, or set `0` to disable the cap entirely. For a
208    /// bundle this is the per-entry cap.
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 clamping the claim to the furthest requested offset
218    /// plus this cap first.
219    #[arg(long, value_name = "MB", default_value_t = 1_048_576)]
220    pub max_blob_mb: u64,
221
222    /// Refuse a provider that quotes a per-MB rate above this many USDC base units
223    /// **before** paying any voucher (#1375). Guards against a provider that quotes
224    /// low on the probe then high on the stream. `0` (the default) = no ceiling.
225    /// For a bundle this applies per entry. Note: unlike the node's automatic
226    /// node-to-node pull (which also caps at the candidate's own probe rate), the
227    /// CLI applies only this absolute value — set it to opt into buyer rate
228    /// protection on the client path.
229    #[arg(long, value_name = "UNITS", default_value_t = 0)]
230    pub max_rate_per_mb: u64,
231
232    /// Hard cap on the total wall-clock time of the `CapacityBond` registry read
233    /// (discovery), in milliseconds. Defaults to 1 hour.
234    ///
235    /// It does NOT bound the blob fetch: a progressing pull carries no overall wall-clock
236    /// cap, so a healthy transfer of any size completes as long as the upstream keeps
237    /// feeding it bytes (#1134). `--give-up-after-secs` bounds a fetch that stops making
238    /// progress.
239    ///
240    /// Exceeding this budget on the registry read (including its retry schedule) is treated
241    /// as a read failure, so a cached peer list is still used if one is present. It does NOT
242    /// bound the probe fan-out that ranks candidates either — probing is bounded per node by
243    /// the probe timeout, not by this flag.
244    #[arg(long, value_name = "MS", default_value_t = 3_600_000, value_parser = clap::value_parser!(u64).range(1..))]
245    pub timeout_ms: u64,
246
247    /// Adopt a delegated pool + capability instead of opening/reusing the
248    /// caller's OWN pool. Pass the `dcap1:` token printed by `decdn pool assign`:
249    /// it names the pool and authorizes THIS client's loaded key to spend against
250    /// it up to the owner-set cap. The loaded keystore MUST be the delegate signer
251    /// the token authorizes (a mismatch aborts). The delegate does not own the
252    /// pool, so its funding recovery step cannot top up: an exhausted cap or a
253    /// drained pool needs the owner to top up or issue a capability for a new
254    /// signer key.
255    /// Mutually exclusive with `--capability-file`.
256    #[arg(long, value_name = "TOKEN", conflicts_with = "capability_file")]
257    pub capability: Option<String>,
258
259    /// Read the `dcap1:` capability token from a file (its whole trimmed
260    /// contents) rather than the command line, keeping it out of shell history
261    /// and the process table. Mutually exclusive with `--capability`.
262    #[arg(long, value_name = "PATH", conflicts_with = "capability")]
263    pub capability_file: Option<PathBuf>,
264}
265
266impl ClientFetchArgs {
267    /// Wall-clock cap on the `CapacityBond` registry read, including its ADR 012 retry
268    /// schedule (#1349): the `--timeout-ms` value.
269    ///
270    /// A separate budget, not a shared one: sharing would make a bundle's per-entry
271    /// fetch depend on how long the read took.
272    ///
273    /// # What this does NOT bound
274    ///
275    /// The probe fan-out. A blob fetch probes `SELECT_K` candidates after the read,
276    /// and a bundle pull probes per entry; neither is inside this budget. Named here because
277    /// the obvious reading of "discovery" includes probing, and it does not.
278    #[must_use]
279    pub const fn discovery_cap(&self) -> Duration {
280        Duration::from_millis(self.timeout_ms)
281    }
282
283    /// The `--give-up-after-secs` override of the no-progress limit, or `None`
284    /// for the default (a terminal waits, a script stops after 10 minutes).
285    #[must_use]
286    pub const fn give_up_after(&self) -> Option<Duration> {
287        match self.give_up_after_secs {
288            Some(secs) => Some(Duration::from_secs(secs)),
289            None => None,
290        }
291    }
292
293    /// Reject a flag combination clap cannot express.
294    ///
295    /// # Errors
296    ///
297    /// When `--provider-address` is set without `--node-id`.
298    pub fn validate(&self) -> anyhow::Result<()> {
299        // `--provider-address` is the delivering node's address on the
300        // explicit-node path, where it pairs with `--node-id`. Standing alone it
301        // names no node to dial, so it is meaningless.
302        anyhow::ensure!(
303            self.provider_address.is_none() || self.node_id.is_some(),
304            "--provider-address requires --node-id (the delivering node to dial); alone it \
305             names no node",
306        );
307        Ok(())
308    }
309
310    /// The `dcap1:` capability token this fetch adopts, if any: the inline
311    /// `--capability` value, or the trimmed contents of `--capability-file`.
312    /// `None` selects the self-owned pool path (unchanged). Clap's
313    /// `conflicts_with` guarantees at most one of the two is set.
314    ///
315    /// # Errors
316    ///
317    /// When `--capability-file` is set but the file cannot be read.
318    pub fn resolve_capability_token(&self) -> anyhow::Result<Option<String>> {
319        if let Some(token) = &self.capability {
320            return Ok(Some(token.clone()));
321        }
322        match &self.capability_file {
323            Some(path) => {
324                let raw = std::fs::read_to_string(path).map_err(|e| {
325                    anyhow::anyhow!("read --capability-file {}: {e}", path.display())
326                })?;
327                Ok(Some(raw.trim().to_string()))
328            }
329            None => Ok(None),
330        }
331    }
332}
333
334#[cfg(test)]
335#[allow(
336    clippy::unwrap_used,
337    clippy::expect_used,
338    clippy::indexing_slicing,
339    clippy::panic
340)]
341mod tests;