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 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;