Skip to main content

decdn_client/
bundle.rs

1//! Pull a whole bundle: every blob a bundle manifest names, into an output
2//! directory, over the paid `cdn/client/v1` path.
3//!
4//! A bundle is a JSON manifest of `(path, hash, size, chunks)` entries
5//! (`adr/appendix-bundles.md`). [`BundlePuller`] fetches each distinct blob
6//! once, from one shared payment pool, and lands it at every path that names
7//! it. It resumes from what an earlier run left: a file already on disk is
8//! skipped, a byte-identical file elsewhere in the output directory is linked,
9//! a range a sibling entry shares is spliced from disk, and a `.partial` an
10//! interrupted run left is the next fetch's prefix.
11//!
12//! # A pull, end to end
13//!
14//! 1. Resolve the holders once for the run with [`select_nodes`]: the pinned
15//!    node, or every active registered node for each entry to sample.
16//! 2. Build a [`BundlePuller`] over the chain, the local site, an endpoint, a
17//!    buyer-pool store and the [`Funding`] the pull pays from.
18//! 3. Get the manifest: [`BundlePuller::fetch_manifest`] it by its hash, or
19//!    [`BundleManifest::parse`] bytes you already hold.
20//! 4. Drop the entries you do not want, then [`BundlePuller::pull`] the rest.
21//!    Progress reaches a [`BundleObserver`]; the result is a [`BundleOutcome`].
22//! 5. [`BundlePuller::finish`] waits for the lanes' watermark writes.
23//!
24//! The pull future is not `Send`: every entry and lane runs on the task that
25//! awaits it. Await it in place, or on a `tokio::task::LocalSet`.
26
27use std::path::{Path, PathBuf};
28use std::sync::Arc;
29use std::time::Duration;
30
31use alloy::primitives::U256;
32use alloy::providers::{DynProvider, Provider as _};
33use alloy::signers::local::PrivateKeySigner;
34use anyhow::Context as _;
35use decdn_incentive::buyer_pool::BuyerPoolStore;
36use decdn_incentive::payment_pool::PaymentPool;
37use decdn_incentive::{slash_judge_domain, voucher_domain};
38use iroh::{Endpoint, RelayUrl};
39use serde::Deserialize;
40use tokio_util::sync::CancellationToken;
41
42use crate::buyer_pool::{OwnedPayer, UnconfirmedEscrows};
43use crate::funding::RunFunding;
44use crate::ordered_writes::OrderedWrites;
45use crate::session::{
46    ChainAdoption, ChainConfig, DepositPolicy, DiscoveryOptions, PinnedNode, PullSite,
47};
48use crate::stream_cap::LaneStreamCap;
49use crate::uri::DecdnUri;
50use crate::{
51    Connections, CredentialSlot, FundingNeeded, LaneLedgers, PeerHealth, PoolReplaced,
52    ProgressClock, RecoveryGate, RunDemand, StopPolicy,
53};
54
55mod engine;
56mod landing;
57mod manifest_cache;
58mod skip_cache;
59
60#[doc(hidden)]
61pub use crate::lane_sources::FaultContext;
62
63/// The directory under the output root where a pull stages the blobs it
64/// fetches: `<hex>.partial` and its resume record while a blob is in flight,
65/// and `<hex>` once it is verified and before it lands at its paths.
66pub const STAGING_DIR: &str = ".decdn-partial";
67
68/// Parse a BLAKE3 hash as a bundle manifest or a user names it: 64 hex
69/// characters, with an optional `0x` or `b3:` prefix.
70///
71/// # Errors
72///
73/// When `s` is not a 64-character hex digest.
74pub fn parse_hash(s: &str) -> anyhow::Result<[u8; 32]> {
75    let hex = s
76        .strip_prefix("0x")
77        .or_else(|| s.strip_prefix("b3:"))
78        .unwrap_or(s);
79    let h = blake3::Hash::from_hex(hex).map_err(|e| {
80        anyhow::anyhow!(
81            "invalid hash {s:?}: expected 64 hex chars (BLAKE3 digest), optional `0x`/`b3:` \
82             prefix: {e}"
83        )
84    })?;
85    Ok(*h.as_bytes())
86}
87
88/// A bundle manifest: the files a bundle publishes (`adr/appendix-bundles.md`).
89#[derive(Debug, Clone)]
90#[non_exhaustive]
91pub struct BundleManifest {
92    /// The manifest blob's BLAKE3 hash: the hash a `decdn:<hash>/` URI names.
93    hash: [u8; 32],
94    /// Its files, in manifest order.
95    pub entries: Vec<ManifestEntry>,
96}
97
98/// The wire form of a manifest: a version gate around the entries.
99#[derive(Deserialize)]
100struct ManifestV1 {
101    version: u32,
102    entries: Vec<ManifestEntry>,
103}
104
105impl BundleManifest {
106    /// A manifest of `entries` whose blob has the BLAKE3 hash `hash`. A pull
107    /// records `hash` as the source of each file it writes, so a wrong one
108    /// makes a later pull of the same entry see its own files as foreign.
109    #[must_use]
110    pub const fn new(hash: [u8; 32], entries: Vec<ManifestEntry>) -> Self {
111        Self { hash, entries }
112    }
113
114    /// The manifest blob's BLAKE3 hash. A pull records it as the source of
115    /// each file it writes. Filtering `entries` keeps it: it names the whole
116    /// manifest.
117    #[must_use]
118    pub const fn hash(&self) -> [u8; 32] {
119        self.hash
120    }
121
122    /// Parse manifest JSON and check its version.
123    ///
124    /// # Errors
125    ///
126    /// [`NotAManifest`] when the bytes are not a manifest, or name a version
127    /// other than 1.
128    pub fn parse(bytes: &[u8]) -> anyhow::Result<Self> {
129        let manifest: ManifestV1 = serde_json::from_slice(bytes)
130            .map_err(|e| NotAManifest(format!("parse bundle JSON (expected v1 manifest): {e}")))?;
131        if manifest.version != 1 {
132            return Err(NotAManifest(format!(
133                "unsupported bundle version {} (this build supports v1)",
134                manifest.version
135            ))
136            .into());
137        }
138        Ok(Self {
139            hash: *blake3::hash(bytes).as_bytes(),
140            entries: manifest.entries,
141        })
142    }
143
144    /// The manifest blob `hash` a pull into `out_dir` cached, or `None` when
145    /// none is cached. The cache is self-verifying: bytes that do not hash to
146    /// `hash` are an error, as is an unreadable cache file.
147    ///
148    /// # Errors
149    ///
150    /// The cached file cannot be read, does not hash to `hash`, or is not a
151    /// manifest.
152    pub fn cached(out_dir: &Path, hash: [u8; 32]) -> anyhow::Result<Option<Self>> {
153        manifest_cache::load(out_dir, hash)?
154            .map(|bytes| {
155                Self::parse(&bytes).with_context(|| {
156                    format!(
157                        "cached bundle manifest {}",
158                        manifest_cache::cache_path(out_dir, hash).display()
159                    )
160                })
161            })
162            .transpose()
163    }
164
165    /// Where a pull into `out_dir` caches the manifest blob `hash`.
166    #[must_use]
167    pub fn cache_path(out_dir: &Path, hash: [u8; 32]) -> PathBuf {
168        manifest_cache::cache_path(out_dir, hash)
169    }
170}
171
172/// The entries of `manifest` whose path under `out_dir` holds something that
173/// a pull of the same entry may replace without the user's say: a file,
174/// directory or link that no record in the skip-cache in `out_dir` vouches
175/// for. A record vouches for a path when the file there still has the
176/// recorded size and mtime, and the record names this entry (this manifest
177/// hash and manifest path) or holds this entry's bytes. A caller that did not
178/// choose `out_dir` can refuse the pull first.
179///
180/// An entry whose path escapes `out_dir`, or that lands on the pull's own
181/// state, is not named: the pull fails that entry and writes nothing there.
182#[must_use]
183pub fn foreign_paths(manifest: &BundleManifest, out_dir: &Path) -> Vec<ForeignPath> {
184    let saved = skip_cache::load_checked(out_dir);
185    manifest
186        .entries
187        .iter()
188        .filter_map(|en| {
189            let dest = engine::writable_dest(out_dir, &en.path).ok()?;
190            let meta = std::fs::symlink_metadata(dest).ok()?;
191            let reason = match &saved {
192                Err(e) => ForeignReason::UnreadableCache(format!("{e:#}")),
193                Ok(saved) => match saved.get(&en.path) {
194                    None => ForeignReason::Unrecorded,
195                    Some(rec) if !rec.still_matches(&meta) => {
196                        ForeignReason::Changed(rec.source.uri())
197                    }
198                    Some(rec)
199                        if rec.source.is(manifest.hash, en.manifest_path())
200                            || rec.hash == en.hash =>
201                    {
202                        return None;
203                    }
204                    Some(rec) => ForeignReason::OtherSource(rec.source.uri()),
205                },
206            };
207            Some(ForeignPath {
208                path: en.path.clone(),
209                reason,
210            })
211        })
212        .collect()
213}
214
215/// A path a pull may replace that no record vouches for ([`foreign_paths`]).
216#[derive(Debug, Clone, PartialEq, Eq)]
217#[non_exhaustive]
218pub struct ForeignPath {
219    /// The entry's path under the output directory.
220    pub path: String,
221    /// Why no record vouches for `path`.
222    pub reason: ForeignReason,
223}
224
225/// Why no skip-cache record vouches for a [`ForeignPath`].
226///
227/// A held [`DecdnUri`] is the bundle entry an earlier pull wrote the path
228/// from. It is `None` when the record names no readable manifest hash.
229#[derive(Debug, Clone, PartialEq, Eq)]
230#[non_exhaustive]
231pub enum ForeignReason {
232    /// No pull recorded writing the path.
233    Unrecorded,
234    /// The skip-cache file is there, but it cannot be used: it cannot be
235    /// read, it is not a skip-cache, or another build wrote it. Holds the
236    /// cause.
237    UnreadableCache(String),
238    /// A pull wrote the path, and it changed after: its size, its mtime, or
239    /// its kind differs from the record.
240    Changed(Option<DecdnUri>),
241    /// A pull wrote the path from another bundle entry, with other bytes.
242    OtherSource(Option<DecdnUri>),
243}
244
245/// Bytes that are not a v1 bundle manifest: [`BundleManifest::parse`]'s error,
246/// so a caller can tell a blob that is not a manifest from a failed fetch.
247#[derive(Debug, Clone, PartialEq, Eq)]
248pub struct NotAManifest(String);
249
250impl std::fmt::Display for NotAManifest {
251    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
252        f.write_str(&self.0)
253    }
254}
255
256impl std::error::Error for NotAManifest {}
257
258/// One file of a [`BundleManifest`].
259#[derive(Debug, Clone, Deserialize)]
260#[non_exhaustive]
261pub struct ManifestEntry {
262    /// The file's POSIX relative path under the output directory.
263    pub path: String,
264    /// The file's whole-file BLAKE3 hash (`b3:`hex), which the fetch verifies.
265    pub hash: String,
266    /// The file's size. Optional on the wire: the pull uses it as the first
267    /// size claim and for progress totals, and verifies the true size.
268    #[serde(default)]
269    pub size: Option<u64>,
270    /// The file's ordered chunk decomposition, a range-dedup hint. Chunks it
271    /// shares with a sibling entry are spliced from disk instead of paid for
272    /// twice. The whole file is still verified by `hash`.
273    #[serde(default)]
274    pub chunks: Option<Vec<ManifestChunk>>,
275    /// The manifest `path` of an entry that [`Self::land_at`] moved, or `None`
276    /// when `path` is still the manifest path.
277    #[serde(skip)]
278    landed_from: Option<String>,
279}
280
281impl ManifestEntry {
282    /// An entry for the file at `path` with whole-file `hash` and `size`, and
283    /// no chunk hints.
284    #[must_use]
285    pub fn new(path: impl Into<String>, hash: impl Into<String>, size: Option<u64>) -> Self {
286        Self {
287            path: path.into(),
288            hash: hash.into(),
289            size,
290            chunks: None,
291            landed_from: None,
292        }
293    }
294
295    /// This entry, landing at `path` under the output directory in place of
296    /// its manifest path. The pull records the manifest path as the source of
297    /// the file it writes. A second `land_at` keeps the first manifest path.
298    /// Setting the `path` field directly makes the pull record that path as
299    /// the manifest path.
300    #[must_use]
301    pub fn land_at(mut self, path: impl Into<String>) -> Self {
302        let manifest_path = std::mem::replace(&mut self.path, path.into());
303        self.landed_from.get_or_insert(manifest_path);
304        self
305    }
306
307    /// The entry's `path` in its manifest: where it lands, unless
308    /// [`Self::land_at`] moved it.
309    #[must_use]
310    pub fn manifest_path(&self) -> &str {
311        self.landed_from.as_deref().unwrap_or(&self.path)
312    }
313
314    /// This entry with the chunk decomposition `chunks`.
315    #[must_use]
316    pub fn with_chunks(mut self, chunks: Vec<ManifestChunk>) -> Self {
317        self.chunks = Some(chunks);
318        self
319    }
320}
321
322/// One chunk of a [`ManifestEntry`]: an independently BLAKE3-addressed run of
323/// the file's bytes, placed at the running sum of the earlier chunks' sizes.
324#[derive(Debug, Clone, Deserialize)]
325#[non_exhaustive]
326pub struct ManifestChunk {
327    /// The chunk's BLAKE3 hash (`b3:`hex).
328    pub hash: String,
329    /// The chunk's length in bytes. The sizes sum to the entry's size.
330    pub size: u64,
331}
332
333impl ManifestChunk {
334    /// A chunk of `size` bytes with BLAKE3 `hash`.
335    #[must_use]
336    pub fn new(hash: impl Into<String>, size: u64) -> Self {
337        Self {
338            hash: hash.into(),
339            size,
340        }
341    }
342}
343
344/// What a bundle pull pays from.
345#[derive(Debug)]
346#[non_exhaustive]
347pub enum Funding {
348    /// The buyer's own pool: reused from the buyer store, adopted from chain,
349    /// or opened at the working deposit, and topped up when it runs low.
350    SelfOwned {
351        /// The pool owner's key: it signs every pool transaction and the
352        /// capability for the lane key. The vouchers sign under a lane key
353        /// derived from it and the buyer store's salt
354        /// ([`crate::buyer_pool::OwnedPayer::for_store`]), so two stores on one
355        /// owner sign on disjoint lanes.
356        owner: Arc<PrivateKeySigner>,
357        /// How the pool is funded.
358        deposit: DepositPolicy,
359    },
360    /// A pool another owner funds, under the capability the slot holds. The
361    /// pull opens and tops up nothing; when the capability cannot pay for the
362    /// rest, the pull ends [`FundingNeeded`].
363    Delegated {
364        /// The delegate key and the owner-signed capability.
365        slot: CredentialSlot,
366    },
367}
368
369/// Where a pull's entries find their holders: the pinned node, or every
370/// active registered node for each entry to sample. Resolve it with
371/// [`select_nodes`].
372#[derive(Debug, Clone)]
373pub struct NodeSelection {
374    /// The node every entry pulls from, when one is pinned.
375    pinned: Option<PinnedNode>,
376    /// Every active registered node, and whether the list is the
377    /// registry-outage fallback, when none is pinned.
378    registry: Option<engine::Registry>,
379}
380
381/// Resolve where a pull's entries find their holders, once for the run.
382///
383/// With a pinned node every entry pulls from it. Otherwise the run's
384/// candidate list is every active registered node: the peer store's
385/// identity-fresh records when enough of them qualify, else one
386/// `CapacityBond` registry read bounded by
387/// [`DiscoveryOptions::registry_timeout`]. Each entry then probes its own
388/// sample of the list. Resolve the selection before you unlock a key, so a
389/// failed discovery costs no password prompt.
390///
391/// # Errors
392///
393/// No node is pinned and `chain` names no `CapacityBond`, or the registry
394/// read fails with no usable peer store, or the registry lists no node.
395pub async fn select_nodes(
396    chain: &ChainConfig,
397    site: &PullSite,
398    discovery: &DiscoveryOptions,
399) -> anyhow::Result<NodeSelection> {
400    if let Some(pinned) = discovery.pinned {
401        return Ok(NodeSelection {
402            pinned: Some(pinned),
403            registry: None,
404        });
405    }
406    Ok(NodeSelection {
407        pinned: None,
408        registry: Some(engine::discover_candidates(chain, site, discovery).await?),
409    })
410}
411
412/// Where a bundle pull reports its progress. Every method has a default
413/// that does nothing. The pull calls them from the task that awaits it.
414pub trait BundleObserver: Send + Sync {
415    /// The pull starts on the entries it was given. `download_total` is the
416    /// content bytes it plans to pay for, after shared chunks and duplicate
417    /// files count once; `content_total` is the bytes that land on disk.
418    /// Either is `None` when no entry declares a size.
419    fn run_started(&self, download_total: Option<u64>, content_total: Option<u64>) {
420        let _ = (download_total, content_total);
421    }
422
423    /// One blob starts: it lands at every one of `paths`. `download_total` is
424    /// the bytes it plans to pay for, and `reconstruct_total` the bytes it
425    /// plans to splice from disk. The returned observer follows that blob.
426    fn file_started(
427        &self,
428        paths: &[String],
429        download_total: u64,
430        reconstruct_total: u64,
431    ) -> Arc<dyn FileObserver> {
432        let _ = (paths, download_total, reconstruct_total);
433        Arc::new(NoFileObserver)
434    }
435
436    /// One blob landed in its staging file.
437    fn entry_landed(&self, landed: &EntryLanded) {
438        let _ = landed;
439    }
440
441    /// Something went wrong that does not fail the pull, such as a manifest
442    /// cache file that cannot be read or written.
443    fn warning(&self, warning: &str) {
444        let _ = warning;
445    }
446
447    /// A funding recovery step replaced the pool. The pull runs the remaining
448    /// entries once more against the new pool.
449    fn pool_replaced(&self, replaced: &PoolReplaced) {
450        let _ = replaced;
451    }
452}
453
454/// Where one blob of a bundle pull reports its progress. Every method has a
455/// default that does nothing.
456pub trait FileObserver: Send + Sync {
457    /// `received` of the blob's `expected` content bytes are verified. Every
458    /// fetch of the blob reports its whole position.
459    fn delivered(&self, received: u64, expected: u64) {
460        let _ = (received, expected);
461    }
462
463    /// The blob probes its holders before any byte arrives.
464    fn discovering(&self) {}
465
466    /// The blob's own bytes are in, and it waits for a sibling to land the
467    /// chunks it shares.
468    fn pending(&self) {}
469
470    /// The blob is assembled, and its whole-file hash runs.
471    fn reconstructing(&self) {}
472
473    /// The whole-file hash has read `bytes` bytes.
474    fn hashed(&self, bytes: u64) {
475        let _ = bytes;
476    }
477
478    /// The blob's pull settled, landed or failed.
479    fn finished(&self) {}
480}
481
482/// The [`FileObserver`] that ignores everything.
483struct NoFileObserver;
484
485impl FileObserver for NoFileObserver {}
486
487/// One blob that landed ([`BundleObserver::entry_landed`]). No byte counts in
488/// more than one of `paid`, `spliced` and `resumed`.
489#[derive(Debug, Clone, Copy, PartialEq, Eq)]
490#[non_exhaustive]
491pub struct EntryLanded {
492    /// The blob's hash.
493    pub hash: [u8; 32],
494    /// The blob's size.
495    pub size: u64,
496    /// The content bytes this pull fetched and paid for.
497    pub paid: u64,
498    /// The bytes spliced from a file already on disk.
499    pub spliced: u64,
500    /// The bytes an earlier, interrupted pull fetched, which this pull resumed.
501    pub resumed: u64,
502    /// How long the blob took: probing, every fetch, any splice and the
503    /// whole-file check.
504    pub elapsed: Duration,
505}
506
507/// Explains a failed fetch to the caller's user, before the pull records the
508/// error. The `decdn` CLI names its own flags and commands here.
509#[doc(hidden)]
510pub trait FaultHints: Send + Sync {
511    /// `err` with what the user can do about it, given what the failed
512    /// fetch's lanes saw.
513    fn annotate(&self, err: anyhow::Error, at: &FaultContext) -> anyhow::Error;
514}
515
516/// How a bundle pull ended, and what it did.
517#[derive(Debug)]
518#[non_exhaustive]
519pub struct BundleOutcome {
520    /// What every entry did.
521    pub report: BundleReport,
522    /// Why the pull ended.
523    pub end: BundleEnd,
524    /// Every pool replacement this pull's funding recovery steps made, in the
525    /// order they made them. Each closed pool may still hold a balance to
526    /// reclaim once its dispute window ends. A replacement that ended the
527    /// first pass runs the rest again against the new pool. One a sibling
528    /// entry's step made while another entry ended the pull, or one that an
529    /// untracked escrow ended the pull over, runs nothing again, and is still
530    /// listed here. A second replacement, in the pass against the new pool,
531    /// runs nothing again and is listed after the first: the pool the first
532    /// one opened is the one it closed. One that ends that pass ends the pull
533    /// "funding needed". [`BundlePuller::replacements`] also lists a manifest
534    /// fetch's.
535    pub replaced: Vec<PoolReplaced>,
536}
537
538/// Why a bundle pull ended.
539#[derive(Debug)]
540#[non_exhaustive]
541pub enum BundleEnd {
542    /// Every entry settled. Some may have failed
543    /// ([`BundleReport::failures`]); a rerun retries them.
544    Complete,
545    /// The cancel token fired. What landed is recorded, and a rerun resumes.
546    /// The caller names what the puller's handle
547    /// ([`BundlePuller::unconfirmed_escrows`]) still holds: each pending
548    /// `openPool` and `topUp` ([`UnconfirmedEscrows::pending`]) and each
549    /// untracked escrow ([`UnconfirmedEscrows::untracked`]). A rerun before
550    /// those resolve escrows again.
551    Interrupted,
552    /// The pool cannot pay for the rest: the capability is spent, expired or
553    /// revoked, or the delegated pool is unfunded. `error` is the whole
554    /// chain.
555    FundingNeeded {
556        /// What funding the pull needs.
557        needed: FundingNeeded,
558        /// The error the pull ended with.
559        error: anyhow::Error,
560    },
561    /// A fault only the user can fix ended the pull, or it gave up after
562    /// making no progress for its whole limit ([`crate::GaveUp`]). A pull that
563    /// is not interrupted ends here while its handle holds an untracked escrow
564    /// ([`UnconfirmedEscrows::untracked`]) or a pending `openPool` or `topUp`
565    /// ([`UnconfirmedEscrows::pending`]), with an error that names each tx:
566    /// an entry dropped while its receipt read was pending leaves one there.
567    /// This includes every later pull on the same handle.
568    Stopped(anyhow::Error),
569}
570
571/// What a bundle pull did. Counts are per entry, and `fetched`, `linked`,
572/// `skipped`, `reused`, `failed` and `not_run` add up to the manifest's entry
573/// count. Every byte total but
574/// [`Self::paid_bytes`] is content bytes and leaves out the bao proof
575/// overhead.
576#[derive(Debug, Clone, Default, PartialEq, Eq)]
577#[non_exhaustive]
578pub struct BundleReport {
579    /// Entries this pull fetched and paid for, one per distinct blob.
580    pub fetched: u64,
581    /// Entries linked or copied from a blob another path of this pull fetched.
582    pub linked: u64,
583    /// Entries already on disk.
584    pub skipped: u64,
585    /// Entries that failed ([`Self::failures`]).
586    pub failed: u64,
587    /// Entries this pull did not run: another entry ended the pull, or the
588    /// cancel token fired, before their fetch ended. A rerun runs them.
589    pub not_run: u64,
590    /// Distinct blobs linked from a byte-identical file already in the output
591    /// directory, with no download.
592    pub reused: u64,
593    /// The size of those blobs, once each.
594    pub reused_bytes: u64,
595    /// The content bytes this pull fetched and paid for, from entry sizes: a
596    /// blob counts once, less what it spliced or resumed.
597    pub downloaded: u64,
598    /// The lane bytes this pull's vouchers and hash-chain reveals paid for:
599    /// how far it grew the claims the nodes can redeem, summed over every lane
600    /// ([`crate::PoolLedger::paid_bytes`]). These are wire bytes, so they
601    /// include the bao proof overhead. Content delivered twice, or paid for and
602    /// then dropped after a lane fault, counts each time it is paid. An entry
603    /// that fails or is interrupted counts here but not in `downloaded`; a
604    /// later pull resumes its `.partial` and reports it as `resumed_bytes`.
605    pub paid_bytes: u64,
606    /// The bytes written to disk: every landed copy of every blob.
607    pub reconstructed: u64,
608    /// The bytes of `.partial` prefixes earlier pulls fetched, which this pull
609    /// resumed. Never part of `downloaded`.
610    pub resumed_bytes: u64,
611    /// The bytes spliced from files already on disk. Never downloaded.
612    pub spliced_bytes: u64,
613    /// Range-dedup hints a fault dropped: a chunk list that does not parse or
614    /// sum to the file size, or a donor that failed its check.
615    pub hints_ignored: u64,
616    /// Every failed entry, in manifest order.
617    pub failures: Vec<EntryFailure>,
618    /// Lines that do not fail the pull: a blob whose size differs from its
619    /// manifest, or a wallet too short of USDC to top the pool up.
620    pub warnings: Vec<String>,
621}
622
623/// One entry a bundle pull failed to land.
624#[derive(Debug, Clone, Default, PartialEq, Eq)]
625#[non_exhaustive]
626pub struct EntryFailure {
627    /// The entry's path.
628    pub path: String,
629    /// Why, with secrets redacted.
630    pub error: String,
631}
632
633/// Pulls bundles into a directory over one paid run: one pool, one
634/// connection per node, one progress clock.
635///
636/// Build it with [`Self::builder`]. One puller serves one run: a manifest
637/// fetch and one [`Self::pull`], or several pulls that share the run's lanes.
638pub struct BundlePuller {
639    ctx: engine::PullCtx<DynProvider>,
640}
641
642impl std::fmt::Debug for BundlePuller {
643    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
644        f.debug_struct("BundlePuller")
645            .field("chain", &self.ctx.chain)
646            .field("self_address", &self.ctx.self_address)
647            .finish_non_exhaustive()
648    }
649}
650
651/// Builds a [`BundlePuller`] ([`BundlePuller::builder`]).
652#[must_use]
653pub struct BundlePullerBuilder {
654    chain: ChainConfig,
655    site: PullSite,
656    endpoint: Endpoint,
657    store: Arc<dyn BuyerPoolStore>,
658    funding: Funding,
659    discovery: DiscoveryOptions,
660    nodes: NodeSelection,
661    relays: Vec<RelayUrl>,
662    namespace: Option<u64>,
663    jobs: usize,
664    max_lane_streams: usize,
665    max_rate_per_mb: u64,
666    max_blob_bytes: u64,
667    interactive: bool,
668    give_up_after: Option<Duration>,
669    observer: Option<Arc<dyn BundleObserver>>,
670    hints: Option<Arc<dyn FaultHints>>,
671    escrows: UnconfirmedEscrows,
672}
673
674impl std::fmt::Debug for BundlePullerBuilder {
675    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
676        f.debug_struct("BundlePullerBuilder")
677            .field("chain", &self.chain)
678            .field("jobs", &self.jobs)
679            .finish_non_exhaustive()
680    }
681}
682
683impl BundlePuller {
684    /// A builder for a pull on `chain`, keeping its peer store and scratch
685    /// files in `site`, dialing from `endpoint`, recording its pool and lanes
686    /// in `store`, and paying from `funding`. `discovery` and `nodes`
687    /// ([`select_nodes`]) say where entries find their holders.
688    pub fn builder(
689        chain: ChainConfig,
690        site: PullSite,
691        endpoint: Endpoint,
692        store: Arc<dyn BuyerPoolStore>,
693        funding: Funding,
694        discovery: DiscoveryOptions,
695        nodes: NodeSelection,
696    ) -> BundlePullerBuilder {
697        BundlePullerBuilder {
698            chain,
699            site,
700            endpoint,
701            store,
702            funding,
703            discovery,
704            nodes,
705            relays: Vec::new(),
706            namespace: None,
707            jobs: 4,
708            max_lane_streams: 4,
709            max_rate_per_mb: 0,
710            max_blob_bytes: 1 << 40,
711            interactive: false,
712            give_up_after: None,
713            observer: None,
714            hints: None,
715            escrows: UnconfirmedEscrows::default(),
716        }
717    }
718
719    /// Fetch the manifest blob `hash`, and cache it in `out_dir`. With
720    /// `use_cache`, a manifest an earlier pull cached there is read instead,
721    /// and nothing is paid; a cache file that cannot be read is reported to
722    /// the observer and fetched over.
723    ///
724    /// # Errors
725    ///
726    /// The fetch fails, or the bytes are not a manifest.
727    pub async fn fetch_manifest(
728        &self,
729        hash: [u8; 32],
730        out_dir: &Path,
731        use_cache: bool,
732    ) -> anyhow::Result<BundleManifest> {
733        Box::pin(self.ctx.fetch_manifest(hash, out_dir, use_cache)).await
734    }
735
736    /// Pull every entry of `manifest` into `out_dir`. With `overwrite`, files
737    /// already on disk are fetched again instead of skipped. A fired `cancel`
738    /// stops every fetch where it is; what landed is recorded, and the next
739    /// pull resumes.
740    ///
741    /// An entry that fails does not stop the others. A fault only the user
742    /// can fix, or the stop policy, ends the pull ([`BundleEnd`]).
743    pub async fn pull(
744        &mut self,
745        manifest: &BundleManifest,
746        out_dir: &Path,
747        overwrite: bool,
748        cancel: &CancellationToken,
749    ) -> BundleOutcome {
750        Box::pin(self.ctx.run(manifest, out_dir, overwrite, cancel)).await
751    }
752
753    /// Every pool replacement this puller's funding recovery steps made, in
754    /// order: its manifest fetches' and its pulls' alike, and a step in a
755    /// pull its caller dropped or cancelled. Each closed pool may still hold
756    /// a balance to reclaim once its dispute window ends. A replacement that
757    /// ended a manifest fetch is in no [`BundleOutcome`].
758    #[must_use]
759    pub fn replacements(&self) -> Vec<PoolReplaced> {
760        self.ctx.recovery.replacements()
761    }
762
763    /// The warning for a wallet seen to hold too little USDC to top the pool
764    /// up during this run, if one was.
765    #[must_use]
766    pub fn funding_shortfall(&self) -> Option<String> {
767        self.ctx.funding.shortfall()
768    }
769
770    /// The handle that holds each `openPool` and `topUp` of the puller that no
771    /// local record credits yet, and each that escrowed with no local record
772    /// crediting it: the one passed to
773    /// [`BundlePullerBuilder::unconfirmed_escrows`], or the puller's own.
774    #[must_use]
775    pub const fn unconfirmed_escrows(&self) -> &UnconfirmedEscrows {
776        self.ctx.funding.escrows()
777    }
778
779    /// Wait for every queued peer-record and watermark write to land. Call it
780    /// before the process exits, after a cancelled pull too, so the next run
781    /// resumes each lane from what it paid.
782    pub async fn finish(self) {
783        self.ctx.writes.settle().await;
784    }
785}
786
787impl BundlePullerBuilder {
788    /// The relays to hint when dialing a node. The first one rides each dial.
789    pub fn relays(mut self, relays: Vec<RelayUrl>) -> Self {
790        self.relays = relays;
791        self
792    }
793
794    /// The namespace (ADR 002) every request carries: a node routes a cache
795    /// miss to that namespace's origins. `None` is the best-effort default.
796    pub const fn namespace(mut self, namespace: Option<u64>) -> Self {
797        self.namespace = namespace;
798        self
799    }
800
801    /// How many blobs fetch at once (at least 1; 4 by default).
802    pub const fn jobs(mut self, jobs: usize) -> Self {
803        self.jobs = jobs;
804        self
805    }
806
807    /// How many streams one provider's lane runs at once across the whole
808    /// pull (at least 1; 4 by default).
809    pub const fn max_lane_streams(mut self, n: usize) -> Self {
810        self.max_lane_streams = n;
811        self
812    }
813
814    /// The highest per-MB rate a lane accepts; `0`, the default, sets no
815    /// ceiling.
816    pub const fn max_rate_per_mb(mut self, rate: u64) -> Self {
817        self.max_rate_per_mb = rate;
818        self
819    }
820
821    /// The largest blob one entry fetches (1 TiB by default).
822    pub const fn max_blob_bytes(mut self, bytes: u64) -> Self {
823        self.max_blob_bytes = bytes;
824        self
825    }
826
827    /// When the pull gives up after making no progress
828    /// ([`crate::StopPolicy`]): an `interactive` pull waits for its caller to
829    /// cancel, and any other stops after 10 minutes. `give_up_after`
830    /// overrides both. Non-interactive by default.
831    pub const fn stop(mut self, interactive: bool, give_up_after: Option<Duration>) -> Self {
832        self.interactive = interactive;
833        self.give_up_after = give_up_after;
834        self
835    }
836
837    /// Where the pull reports its progress. None by default.
838    pub fn observer(mut self, observer: Arc<dyn BundleObserver>) -> Self {
839        self.observer = Some(observer);
840        self
841    }
842
843    /// Where the pull holds each `openPool` and `topUp` that no local record
844    /// credits yet ([`UnconfirmedEscrows::pending`]), across every pass. A
845    /// cancel or a fault that ends the pull can drop a receipt wait, and the
846    /// transaction may still escrow. A pull that is not interrupted ends
847    /// [`BundleEnd::Stopped`] on each one, and the caller keeps a clone to
848    /// name what is left on [`BundleEnd::Interrupted`]. A `topUp` the handle
849    /// holds also refuses every later `topUp` of the pull, across passes. The
850    /// handle also holds each `openPool` and `topUp` that escrowed with no
851    /// local record crediting it ([`UnconfirmedEscrows::untracked`]). It
852    /// refuses every later `openPool` and `topUp`, ends every later pull on
853    /// the handle [`BundleEnd::Stopped`], and is the caller's to name on
854    /// [`BundleEnd::Interrupted`]. A private handle by default
855    /// ([`BundlePuller::unconfirmed_escrows`]).
856    pub fn unconfirmed_escrows(mut self, escrows: UnconfirmedEscrows) -> Self {
857        self.escrows = escrows;
858        self
859    }
860
861    /// What explains a failed fetch to the caller's user.
862    #[doc(hidden)]
863    pub fn fault_hints(mut self, hints: Arc<dyn FaultHints>) -> Self {
864        self.hints = Some(hints);
865        self
866    }
867
868    /// Build the puller: connect to the RPC endpoint and read the pool's
869    /// settlement token.
870    ///
871    /// # Errors
872    ///
873    /// The RPC URL is malformed, the capability does not decode, the buyer
874    /// store cannot read or record its lane salt (a self-owned run), or the
875    /// `PaymentPool` token read fails.
876    pub async fn build(self) -> anyhow::Result<BundlePuller> {
877        let (signer, payer, deposit) = match self.funding {
878            Funding::SelfOwned { owner, deposit } => {
879                let payer = OwnedPayer::for_store(Arc::clone(&owner), self.store.as_ref())?;
880                (owner, engine::RunPayer::Owned(payer), deposit)
881            }
882            Funding::Delegated { slot } => {
883                // A delegate owns no pool, so it funds and adopts none. Its
884                // lanes sign under the slot's credential as each is built; the
885                // provider below sends no transaction.
886                let deposit = DepositPolicy {
887                    working_deposit: U256::ZERO,
888                    max_approve: false,
889                    adoption: ChainAdoption::Refused,
890                };
891                (
892                    slot.current().signer,
893                    engine::RunPayer::Delegated(slot),
894                    deposit,
895                )
896            }
897        };
898        let rpc = crate::provider::build_provider(&self.chain.rpc_url, &signer)?.erased();
899        let contract = PaymentPool::new(self.chain.payment_pool, rpc.clone());
900        let token = contract
901            .usdc()
902            .call()
903            .await
904            .map_err(|e| anyhow::Error::new(e).context("read PaymentPool.usdc()"))?;
905        let ctx = engine::PullCtx {
906            voucher_dom: voucher_domain(self.chain.chain_id, self.chain.payment_pool),
907            slash_dom: slash_judge_domain(self.chain.chain_id, self.chain.slash_judge),
908            connections: Connections::new(self.endpoint.clone()),
909            self_address: signer.address(),
910            endpoint: self.endpoint,
911            store: self.store,
912            contract,
913            rpc,
914            payer,
915            token,
916            chain: self.chain,
917            site: self.site,
918            relays: self.relays,
919            nodes: self.nodes,
920            discovery: self.discovery,
921            max_rate_per_mb: self.max_rate_per_mb,
922            max_blob_bytes: self.max_blob_bytes,
923            deposit,
924            namespace_id: namespace_id(self.namespace),
925            ledgers: LaneLedgers::new(),
926            funding: RunFunding::reporting_escrows_to(self.escrows),
927            recovery: Arc::new(RecoveryGate::new()),
928            dedup_stats: engine::DedupStats::default(),
929            open_lock: tokio::sync::Mutex::new(()),
930            gate: engine::JobGate::new(self.jobs.max(1)),
931            lane_cap: LaneStreamCap::new(self.max_lane_streams),
932            health: Arc::new(PeerHealth::default()),
933            writes: OrderedWrites::default(),
934            // One clock for the whole run: a stuck entry waits while another
935            // lands bytes.
936            stop: StopPolicy::new(
937                self.interactive,
938                self.give_up_after,
939                Arc::new(ProgressClock::new()),
940            ),
941            demand: Arc::new(RunDemand::default()),
942            observer: self.observer,
943            hints: self.hints,
944        };
945        Ok(BundlePuller { ctx })
946    }
947}
948
949/// The namespace id (ADR 002) every paid pull in the run carries: `namespace`
950/// as a big-endian `uint256`, or `NO_NAMESPACE` when it is `None`.
951fn namespace_id(namespace: Option<u64>) -> [u8; 32] {
952    namespace.map_or(decdn_protocol::client::NO_NAMESPACE, |n| {
953        U256::from(n).to_be_bytes()
954    })
955}