Skip to main content

ant_core/data/client/
file.rs

1//! File operations using streaming self-encryption.
2//!
3//! Upload files directly from disk without loading them entirely into memory.
4//! Uses `stream_encrypt` to process files in 8KB chunks, encrypting and
5//! uploading each piece as it's produced.
6//!
7//! Encrypted chunks are spilled to a temporary directory during encryption
8//! so that peak memory usage is bounded to one wave (~256 MB for 64 × 4 MB
9//! chunks) regardless of file size.
10//!
11//! For in-memory data uploads, see the `data` module.
12
13use crate::data::client::adaptive::{observe_op, rebucketed_unordered};
14use crate::data::client::batch::{
15    finalize_batch_payment, PaymentIntent, PreparedChunk, WaveAggregateStats,
16};
17use crate::data::client::chunk::ChunkPeerGetResult;
18use crate::data::client::classify_error;
19use crate::data::client::merkle::{
20    finalize_merkle_batch, merge_merkle_batch_results, merkle_batch_sizes, merkle_billable_leaves,
21    merkle_deferred_retry, merkle_store_with_retry, should_use_merkle, MerkleBatchPaymentResult,
22    PaymentMode, PreparedMerkleBatch, DEFERRED_ROUND_DELAYS_SECS,
23};
24use crate::data::client::payment::SINGLE_NODE_PAYMENT_MULTIPLIER;
25use crate::data::client::Client;
26use crate::data::error::{Error, PartialUploadSpend, Result};
27use ant_protocol::evm::{Amount, PaymentQuote, QuoteHash, TxHash, MAX_LEAVES};
28use ant_protocol::transport::{MultiAddr, PeerId};
29use ant_protocol::{compute_address, XorName as ChunkAddress, DATA_TYPE_CHUNK};
30use bytes::Bytes;
31use fs2::FileExt;
32use futures::stream::{self, StreamExt};
33use self_encryption::{
34    get_root_data_map_parallel, stream_decrypt_batch_size, stream_encrypt,
35    streaming_decrypt_with_batch_size, DataMap,
36};
37use std::collections::{HashMap, HashSet};
38use std::io::Write;
39use std::num::NonZeroUsize;
40use std::path::{Path, PathBuf};
41use std::sync::{Arc, Mutex};
42use tokio::runtime::Handle;
43use tokio::sync::mpsc;
44use tracing::{debug, info, warn};
45use xor_name::XorName;
46
47/// Progress events emitted during file upload for UI feedback.
48#[derive(Debug, Clone)]
49pub enum UploadEvent {
50    /// A chunk has been encrypted and spilled to disk.
51    Encrypting { chunks_done: usize },
52    /// File encryption complete.
53    Encrypted { total_chunks: usize },
54    /// Starting quote collection for a wave.
55    QuotingChunks {
56        wave: usize,
57        total_waves: usize,
58        chunks_in_wave: usize,
59    },
60    /// A chunk has been quoted (peer discovery + price received).
61    /// This is the slow phase — each quote involves network round-trips.
62    ChunkQuoted { quoted: usize, total: usize },
63    /// A chunk has been stored on the network.
64    ChunkStored { stored: usize, total: usize },
65}
66
67/// Progress events emitted during file download for UI feedback.
68#[derive(Debug, Clone)]
69pub enum DownloadEvent {
70    /// Resolving hierarchical DataMap to discover real chunk count.
71    ResolvingDataMap { total_map_chunks: usize },
72    /// A DataMap chunk has been fetched during resolution.
73    MapChunkFetched { fetched: usize },
74    /// DataMap resolved — total data chunk count now known.
75    DataMapResolved { total_chunks: usize },
76    /// Data chunks are being fetched from the network.
77    ChunksFetched { fetched: usize, total: usize },
78}
79
80/// File download result when peer-health diagnostics are enabled.
81#[derive(Debug, Clone)]
82pub struct FileDownloadWithPeerReport {
83    /// Number of plaintext bytes written to the destination.
84    pub bytes_written: u64,
85    /// Per-file-chunk closest-peer GET results collected during the actual download.
86    pub chunk_reports: Vec<FileChunkPeerReport>,
87}
88
89/// Closest-peer GET results for one file chunk.
90#[derive(Debug, Clone)]
91pub struct FileChunkPeerReport {
92    /// 1-based chunk index in the resolved file DataMap.
93    pub index: usize,
94    /// Chunk address.
95    pub address: ChunkAddress,
96    /// All diagnostic GET sweeps attempted for this chunk.
97    pub sweeps: Vec<FileChunkPeerSweepReport>,
98}
99
100/// One all-peer diagnostic GET sweep for a file chunk.
101#[derive(Debug, Clone)]
102pub struct FileChunkPeerSweepReport {
103    /// 1-based attempt number for this chunk.
104    pub attempt: usize,
105    /// Whether this sweep happened during a deferred retry round.
106    pub deferred_retry: bool,
107    /// DHT lookup / sweep-level error, if the closest-peer group could not be queried.
108    pub error: Option<String>,
109    /// Per-peer results, sorted closest first.
110    pub peers: Vec<FileChunkPeerReportPeer>,
111}
112
113/// One peer result in a [`FileChunkPeerReport`].
114#[derive(Debug, Clone)]
115pub struct FileChunkPeerReportPeer {
116    /// Peer queried for the chunk.
117    pub peer_id: PeerId,
118    /// Known network addresses used for the peer.
119    pub peer_addrs: Vec<MultiAddr>,
120    /// XOR distance from `peer_id` to the chunk address.
121    pub xor_distance: ChunkAddress,
122    /// Whether this peer returned the chunk or why it did not.
123    pub status: FileChunkPeerStatus,
124}
125
126/// Peer-level file chunk GET diagnostic status.
127#[derive(Debug, Clone)]
128pub enum FileChunkPeerStatus {
129    /// The peer returned the chunk.
130    Found { bytes: usize },
131    /// The peer responded authoritatively that it does not store the chunk.
132    NotFound,
133    /// The peer did not respond before the timeout.
134    Timeout { message: String },
135    /// The transport/network path to the peer failed.
136    NetworkError { message: String },
137    /// Any other per-peer error.
138    Error { message: String },
139}
140
141/// One entry in the per-chunk quote list returned by
142/// [`Client::get_store_quotes`]: the responding peer, its addresses, the
143/// signed quote it returned, the payment amount it is demanding, and (ADR-0004)
144/// the opaque signed-commitment blob the node shipped with the quote.
145type QuoteEntry = (
146    PeerId,
147    Vec<MultiAddr>,
148    PaymentQuote,
149    Amount,
150    Option<Vec<u8>>,
151);
152
153type DownloadBatchEntry = (usize, std::result::Result<Bytes, XorName>);
154
155#[derive(Debug, Clone)]
156struct RecordedFileChunkPeerSweep {
157    index: usize,
158    address: ChunkAddress,
159    sweep: FileChunkPeerSweepReport,
160}
161
162#[derive(Clone)]
163struct FileDownloadFetchContext {
164    total_chunks: usize,
165    peer_count: usize,
166    fetched_ref: Arc<std::sync::atomic::AtomicUsize>,
167    progress_ref: Option<mpsc::Sender<DownloadEvent>>,
168    peer_reports: Option<Arc<Mutex<Vec<RecordedFileChunkPeerSweep>>>>,
169}
170
171/// Number of chunks per upload wave (matches batch.rs PAYMENT_WAVE_SIZE).
172const UPLOAD_WAVE_SIZE: usize = 64;
173
174/// Hard ceiling on chunk bodies held in memory at once by the merkle whole-file
175/// store fan-out (`upload_merkle_from_spill`). Each in-flight store holds one
176/// spilled body (≤ `MAX_CHUNK_SIZE` = 4 MiB), so this bounds peak resident store
177/// memory at ~256 MiB — the same bound the old fixed 64-chunk waves gave. The
178/// adaptive store cap can legitimately exceed this (`AdaptiveConfig::sanitize`
179/// permits `adaptive.max.store` above 64), so the fan-out clamps its cap here to
180/// keep a high configured max from pinning gigabytes of chunk bodies (PR #137
181/// review). Throughput is unaffected at the default cap, which is already 64.
182const MERKLE_STORE_MAX_IN_FLIGHT: usize = 64;
183
184/// The merkle whole-file store fan-out concurrency: the adaptive store cap,
185/// clamped to [`MERKLE_STORE_MAX_IN_FLIGHT`] (memory bound) and floored at 1.
186fn merkle_store_cap(limiter_current: usize) -> usize {
187    limiter_current.clamp(1, MERKLE_STORE_MAX_IN_FLIGHT)
188}
189
190/// Stream decrypt batches should be larger than fetch fan-out so
191/// the rolling fetch scheduler can keep launching new chunk GETs as earlier
192/// ones complete, instead of stopping at each self-encryption batch boundary.
193const DOWNLOAD_STREAM_BATCH_FETCH_MULTIPLIER: usize = 4;
194
195/// Use at most this fraction of currently usable RAM for one decrypt batch.
196const DOWNLOAD_STREAM_BATCH_MEMORY_BUDGET_DIVISOR: u64 = 4;
197
198/// A decrypt batch briefly holds encrypted chunk bytes, decrypted chunk bytes,
199/// and Vec/Bytes overhead. Use a conservative multiplier rather than assuming
200/// payload bytes alone.
201const DOWNLOAD_STREAM_BATCH_BYTES_PER_CHUNK_MULTIPLIER: u64 = 3;
202
203/// Maximum number of distinct chunk addresses to sample when probing for a
204/// representative quote in [`Client::estimate_upload_cost`].
205///
206/// Bounded small so we never spend more than a couple of round-trips on the
207/// `AlreadyStored` retry path, which only matters when many leading chunks
208/// of a file already live on the network.
209const ESTIMATE_SAMPLE_CAP: usize = 5;
210
211/// First diagnostic all-peer fetch attempt for a file chunk.
212const FIRST_DIAGNOSTIC_FETCH_ATTEMPT: usize = 1;
213
214/// Deferred retry attempt number for retry round 0.
215const DEFERRED_RETRY_ATTEMPT_OFFSET: usize = 2;
216
217/// Pick up to `cap` chunk indices spread evenly across `[0, total)`, always
218/// including the first and last chunk.
219///
220/// Sampling the *first* N chunks biases the probe: a file sharing a leading
221/// prefix with a prior upload (compressed archives, similar headers) reports
222/// those chunks as `AlreadyStored` even when the tail is new, so a positional
223/// sample looks in the worst possible place. Spreading the sample means a
224/// single new chunk anywhere in the file yields a real price.
225///
226/// Returns `[0]` for a single chunk and every index when `total <= cap`, so
227/// [`Client::estimate_upload_cost`] can still detect the "whole file sampled"
228/// case. Indices are strictly increasing.
229fn distributed_sample_indices(total: usize, cap: usize) -> Vec<usize> {
230    if total == 0 {
231        return Vec::new();
232    }
233    let sample_limit = total.min(cap);
234    if sample_limit <= 1 {
235        return vec![0];
236    }
237    let mut indices: Vec<usize> = (0..sample_limit)
238        .map(|i| i * (total - 1) / (sample_limit - 1))
239        .collect();
240    indices.dedup(); // defensive: already strictly increasing for cap >= 2
241    indices
242}
243
244fn file_chunk_sweep_report_from_peer_results(
245    attempt: usize,
246    deferred_retry: bool,
247    results: &[ChunkPeerGetResult],
248) -> (Option<Bytes>, FileChunkPeerSweepReport) {
249    let mut content = None;
250    let peers = results
251        .iter()
252        .map(|result| {
253            if content.is_none() {
254                if let Ok(Some(chunk)) = &result.chunk_result {
255                    content = Some(chunk.content.clone());
256                }
257            }
258
259            FileChunkPeerReportPeer {
260                peer_id: result.peer_id,
261                peer_addrs: result.peer_addrs.clone(),
262                xor_distance: result.xor_distance,
263                status: file_chunk_peer_status(&result.chunk_result),
264            }
265        })
266        .collect();
267
268    (
269        content,
270        FileChunkPeerSweepReport {
271            attempt,
272            deferred_retry,
273            error: None,
274            peers,
275        },
276    )
277}
278
279fn file_chunk_sweep_report_from_error(
280    attempt: usize,
281    deferred_retry: bool,
282    error: &Error,
283) -> FileChunkPeerSweepReport {
284    FileChunkPeerSweepReport {
285        attempt,
286        deferred_retry,
287        error: Some(error.to_string()),
288        peers: Vec::new(),
289    }
290}
291
292fn file_chunk_reports_from_recorded_sweeps(
293    mut sweeps: Vec<RecordedFileChunkPeerSweep>,
294) -> Vec<FileChunkPeerReport> {
295    sweeps.sort_by_key(|record| (record.index, record.sweep.attempt));
296
297    let mut reports: Vec<FileChunkPeerReport> = Vec::new();
298    for record in sweeps {
299        if let Some(report) = reports
300            .last_mut()
301            .filter(|report| report.index == record.index)
302        {
303            report.sweeps.push(record.sweep);
304            continue;
305        }
306
307        reports.push(FileChunkPeerReport {
308            index: record.index,
309            address: record.address,
310            sweeps: vec![record.sweep],
311        });
312    }
313
314    reports
315}
316
317fn file_chunk_peer_status(
318    chunk_result: &std::result::Result<Option<ant_protocol::DataChunk>, Error>,
319) -> FileChunkPeerStatus {
320    match chunk_result {
321        Ok(Some(chunk)) => FileChunkPeerStatus::Found {
322            bytes: chunk.content.len(),
323        },
324        Ok(None) => FileChunkPeerStatus::NotFound,
325        Err(Error::Timeout(e)) => FileChunkPeerStatus::Timeout { message: e.clone() },
326        Err(Error::Network(e)) => FileChunkPeerStatus::NetworkError { message: e.clone() },
327        Err(e) => FileChunkPeerStatus::Error {
328            message: e.to_string(),
329        },
330    }
331}
332
333/// Gas used by one `pay_for_quotes` transaction that packs up to
334/// `UPLOAD_WAVE_SIZE` (quote_hash, rewards_address, amount) entries.
335///
336/// `batch_pay` in `batch.rs` flattens every chunk's close-group quotes into a
337/// single EVM call, so the dominant cost is the SSTOREs for each entry plus
338/// the base tx overhead. On Arbitrum that is roughly
339/// `21_000 + 64 × (20_000 + small)` ≈ 1.3M; we round up to 1.5M as a
340/// conservative per-wave upper bound.
341const GAS_PER_WAVE_TX: u128 = 1_500_000;
342
343/// Gas used by one merkle batch payment transaction.
344///
345/// One on-chain tx per merkle sub-batch, but each tx verifies a merkle tree
346/// and posts a pool commitment, so budget higher than a plain transfer.
347const GAS_PER_MERKLE_TX: u128 = 500_000;
348
349/// Advisory gas price (wei/gas) used to turn the gas estimate into an ETH
350/// figure when no live gas oracle is consulted.
351///
352/// Arbitrum One typically settles around 0.1 gwei on quiet blocks; we use
353/// that as the default so the CLI prints a sensible order-of-magnitude
354/// number. Users should treat the reported gas cost as an estimate, not a
355/// commitment — real gas is bid at submission time.
356const ARBITRUM_GAS_PRICE_WEI: u128 = 100_000_000;
357
358/// Extra headroom percentage for disk space check.
359///
360/// Encrypted chunks are slightly larger than the source data due to padding
361/// and self-encryption overhead. We require file_size + 10% free space in
362/// the temp directory to account for this.
363const DISK_SPACE_HEADROOM_PERCENT: u64 = 10;
364
365/// Temporary on-disk buffer for encrypted chunks.
366///
367/// During file encryption, chunks are written to a temp directory so that
368/// only their 32-byte addresses stay in memory. At upload time chunks are
369/// read back one wave at a time, keeping peak RAM at ~`UPLOAD_WAVE_SIZE × 4 MB`.
370/// Grace period (in seconds) before a spill dir is eligible for stale cleanup.
371///
372/// This is a small TOCTOU guard covering the sub-millisecond window inside
373/// [`ChunkSpill::new`] between `create_dir` and `try_lock_exclusive`. Once a
374/// dir is older than this and its lockfile is releasable, the owning process
375/// is gone and the dir is safe to reap — regardless of how old it is.
376///
377/// The previous policy waited 24 h before reaping any orphan, which meant
378/// that any non-graceful exit (SIGKILL, kernel OOM, panic abort) leaked its
379/// spill dir until the next day's upload — and on a host being restart-looped
380/// by systemd, orphans could fill the disk well within that window.
381const SPILL_STALE_GRACE_SECS: u64 = 30;
382
383/// Prefix for spill directory names to distinguish from user files.
384const SPILL_DIR_PREFIX: &str = "spill_";
385
386/// Lockfile name inside each spill dir to signal active use.
387const SPILL_LOCK_NAME: &str = ".lock";
388
389struct ChunkSpill {
390    /// Directory holding spilled chunk files (named by hex address).
391    dir: PathBuf,
392    /// Lockfile held for the lifetime of this spill (prevents stale cleanup).
393    _lock: std::fs::File,
394    /// Deduplicated list of chunk addresses.
395    addresses: Vec<[u8; 32]>,
396    /// Tracks seen addresses for deduplication.
397    seen: HashSet<[u8; 32]>,
398    /// Byte size per spilled chunk address.
399    sizes: HashMap<[u8; 32], u64>,
400    /// Running total of unique chunk byte sizes (for average-size calculation).
401    total_bytes: u64,
402}
403
404impl ChunkSpill {
405    /// Return the parent directory for all spill dirs: `<data_dir>/spill/`.
406    fn spill_root() -> Result<PathBuf> {
407        use crate::config;
408        let root = config::data_dir()
409            .map_err(|e| Error::Config(format!("cannot determine data dir for spill: {e}")))?
410            .join("spill");
411        Ok(root)
412    }
413
414    /// Create a new spill directory under `<data_dir>/spill/`.
415    ///
416    /// Directory name is `spill_<timestamp>_<random>` so orphans can be
417    /// identified by prefix and cleaned up by age. A lockfile inside the
418    /// dir prevents concurrent cleanup from deleting an active spill.
419    fn new() -> Result<Self> {
420        let root = Self::spill_root()?;
421        std::fs::create_dir_all(&root)?;
422
423        // Clean up stale spill dirs from previous crashed runs.
424        Self::cleanup_stale(&root);
425
426        let now = std::time::SystemTime::now()
427            .duration_since(std::time::UNIX_EPOCH)
428            .unwrap_or_default()
429            .as_secs();
430        let unique: u64 = rand::random();
431        let dir = root.join(format!("{SPILL_DIR_PREFIX}{now}_{unique}"));
432        std::fs::create_dir(&dir)?;
433
434        // Create and hold a lockfile for the lifetime of this spill.
435        // cleanup_stale() will skip dirs with locked files.
436        let lock_path = dir.join(SPILL_LOCK_NAME);
437        let lock_file = std::fs::File::create(&lock_path).map_err(|e| {
438            Error::Io(std::io::Error::new(
439                e.kind(),
440                format!("failed to create spill lockfile: {e}"),
441            ))
442        })?;
443        lock_file.try_lock_exclusive().map_err(|e| {
444            Error::Io(std::io::Error::new(
445                e.kind(),
446                format!("failed to lock spill lockfile: {e}"),
447            ))
448        })?;
449
450        Ok(Self {
451            dir,
452            _lock: lock_file,
453            addresses: Vec::new(),
454            seen: HashSet::new(),
455            sizes: HashMap::new(),
456            total_bytes: 0,
457        })
458    }
459
460    /// Clean up stale spill directories. Best-effort, errors are logged.
461    ///
462    /// A spill dir is reaped when:
463    /// 1. Its name starts with `SPILL_DIR_PREFIX` (ignores unrelated files)
464    /// 2. It is an actual directory, not a symlink (prevents symlink attacks)
465    /// 3. Its timestamp is older than `SPILL_STALE_GRACE_SECS` (TOCTOU guard)
466    /// 4. Its lockfile is releasable — i.e. no live process holds it
467    ///
468    /// The lockfile is the primary correctness gate: a releasable lock means
469    /// the owning `ChunkSpill` has been dropped or the process is gone, so
470    /// the dir is fair game. The grace period covers only the brief window
471    /// inside [`Self::new`] between `create_dir` and `try_lock_exclusive`.
472    ///
473    /// Safe to call concurrently from multiple processes.
474    fn cleanup_stale(root: &Path) {
475        let now = std::time::SystemTime::now()
476            .duration_since(std::time::UNIX_EPOCH)
477            .unwrap_or_default()
478            .as_secs();
479
480        if now == 0 {
481            // Clock is broken (before Unix epoch). Skip cleanup to avoid
482            // misidentifying dirs as stale.
483            warn!("System clock before Unix epoch, skipping spill cleanup");
484            return;
485        }
486
487        let entries = match std::fs::read_dir(root) {
488            Ok(entries) => entries,
489            Err(_) => return,
490        };
491
492        for entry in entries.flatten() {
493            let name = entry.file_name();
494            let name_str = name.to_string_lossy();
495
496            // Only process dirs with our prefix.
497            let suffix = match name_str.strip_prefix(SPILL_DIR_PREFIX) {
498                Some(s) => s,
499                None => continue,
500            };
501
502            // Parse timestamp: "spill_<timestamp>_<random>"
503            let timestamp: u64 = match suffix.split('_').next().and_then(|s| s.parse().ok()) {
504                Some(ts) => ts,
505                None => continue,
506            };
507
508            if now.saturating_sub(timestamp) < SPILL_STALE_GRACE_SECS {
509                continue;
510            }
511
512            // Safety: only delete actual directories, not symlinks.
513            let file_type = match entry.file_type() {
514                Ok(ft) => ft,
515                Err(_) => continue,
516            };
517            if !file_type.is_dir() {
518                continue;
519            }
520
521            let path = entry.path();
522
523            // Check lockfile: if locked, the dir is in active use -- skip it.
524            let lock_path = path.join(SPILL_LOCK_NAME);
525            if let Ok(lock_file) = std::fs::File::open(&lock_path) {
526                use fs2::FileExt;
527                if lock_file.try_lock_exclusive().is_err() {
528                    // Lock held by another process -- dir is active.
529                    debug!("Skipping active spill dir: {}", path.display());
530                    continue;
531                }
532                // We acquired the lock, so no one else holds it.
533                // Drop it before deleting.
534                drop(lock_file);
535            }
536
537            info!("Cleaning up stale spill dir: {}", path.display());
538            if let Err(e) = std::fs::remove_dir_all(&path) {
539                warn!("Failed to clean up stale spill dir {}: {e}", path.display());
540            }
541        }
542    }
543
544    /// Run stale spill cleanup. Call at client startup or periodically.
545    #[allow(dead_code)]
546    pub(crate) fn run_cleanup() {
547        if let Ok(root) = Self::spill_root() {
548            Self::cleanup_stale(&root);
549        }
550    }
551
552    /// Write one encrypted chunk to disk and record its address.
553    ///
554    /// Deduplicates by content address: if the same chunk was already
555    /// spilled, the write and accounting are skipped. This prevents
556    /// double-uploads and inflated quoting metrics.
557    fn push(&mut self, content: &[u8]) -> Result<()> {
558        let address = compute_address(content);
559        if !self.seen.insert(address) {
560            return Ok(());
561        }
562        let path = self.dir.join(hex::encode(address));
563        std::fs::write(&path, content)?;
564        let content_len = content.len() as u64;
565        self.sizes.insert(address, content_len);
566        self.total_bytes += content_len;
567        self.addresses.push(address);
568        Ok(())
569    }
570
571    /// Number of chunks stored.
572    fn len(&self) -> usize {
573        self.addresses.len()
574    }
575
576    /// Total bytes of all spilled chunks.
577    fn total_bytes(&self) -> u64 {
578        self.total_bytes
579    }
580
581    /// Address and byte-size pairs for all spilled chunks.
582    fn chunk_entries(&self) -> Result<Vec<([u8; 32], u64)>> {
583        self.addresses
584            .iter()
585            .map(|address| {
586                self.sizes
587                    .get(address)
588                    .copied()
589                    .map(|size| (*address, size))
590                    .ok_or_else(|| {
591                        Error::Storage(format!(
592                            "missing size for spilled chunk {}",
593                            hex::encode(address)
594                        ))
595                    })
596            })
597            .collect()
598    }
599
600    /// Read a single chunk back from disk by address.
601    fn read_chunk(&self, address: &[u8; 32]) -> Result<Bytes> {
602        let path = self.dir.join(hex::encode(address));
603        let data = std::fs::read(&path).map_err(|e| {
604            Error::Io(std::io::Error::new(
605                e.kind(),
606                format!("reading spilled chunk {}: {e}", hex::encode(address)),
607            ))
608        })?;
609        Ok(Bytes::from(data))
610    }
611
612    /// Read the bodies for `addresses` back from disk, in the given order.
613    fn read_chunks(&self, addresses: &[[u8; 32]]) -> Result<Vec<Bytes>> {
614        addresses.iter().map(|addr| self.read_chunk(addr)).collect()
615    }
616
617    /// Read every spilled body back, in insertion order.
618    fn read_all_chunks(&self) -> Result<Vec<Bytes>> {
619        self.read_chunks(&self.addresses)
620    }
621
622    /// Clean up the spill directory.
623    fn cleanup(&self) {
624        if let Err(e) = std::fs::remove_dir_all(&self.dir) {
625            warn!(
626                "Failed to clean up chunk spill dir {}: {e}",
627                self.dir.display()
628            );
629        }
630    }
631}
632
633impl Drop for ChunkSpill {
634    fn drop(&mut self) {
635        self.cleanup();
636    }
637}
638
639fn cached_merkle_covers_addresses(
640    cached: &MerkleBatchPaymentResult,
641    addresses: &[[u8; 32]],
642) -> bool {
643    addresses
644        .iter()
645        .all(|addr| cached.proofs.contains_key(addr))
646}
647
648/// Split `addresses` into `(to_store, missing_proof)`: those that have a merkle
649/// proof in `proofs`, and those that don't.
650///
651/// A partial [`MerkleBatchPaymentResult`] (from a `pay_for_merkle_multi_batch`
652/// where a later sub-batch's payment failed) carries proofs only for the
653/// already-paid sub-batches, so unpaid chunks reach the upload path with no
654/// proof. `upload_merkle_from_spill` reports those as failed via
655/// [`Error::PartialUpload`] rather than aborting the whole file. Order within
656/// each group follows `addresses`.
657fn partition_addresses_by_proof(
658    addresses: &[[u8; 32]],
659    proofs: &HashMap<[u8; 32], Vec<u8>>,
660) -> (Vec<[u8; 32]>, Vec<[u8; 32]>) {
661    addresses
662        .iter()
663        .copied()
664        .partition(|addr| proofs.contains_key(addr))
665}
666
667/// Build a `PartialUpload` after a fatal merkle store error, with accurate
668/// counts.
669///
670/// A fatal abort can leave chunks in three states: confirmed stored (in
671/// `stored_addresses`), known-failed (in `known_failed` — missing proofs, the
672/// quorum shortfalls and the fatal chunk seen so far), and "in flight when the
673/// abort hit" (neither). Rather than trust the helpers to enumerate the last
674/// group, this derives the failed set authoritatively as *every* `addresses`
675/// entry not in `stored_addresses`, preferring a known per-chunk message and
676/// falling back to the fatal `reason`. That guarantees
677/// `stored_count + failed_count` accounts for the whole file — fixing the
678/// under-reporting where a fatal wave could surface `failed_count = 0` and omit
679/// same-pass successes.
680fn partial_upload_after_fatal(
681    addresses: &[[u8; 32]],
682    stored_addresses: Vec<[u8; 32]>,
683    stored_count: usize,
684    total_chunks: usize,
685    known_failed: Vec<([u8; 32], String)>,
686    spend: PartialUploadSpend,
687    reason: String,
688) -> Error {
689    let stored_set: HashSet<[u8; 32]> = stored_addresses.iter().copied().collect();
690    let mut failed_map: HashMap<[u8; 32], String> = HashMap::new();
691    for (addr, msg) in known_failed {
692        if !stored_set.contains(&addr) {
693            failed_map.entry(addr).or_insert(msg);
694        }
695    }
696    for addr in addresses {
697        if !stored_set.contains(addr) {
698            failed_map.entry(*addr).or_insert_with(|| reason.clone());
699        }
700    }
701    let failed: Vec<([u8; 32], String)> = failed_map.into_iter().collect();
702    let failed_count = failed.len();
703    Error::PartialUpload {
704        stored: stored_addresses,
705        stored_count,
706        failed,
707        failed_count,
708        total_chunks,
709        spend: Box::new(spend),
710        reason,
711    }
712}
713
714/// Fold the per-batch winner hashes of an external merkle upload into one
715/// combined payment receipt.
716///
717/// Validates that `winner_pool_hashes` aligns with `prepared_batches` (one
718/// entry per batch, in order), requires at least one paid batch, finalizes
719/// each paid batch, and merges the receipts the way the wallet path folds
720/// its sub-batch payments. Unpaid (`None`) batches contribute no proofs, so
721/// the store phase reports their chunks through [`Error::PartialUpload`]
722/// (ADR-0003).
723fn fold_external_merkle_payments(
724    prepared_batches: Vec<PreparedMerkleBatch>,
725    winner_pool_hashes: Vec<Option<[u8; 32]>>,
726) -> Result<MerkleBatchPaymentResult> {
727    let batch_count = prepared_batches.len();
728    if winner_pool_hashes.len() != batch_count {
729        return Err(Error::Payment(format!(
730            "Expected {batch_count} winner pool hash entries (one per \
731             prepared sub-batch), got {}.",
732            winner_pool_hashes.len()
733        )));
734    }
735
736    let mut paid = Vec::with_capacity(batch_count);
737    let mut unpaid_batches = 0usize;
738    for (batch, hash) in prepared_batches.into_iter().zip(winner_pool_hashes) {
739        match hash {
740            Some(h) => paid.push(finalize_merkle_batch(batch, h)?),
741            None => unpaid_batches += 1,
742        }
743    }
744    if paid.is_empty() {
745        return Err(Error::Payment(
746            "No merkle sub-batch was paid — nothing to finalize. \
747             Pay at least one batch or drop the prepared upload."
748                .to_string(),
749        ));
750    }
751    if unpaid_batches > 0 {
752        warn!(
753            "External merkle finalize: {unpaid_batches}/{batch_count} sub-batch(es) \
754             unpaid; their chunks will be reported as failed"
755        );
756    }
757    Ok(merge_merkle_batch_results(paid))
758}
759
760/// One wave's contribution to a single-node upload, distilled from its
761/// `batch_upload_chunks_with_events` result.
762#[derive(Debug)]
763struct SingleWaveOutcome {
764    /// Addresses confirmed stored in this wave.
765    stored: Vec<[u8; 32]>,
766    /// Chunks that failed after retries in this wave.
767    failed: Vec<([u8; 32], String)>,
768    /// Storage cost paid on-chain for this wave, in atto-tokens.
769    storage_atto: Amount,
770    /// Gas paid on-chain for this wave, in wei.
771    gas_wei: u128,
772    /// Per-wave store/retry statistics. Empty for a quorum-short wave, whose
773    /// `PartialUpload` carries no stats.
774    stats: WaveAggregateStats,
775}
776
777/// Fold one wave's batch-upload result for the single-node path.
778///
779/// A `PartialUpload` (chunks short of quorum after retries) is **recoverable**:
780/// its stored/failed chunks and on-chain spend are returned so the caller
781/// records them and continues to the next wave, making the file make maximum
782/// progress exactly like `upload_merkle_from_spill`. Every other error is **fatal**
783/// (wallet/payment-infrastructure failures, missing proofs, spill reads) and is
784/// returned via `Err` to abort the file. Because `UPLOAD_WAVE_SIZE ==
785/// PAYMENT_WAVE_SIZE`, each batch call is exactly one payment wave, so folding a
786/// `PartialUpload` leaves nothing un-attempted within the wave.
787fn fold_single_wave(
788    result: Result<(Vec<[u8; 32]>, String, u128, WaveAggregateStats)>,
789) -> Result<SingleWaveOutcome> {
790    match result {
791        Ok((stored, storage, gas, stats)) => Ok(SingleWaveOutcome {
792            stored,
793            failed: Vec::new(),
794            storage_atto: storage.parse().unwrap_or(Amount::ZERO),
795            gas_wei: gas,
796            stats,
797        }),
798        Err(Error::PartialUpload {
799            stored,
800            failed,
801            spend,
802            ..
803        }) => Ok(SingleWaveOutcome {
804            stored,
805            failed,
806            storage_atto: spend.storage_cost_atto.parse().unwrap_or(Amount::ZERO),
807            gas_wei: spend.gas_cost_wei,
808            stats: WaveAggregateStats::default(),
809        }),
810        Err(e) => Err(e),
811    }
812}
813
814/// Check that the spill directory has enough free space for the spilled chunks.
815///
816/// `file_size` is the source file's byte count. We require
817/// `file_size + 10%` free space to account for self-encryption overhead.
818fn check_disk_space_for_spill(file_size: u64) -> Result<()> {
819    let spill_root = ChunkSpill::spill_root()?;
820
821    // Ensure the root exists so fs2 can query it.
822    std::fs::create_dir_all(&spill_root)?;
823
824    let available = fs2::available_space(&spill_root).map_err(|e| {
825        Error::Io(std::io::Error::new(
826            e.kind(),
827            format!(
828                "failed to query disk space on {}: {e}",
829                spill_root.display()
830            ),
831        ))
832    })?;
833
834    // Use integer arithmetic to avoid f64 precision loss on large file sizes.
835    let headroom = file_size / DISK_SPACE_HEADROOM_PERCENT;
836    let required = file_size.saturating_add(headroom);
837
838    if available < required {
839        let avail_mb = available / (1024 * 1024);
840        let req_mb = required / (1024 * 1024);
841        return Err(Error::InsufficientDiskSpace(format!(
842            "need ~{req_mb} MB in spill dir ({}) but only {avail_mb} MB available",
843            spill_root.display()
844        )));
845    }
846
847    debug!(
848        "Disk space check passed: {available} bytes available, {required} bytes required (spill: {})",
849        spill_root.display()
850    );
851    Ok(())
852}
853
854fn usable_memory_bytes() -> Option<u64> {
855    let mut system = sysinfo::System::new();
856    system.refresh_memory();
857
858    let available_memory = system.available_memory();
859    let free_memory = system.free_memory();
860    let used_memory = system.used_memory();
861    let total_memory = system.total_memory();
862    let unused_memory = total_memory.saturating_sub(used_memory);
863
864    let mut usable = [available_memory, free_memory, unused_memory]
865        .into_iter()
866        .filter(|bytes| *bytes > 0)
867        .max();
868
869    let cgroup_free_memory = system
870        .cgroup_limits()
871        .filter(|limits| limits.total_memory > 0)
872        .map(|limits| limits.free_memory);
873    if let Some(cgroup_free_memory) = cgroup_free_memory {
874        usable = Some(usable.unwrap_or(u64::MAX).min(cgroup_free_memory));
875    }
876
877    debug!(
878        available_memory,
879        free_memory,
880        used_memory,
881        total_memory,
882        cgroup_free_memory,
883        usable_memory = ?usable,
884        "Detected usable memory for stream decrypt batch sizing"
885    );
886
887    usable
888}
889
890fn stream_decrypt_batch_memory_cap(usable_memory_bytes: u64) -> usize {
891    let budget = usable_memory_bytes / DOWNLOAD_STREAM_BATCH_MEMORY_BUDGET_DIVISOR;
892    let estimated_bytes_per_chunk = (self_encryption::MAX_CHUNK_SIZE as u64)
893        .saturating_mul(DOWNLOAD_STREAM_BATCH_BYTES_PER_CHUNK_MULTIPLIER)
894        .max(1);
895    let cap = (budget / estimated_bytes_per_chunk).max(1);
896
897    usize::try_from(cap).unwrap_or(usize::MAX)
898}
899
900fn adaptive_stream_decrypt_batch_size(
901    total_chunks: usize,
902    fetch_cap: usize,
903    configured_batch_floor: usize,
904    usable_memory_bytes: Option<u64>,
905) -> usize {
906    let fetch_target = fetch_cap
907        .max(1)
908        .saturating_mul(DOWNLOAD_STREAM_BATCH_FETCH_MULTIPLIER);
909    let requested = match usable_memory_bytes {
910        Some(bytes) => {
911            let memory_cap = stream_decrypt_batch_memory_cap(bytes);
912            configured_batch_floor
913                .max(fetch_target)
914                .max(1)
915                .min(memory_cap)
916        }
917        None => configured_batch_floor.max(1),
918    };
919
920    requested.min(total_chunks.max(1)).max(1)
921}
922
923/// Whether the data map is published to the network for address-based retrieval.
924///
925/// A private upload stores only the data chunks and returns the `DataMap` to
926/// the caller — only someone holding that `DataMap` can reconstruct the file.
927/// A public upload additionally stores the serialized `DataMap` as a chunk on
928/// the network, yielding a single chunk address that anyone can use to
929/// retrieve the `DataMap` (via [`Client::data_map_fetch`]) and then the file.
930#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
931pub enum Visibility {
932    /// Keep the data map local; only the holder can retrieve the file.
933    #[default]
934    Private,
935    /// Publish the data map as a network chunk so anyone with the returned
936    /// address can retrieve and decrypt the file.
937    Public,
938}
939
940/// Confidence attached to an [`UploadCostEstimate`]'s `storage_cost_atto`.
941///
942/// `estimate_upload_cost` prices a file by sampling a few of its chunk
943/// addresses and extrapolating. When every sampled chunk is already stored
944/// there is no live price to extrapolate from, so a `"0"` cost can mean either
945/// "provably free" (the whole file was sampled) or only "probably free" (the
946/// tail was unsampled). This lets callers tell those apart instead of treating
947/// every `"0"` as unconditionally free.
948#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
949#[serde(rename_all = "snake_case")]
950pub enum CostEstimateConfidence {
951    /// At least one sampled chunk returned a live quote; `storage_cost_atto`
952    /// is extrapolated from a real per-chunk price. The normal case.
953    #[default]
954    PricedSample,
955    /// Every chunk in the file was sampled and every one was already stored.
956    /// `storage_cost_atto` is exactly `"0"` — the upload is genuinely free.
957    VerifiedAllAlreadyStored,
958    /// Every *sampled* chunk was already stored, but not all chunks were
959    /// sampled. `storage_cost_atto` is `"0"` as a best-effort guess; the real
960    /// upload reconciles the true cost at payment time. Render this as "likely
961    /// already stored", not a guaranteed-free price.
962    AllSamplesAlreadyStoredIncomplete,
963}
964
965/// Estimated cost of uploading a file, returned by
966/// [`Client::estimate_upload_cost`].
967///
968/// Marked `#[non_exhaustive]` so adding a field later is not a breaking change
969/// for downstream consumers that construct or pattern-match on this struct.
970#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
971#[non_exhaustive]
972pub struct UploadCostEstimate {
973    /// Original file size in bytes.
974    pub file_size: u64,
975    /// Number of chunks the file would be split into (data chunks only,
976    /// does not include the DataMap chunk added during public uploads).
977    pub chunk_count: usize,
978    /// Estimated total storage cost in atto (token smallest unit).
979    pub storage_cost_atto: String,
980    /// Estimated gas cost in wei as a string. This is a rough heuristic
981    /// based on chunk count and payment mode, NOT a live gas price query.
982    pub estimated_gas_cost_wei: String,
983    /// Payment mode that would be used.
984    pub payment_mode: PaymentMode,
985    /// How much to trust `storage_cost_atto`. See [`CostEstimateConfidence`].
986    #[serde(default)]
987    pub confidence: CostEstimateConfidence,
988}
989
990/// Result of a file upload: the `DataMap` needed to retrieve the file.
991///
992/// Marked `#[non_exhaustive]` so adding a new field in future is not a
993/// breaking change for downstream consumers that construct or pattern-match
994/// on this struct.
995#[derive(Debug, Clone)]
996#[non_exhaustive]
997pub struct FileUploadResult {
998    /// The data map containing chunk metadata for reconstruction.
999    pub data_map: DataMap,
1000    /// Number of chunks stored on the network.
1001    pub chunks_stored: usize,
1002    /// Number of chunks that failed to store. Always 0 for a successful
1003    /// upload — partial-failure information is conveyed via
1004    /// [`crate::data::Error::PartialUpload`] instead.
1005    pub chunks_failed: usize,
1006    /// Total number of chunks in the upload, including chunks that were
1007    /// already stored and skipped. On full success this equals `chunks_stored`.
1008    pub total_chunks: usize,
1009    /// Which payment mode was actually used (not just requested).
1010    pub payment_mode_used: PaymentMode,
1011    /// Total storage cost paid in token units (atto). "0" if all chunks already existed.
1012    pub storage_cost_atto: String,
1013    /// Total gas cost in wei. 0 if no on-chain transactions were made.
1014    pub gas_cost_wei: u128,
1015    /// Chunk address of the serialized `DataMap`, set only for
1016    /// [`Visibility::Public`] uploads. **`Some` means this address is
1017    /// retrievable from the network (via [`Client::data_map_fetch`])**, not
1018    /// necessarily that *this* upload paid to store it — if the serialized
1019    /// `DataMap` hashed to a chunk that was already on the network (same
1020    /// file uploaded before; deterministic via self-encryption), the address
1021    /// is still returned but no storage payment was made for it.
1022    pub data_map_address: Option<[u8; 32]>,
1023    /// Sum of chunk-store RPC attempts across the upload
1024    /// (`>= chunks_stored` on full success; more if any chunk retried).
1025    /// `0` for paths that don't run the wave store loop.
1026    pub chunk_attempts_total: usize,
1027    /// Per-chunk store wall-clock in ms (length == `chunks_stored` on full
1028    /// success, empty for paths that don't run the wave store loop).
1029    pub store_durations_ms: Vec<u64>,
1030    /// Count of stored chunks that succeeded on each retry round
1031    /// (index 0 = first attempt, 1 = first retry, etc.). All zeros for
1032    /// paths that don't run the wave store loop.
1033    pub retries_histogram: [usize; 4],
1034}
1035
1036/// Payment information for external signing — either wave-batch or merkle.
1037// ADR-0004 added the signed commitment fields (`committed_key_count`,
1038// `commitment_pin`) to the merkle candidate quotes carried inside
1039// `PreparedMerkleBatch`, which grew the `Merkle` variant past the
1040// `large_enum_variant` threshold. This enum is constructed one-off per payment
1041// (never held in bulk collections), so the size delta is harmless; allow it
1042// rather than box a field on the security-sensitive merkle-finalize path.
1043#[allow(clippy::large_enum_variant)]
1044#[derive(Debug)]
1045pub enum ExternalPaymentInfo {
1046    /// Wave-batch: individual (quote_hash, rewards_address, amount) tuples.
1047    WaveBatch {
1048        /// Chunks ready for payment (needed for finalize).
1049        prepared_chunks: Vec<PreparedChunk>,
1050        /// Payment intent for external signing.
1051        payment_intent: PaymentIntent,
1052    },
1053    /// Merkle: one on-chain payment call per prepared sub-batch.
1054    Merkle {
1055        /// The prepared merkle sub-batches, in address order (public fields
1056        /// sent to the frontend, private fields stay in Rust). The external
1057        /// signer submits one `payForMerkleTree` transaction per batch;
1058        /// finalize takes one winner hash per batch in the same order
1059        /// (ADR-0003). A fresh upload below `MAX_LEAVES` chunks prepares as
1060        /// exactly one batch, so single-payment consumers keep working
1061        /// until they exceed it.
1062        prepared_batches: Vec<PreparedMerkleBatch>,
1063        /// Bodies of the chunks that still need upload, held in the
1064        /// encryption spill on disk — NOT resident in memory (ADR-0003).
1065        chunk_store: ExternalChunkStore,
1066        /// Chunk addresses that still need upload after the preflight check.
1067        chunk_addresses: Vec<[u8; 32]>,
1068    },
1069}
1070
1071/// Opaque on-disk store of the chunk bodies carried by a prepared external
1072/// merkle upload.
1073///
1074/// Wraps the encryption spill: bodies stay on disk from prepare until
1075/// finalize reads them back ≤ store-cap at a time, so peak RAM for the
1076/// external path matches the wallet path's ~256 MB bound instead of the file
1077/// size (ADR-0003). The spill directory lives exactly as long as this value:
1078/// dropping the `PreparedUpload` (e.g. a consumer's session TTL expiring or
1079/// an explicit cancel) removes it from disk.
1080pub struct ExternalChunkStore(ChunkSpill);
1081
1082impl ExternalChunkStore {
1083    fn from_spill(spill: ChunkSpill) -> Self {
1084        Self(spill)
1085    }
1086
1087    fn spill(&self) -> &ChunkSpill {
1088        &self.0
1089    }
1090}
1091
1092impl std::fmt::Debug for ExternalChunkStore {
1093    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1094        f.debug_struct("ExternalChunkStore")
1095            .field("chunks", &self.0.len())
1096            .field("bytes", &self.0.total_bytes())
1097            .finish()
1098    }
1099}
1100
1101/// Prepared upload ready for external payment.
1102///
1103/// Contains everything needed to construct the on-chain payment transaction
1104/// externally (e.g. via WalletConnect in a desktop app) and then finalize
1105/// the upload without a Rust-side wallet.
1106///
1107/// Note: This struct stays in Rust memory — only the public fields of
1108/// `payment_info` are sent to the frontend. `PreparedChunk` contains
1109/// non-serializable network types, so the full struct cannot derive `Serialize`.
1110///
1111/// Marked `#[non_exhaustive]` so adding a new field in future is not a
1112/// breaking change for downstream consumers.
1113#[derive(Debug)]
1114#[non_exhaustive]
1115pub struct PreparedUpload {
1116    /// The data map for later retrieval.
1117    pub data_map: DataMap,
1118    /// Payment information for chunks that still need payment after the
1119    /// already-stored preflight. This may be wave-batch even when the original
1120    /// chunk count was merkle-eligible if the remaining count is below the
1121    /// merkle threshold.
1122    pub payment_info: ExternalPaymentInfo,
1123    /// Chunk address of the serialized `DataMap` when this upload was
1124    /// prepared with [`Visibility::Public`]. `Some` means the address is
1125    /// retrievable on the network after finalization — either because this
1126    /// upload paid to store the chunk in `payment_info`, or because the
1127    /// chunk was already on the network (deterministic self-encryption).
1128    /// Carried through to [`FileUploadResult::data_map_address`].
1129    pub data_map_address: Option<[u8; 32]>,
1130    /// Chunk addresses already present on the network when this upload was
1131    /// prepared. These do not require payment or PUT during finalization.
1132    pub already_stored_addresses: Vec<[u8; 32]>,
1133    /// Total chunk count for the upload, including already-stored chunks.
1134    pub total_chunks: usize,
1135}
1136
1137/// Return type for [`spawn_file_encryption`]: chunk receiver, `DataMap` oneshot, join handle.
1138type EncryptionChannels = (
1139    tokio::sync::mpsc::Receiver<Bytes>,
1140    tokio::sync::oneshot::Receiver<DataMap>,
1141    tokio::task::JoinHandle<Result<()>>,
1142);
1143
1144/// Spawn a blocking task that streams file encryption through a channel.
1145fn spawn_file_encryption(path: PathBuf) -> Result<EncryptionChannels> {
1146    let metadata = std::fs::metadata(&path)?;
1147    let data_size = usize::try_from(metadata.len())
1148        .map_err(|e| Error::Encryption(format!("file size exceeds platform usize: {e}")))?;
1149
1150    let (chunk_tx, chunk_rx) = tokio::sync::mpsc::channel(2);
1151    let (datamap_tx, datamap_rx) = tokio::sync::oneshot::channel();
1152
1153    let handle = tokio::task::spawn_blocking(move || {
1154        let file = std::fs::File::open(&path)?;
1155        let mut reader = std::io::BufReader::new(file);
1156
1157        let read_error: Arc<Mutex<Option<std::io::Error>>> = Arc::new(Mutex::new(None));
1158        let read_error_clone = Arc::clone(&read_error);
1159
1160        let data_iter = std::iter::from_fn(move || {
1161            let mut buffer = vec![0u8; 8192];
1162            match std::io::Read::read(&mut reader, &mut buffer) {
1163                Ok(0) => None,
1164                Ok(n) => {
1165                    buffer.truncate(n);
1166                    Some(Bytes::from(buffer))
1167                }
1168                Err(e) => {
1169                    let mut guard = read_error_clone
1170                        .lock()
1171                        .unwrap_or_else(|poisoned| poisoned.into_inner());
1172                    *guard = Some(e);
1173                    None
1174                }
1175            }
1176        });
1177
1178        let mut stream = stream_encrypt(data_size, data_iter)
1179            .map_err(|e| Error::Encryption(format!("stream_encrypt failed: {e}")))?;
1180
1181        for chunk_result in stream.chunks() {
1182            // Check for captured read errors immediately after each chunk.
1183            // stream_encrypt sees None (EOF) when a read fails, so it stops
1184            // producing chunks. We must detect this before sending the
1185            // partial results to avoid uploading a truncated DataMap.
1186            {
1187                let guard = read_error
1188                    .lock()
1189                    .unwrap_or_else(|poisoned| poisoned.into_inner());
1190                if let Some(ref e) = *guard {
1191                    return Err(Error::Io(std::io::Error::new(e.kind(), e.to_string())));
1192                }
1193            }
1194
1195            let (_hash, content) = chunk_result
1196                .map_err(|e| Error::Encryption(format!("chunk encryption failed: {e}")))?;
1197            if chunk_tx.blocking_send(content).is_err() {
1198                return Err(Error::Encryption("upload receiver dropped".to_string()));
1199            }
1200        }
1201
1202        // Final check: read error after last chunk (stream saw EOF).
1203        {
1204            let guard = read_error
1205                .lock()
1206                .unwrap_or_else(|poisoned| poisoned.into_inner());
1207            if let Some(ref e) = *guard {
1208                return Err(Error::Io(std::io::Error::new(e.kind(), e.to_string())));
1209            }
1210        }
1211
1212        let datamap = stream
1213            .into_datamap()
1214            .ok_or_else(|| Error::Encryption("no DataMap after encryption".to_string()))?;
1215        if datamap_tx.send(datamap).is_err() {
1216            warn!("DataMap receiver dropped — upload may have been cancelled");
1217        }
1218        Ok(())
1219    });
1220
1221    Ok((chunk_rx, datamap_rx, handle))
1222}
1223
1224/// RAII guard for the staging temp file used during a disk download.
1225///
1226/// Removes the file on drop — including a panic unwind out of the
1227/// `block_in_place` decrypt loop — unless [`commit`](Self::commit) has
1228/// promoted it to its final path. Centralizes the cleanup the explicit error
1229/// arms used to repeat.
1230struct TempDownload {
1231    /// `Some` while the staging file may need cleanup; `None` once committed.
1232    path: Option<PathBuf>,
1233}
1234
1235impl TempDownload {
1236    fn new(path: PathBuf) -> Self {
1237        Self { path: Some(path) }
1238    }
1239
1240    /// Path of the staging file (valid until `commit`).
1241    fn path(&self) -> &Path {
1242        self.path
1243            .as_deref()
1244            .expect("TempDownload::path called after commit")
1245    }
1246
1247    /// Rename the staged file to `dest`. On success the guard is defused so
1248    /// `Drop` is a no-op; on failure the guard stays armed and `Drop` removes
1249    /// the orphaned temp file.
1250    fn commit(mut self, dest: &Path) -> std::io::Result<()> {
1251        std::fs::rename(self.path(), dest)?; // err → guard armed → Drop cleans up
1252        self.path = None; // success → nothing left to clean
1253        Ok(())
1254    }
1255}
1256
1257impl Drop for TempDownload {
1258    fn drop(&mut self) {
1259        if let Some(path) = self.path.take() {
1260            if let Err(e) = std::fs::remove_file(&path) {
1261                // Absent file is fine (never created / already gone).
1262                if e.kind() != std::io::ErrorKind::NotFound {
1263                    warn!(
1264                        "Failed to remove temp download file {}: {e}",
1265                        path.display()
1266                    );
1267                }
1268            }
1269        }
1270    }
1271}
1272
1273impl Client {
1274    /// Upload a file to the network using streaming self-encryption.
1275    ///
1276    /// Automatically selects merkle batch payment for files that produce
1277    /// 64+ chunks (saves gas). Encrypted chunks are spilled to a temp
1278    /// directory so peak memory stays at ~256 MB regardless of file size.
1279    ///
1280    /// # Errors
1281    ///
1282    /// Returns an error if the file cannot be read, encryption fails,
1283    /// or any chunk cannot be stored.
1284    pub async fn file_upload(&self, path: &Path) -> Result<FileUploadResult> {
1285        self.file_upload_with_mode(path, PaymentMode::Auto).await
1286    }
1287
1288    /// Estimate the cost of uploading a file without actually uploading.
1289    ///
1290    /// Encrypts the file to determine chunk count and sizes, then requests
1291    /// a single quote from the network for a representative chunk. The
1292    /// per-chunk price is extrapolated to the total chunk count.
1293    ///
1294    /// The estimate is fast (~2-5s) and does not require a wallet. Spilled
1295    /// chunks are cleaned up automatically when the function returns.
1296    ///
1297    /// Gas cost is an advisory heuristic, not a live gas-oracle query. It is
1298    /// derived from realistic per-transaction budgets (`GAS_PER_WAVE_TX`,
1299    /// `GAS_PER_MERKLE_TX`) priced at `ARBITRUM_GAS_PRICE_WEI`. Real gas
1300    /// varies with network conditions.
1301    ///
1302    /// Sampled chunk addresses are spread across the whole file (not the first
1303    /// N) so a shared leading prefix doesn't bias the sample. When a sample
1304    /// returns a live quote the per-chunk price is extrapolated and the result
1305    /// is tagged [`CostEstimateConfidence::PricedSample`].
1306    ///
1307    /// When every sampled chunk is already stored the result is still `Ok`
1308    /// with `storage_cost_atto: "0"`, tagged either
1309    /// [`CostEstimateConfidence::VerifiedAllAlreadyStored`] when the whole file
1310    /// was sampled (exactly free) or
1311    /// [`CostEstimateConfidence::AllSamplesAlreadyStoredIncomplete`] when the
1312    /// tail was unsampled (a best-effort guess that payment reconciles).
1313    ///
1314    /// # Errors
1315    ///
1316    /// Returns an error if the file cannot be read, encryption fails, or the
1317    /// network cannot provide a quote.
1318    pub async fn estimate_upload_cost(
1319        &self,
1320        path: &Path,
1321        mode: PaymentMode,
1322        progress: Option<mpsc::Sender<UploadEvent>>,
1323    ) -> Result<UploadCostEstimate> {
1324        let file_size = std::fs::metadata(path).map_err(Error::Io)?.len();
1325
1326        if file_size < 3 {
1327            return Err(Error::InvalidData(
1328                "File too small: self-encryption requires at least 3 bytes".into(),
1329            ));
1330        }
1331
1332        check_disk_space_for_spill(file_size)?;
1333
1334        info!(
1335            "Estimating upload cost for {} ({file_size} bytes)",
1336            path.display()
1337        );
1338
1339        let (spill, _data_map) = self.encrypt_file_to_spill(path, progress.as_ref()).await?;
1340        let chunk_count = spill.len();
1341
1342        if let Some(ref tx) = progress {
1343            let _ = tx
1344                .send(UploadEvent::Encrypted {
1345                    total_chunks: chunk_count,
1346                })
1347                .await;
1348        }
1349
1350        info!("Encrypted into {chunk_count} chunks, requesting quote");
1351        let uses_merkle = should_use_merkle(chunk_count, mode);
1352
1353        // Sample chunk addresses spread evenly across the file (see
1354        // `distributed_sample_indices`) rather than the first N. A single
1355        // AlreadyStored result says nothing about the rest of the file, and a
1356        // positional sample lands on a shared leading prefix in the worst case,
1357        // so we spread the probe and only treat the whole file as "fully
1358        // stored" when every sample comes back stored.
1359        let sample_indices = distributed_sample_indices(spill.addresses.len(), ESTIMATE_SAMPLE_CAP);
1360        let mut sampled = 0usize;
1361        let mut all_already_stored = true;
1362        let mut quotes_opt: Option<Vec<QuoteEntry>> = None;
1363
1364        for &idx in &sample_indices {
1365            let addr = &spill.addresses[idx];
1366            sampled += 1;
1367            let chunk_bytes = spill.read_chunk(addr)?;
1368            let data_size = u64::try_from(chunk_bytes.len())
1369                .map_err(|e| Error::InvalidData(format!("chunk size too large: {e}")))?;
1370            let result = if uses_merkle {
1371                self.get_store_quotes_with_fault_tolerance(addr, data_size, DATA_TYPE_CHUNK)
1372                    .await
1373            } else {
1374                self.get_store_quotes(addr, data_size, DATA_TYPE_CHUNK)
1375                    .await
1376            };
1377            match result {
1378                Ok(q) => {
1379                    quotes_opt = Some(q);
1380                    all_already_stored = false;
1381                    break;
1382                }
1383                Err(Error::AlreadyStored) => {
1384                    debug!(
1385                        "Sample chunk {} already stored; trying next address ({sampled}/{})",
1386                        hex::encode(addr),
1387                        sample_indices.len()
1388                    );
1389                    continue;
1390                }
1391                Err(e) => return Err(e),
1392            }
1393        }
1394
1395        let quotes = match quotes_opt {
1396            Some(q) => q,
1397            None if all_already_stored && sampled == chunk_count => {
1398                // Every address in the file was sampled and every one is
1399                // already on the network — a zero-cost estimate is exact here.
1400                info!("All {chunk_count} chunks already stored; returning zero-cost estimate");
1401                return Ok(UploadCostEstimate {
1402                    file_size,
1403                    chunk_count,
1404                    storage_cost_atto: "0".into(),
1405                    estimated_gas_cost_wei: "0".into(),
1406                    payment_mode: if uses_merkle {
1407                        PaymentMode::Merkle
1408                    } else {
1409                        PaymentMode::Single
1410                    },
1411                    confidence: CostEstimateConfidence::VerifiedAllAlreadyStored,
1412                });
1413            }
1414            None => {
1415                // Every sampled chunk was already stored but the tail was not
1416                // sampled, so there is no live price to extrapolate. The
1417                // estimate is display-only and payment reconciles the true
1418                // cost, so return an optimistic zero flagged as incomplete
1419                // rather than erroring — callers still get a value to show.
1420                info!(
1421                    "All {sampled}/{chunk_count} sampled chunks already stored; \
1422                     returning incomplete zero-cost estimate"
1423                );
1424                return Ok(UploadCostEstimate {
1425                    file_size,
1426                    chunk_count,
1427                    storage_cost_atto: "0".into(),
1428                    estimated_gas_cost_wei: "0".into(),
1429                    payment_mode: if uses_merkle {
1430                        PaymentMode::Merkle
1431                    } else {
1432                        PaymentMode::Single
1433                    },
1434                    confidence: CostEstimateConfidence::AllSamplesAlreadyStoredIncomplete,
1435                });
1436            }
1437        };
1438
1439        // Use the median price × 3, matching the settlement multiplier both
1440        // payment paths now apply.
1441        let mut prices: Vec<Amount> = quotes.iter().map(|(_, _, _, price, _)| *price).collect();
1442        prices.sort();
1443        let median_price = prices
1444            .get(prices.len() / 2)
1445            .copied()
1446            .unwrap_or(Amount::ZERO);
1447        let per_chunk_cost = median_price * Amount::from(SINGLE_NODE_PAYMENT_MULTIPLIER);
1448
1449        let chunk_count_u64 = u64::try_from(chunk_count).unwrap_or(u64::MAX);
1450        // Merkle settles per *padded* leaf, not per chunk: the contract charges
1451        // `median16 × 2^depth` per batch and the tree rounds up to a power of
1452        // two, so a 65-chunk batch pays for 128 leaves. The leaf total is
1453        // summed over the batches the payment path really builds
1454        // (`merkle_batch_sizes`), so the estimate cannot drift from execution.
1455        let billable_units = if uses_merkle {
1456            merkle_billable_leaves(chunk_count_u64)
1457        } else {
1458            chunk_count_u64
1459        };
1460        let total_storage = per_chunk_cost * Amount::from(billable_units);
1461
1462        // Estimate gas cost from realistic per-transaction budgets rather
1463        // than a flat per-chunk or per-wave number.
1464        //
1465        // - Single mode: `batch_pay` packs up to UPLOAD_WAVE_SIZE chunks'
1466        //   close-group quotes into one `pay_for_quotes` call on Arbitrum.
1467        //   The dominant cost is one SSTORE per entry plus base tx overhead,
1468        //   so we use GAS_PER_WAVE_TX (≈1.5M) as a conservative upper bound
1469        //   on a full wave and multiply by the number of waves. The previous
1470        //   per-wave figure of 150k was closer to a single-entry transfer
1471        //   and understated cost by 5–10x for full waves.
1472        // - Merkle mode: one tx per sub-batch that verifies a merkle tree
1473        //   and posts a pool commitment (GAS_PER_MERKLE_TX ≈ 500k each).
1474        //
1475        // Gas is priced at ARBITRUM_GAS_PRICE_WEI (~0.1 gwei, a typical
1476        // Arbitrum baseline). Treat the result as advisory, not a commitment.
1477        let waves = u128::try_from(chunk_count.div_ceil(UPLOAD_WAVE_SIZE)).unwrap_or(u128::MAX);
1478        // One tx per batch the payment path builds — same partition the leaf
1479        // total above is derived from.
1480        let merkle_batches =
1481            u128::try_from(merkle_batch_sizes(chunk_count).len()).unwrap_or(u128::MAX);
1482        let estimated_gas: u128 = if uses_merkle {
1483            merkle_batches
1484                .saturating_mul(GAS_PER_MERKLE_TX)
1485                .saturating_mul(ARBITRUM_GAS_PRICE_WEI)
1486        } else {
1487            waves
1488                .saturating_mul(GAS_PER_WAVE_TX)
1489                .saturating_mul(ARBITRUM_GAS_PRICE_WEI)
1490        };
1491
1492        info!(
1493            "Estimate: {chunk_count} chunks, storage={total_storage} atto, gas~={estimated_gas} wei"
1494        );
1495
1496        Ok(UploadCostEstimate {
1497            file_size,
1498            chunk_count,
1499            storage_cost_atto: total_storage.to_string(),
1500            estimated_gas_cost_wei: estimated_gas.to_string(),
1501            payment_mode: if uses_merkle {
1502                PaymentMode::Merkle
1503            } else {
1504                PaymentMode::Single
1505            },
1506            confidence: CostEstimateConfidence::PricedSample,
1507        })
1508    }
1509
1510    /// Phase 1 of external-signer upload: encrypt file and prepare chunks.
1511    ///
1512    /// Equivalent to [`Client::file_prepare_upload_with_visibility`] with
1513    /// [`Visibility::Private`] — see that method for details.
1514    pub async fn file_prepare_upload(&self, path: &Path) -> Result<PreparedUpload> {
1515        self.file_prepare_upload_with_progress(path, Visibility::Private, None)
1516            .await
1517    }
1518
1519    /// Phase 1 of external-signer upload with explicit [`Visibility`] control.
1520    ///
1521    /// Equivalent to [`Client::file_prepare_upload_with_progress`] with
1522    /// `progress: None` — see that method for details.
1523    pub async fn file_prepare_upload_with_visibility(
1524        &self,
1525        path: &Path,
1526        visibility: Visibility,
1527    ) -> Result<PreparedUpload> {
1528        self.file_prepare_upload_with_progress(path, visibility, None)
1529            .await
1530    }
1531
1532    /// Phase 1 of external-signer upload with progress events.
1533    ///
1534    /// Equivalent to [`Client::file_prepare_upload_with_mode`] with
1535    /// [`PaymentMode::Auto`] — see that method for details.
1536    pub async fn file_prepare_upload_with_progress(
1537        &self,
1538        path: &Path,
1539        visibility: Visibility,
1540        progress: Option<mpsc::Sender<UploadEvent>>,
1541    ) -> Result<PreparedUpload> {
1542        self.file_prepare_upload_with_mode(path, visibility, PaymentMode::Auto, progress)
1543            .await
1544    }
1545
1546    /// Phase 1 of external-signer upload with an explicit [`PaymentMode`].
1547    ///
1548    /// Requires an EVM network (for contract price queries) but NOT a wallet.
1549    /// Returns a [`PreparedUpload`] containing the data map and either a
1550    /// [`PaymentIntent`] (wave-batch) or prepared merkle sub-batches that
1551    /// the external signer uses to construct and submit the on-chain payment
1552    /// transaction(s) — one per sub-batch (ADR-0003).
1553    ///
1554    /// `mode` mirrors the wallet path's [`Client::file_upload_with_mode`]:
1555    /// [`PaymentMode::Auto`] picks merkle at the chunk threshold,
1556    /// [`PaymentMode::Merkle`] forces merkle for ≥ 2 upload chunks (this is
1557    /// how tests exercise the external merkle flow with small files), and
1558    /// [`PaymentMode::Single`] forces wave-batch.
1559    ///
1560    /// When `visibility` is [`Visibility::Public`], the serialized `DataMap`
1561    /// is bundled into the payment batch as an additional chunk and its
1562    /// address is recorded on the returned [`PreparedUpload`]. After
1563    /// [`Client::finalize_upload`] (or `_merkle`) succeeds, that address is
1564    /// surfaced via [`FileUploadResult::data_map_address`] so the uploader
1565    /// can share a single address from which anyone can retrieve the file.
1566    ///
1567    /// When `progress` is `Some`, [`UploadEvent`]s are emitted on the channel
1568    /// during encryption ([`UploadEvent::Encrypting`] / [`UploadEvent::Encrypted`])
1569    /// and per-chunk quoting ([`UploadEvent::ChunkQuoted`]). Storage events are
1570    /// emitted later by [`Client::finalize_upload_with_progress`] /
1571    /// [`Client::finalize_upload_merkle_with_progress`].
1572    ///
1573    /// **Memory note:** on the merkle path, chunk bodies stay in the on-disk
1574    /// encryption spill inside the returned [`PreparedUpload`] and are read
1575    /// back ≤ store-cap at a time during finalize, so peak RAM stays bounded
1576    /// (~256 MB) regardless of file size (ADR-0003). The spill directory
1577    /// lives as long as the `PreparedUpload` does. The wave-batch path —
1578    /// below the merkle threshold, so < ~64 × 4 MiB of chunks (unless
1579    /// [`PaymentMode::Single`] forces it for a larger file) — still holds
1580    /// its chunk bodies resident.
1581    ///
1582    /// # Errors
1583    ///
1584    /// Returns an error if there is insufficient disk space, the file cannot
1585    /// be read, encryption fails, or quote collection fails.
1586    pub async fn file_prepare_upload_with_mode(
1587        &self,
1588        path: &Path,
1589        visibility: Visibility,
1590        mode: PaymentMode,
1591        progress: Option<mpsc::Sender<UploadEvent>>,
1592    ) -> Result<PreparedUpload> {
1593        debug!(
1594            "Preparing file upload for external signing (visibility={visibility:?}, mode={mode:?}): {}",
1595            path.display()
1596        );
1597
1598        let file_size = std::fs::metadata(path)?.len();
1599        check_disk_space_for_spill(file_size)?;
1600
1601        let (mut spill, data_map) = self.encrypt_file_to_spill(path, progress.as_ref()).await?;
1602
1603        info!(
1604            "Encrypted {} into {} chunks for external signing (spilled to disk)",
1605            path.display(),
1606            spill.len()
1607        );
1608
1609        // For public uploads, bundle the serialized DataMap as an extra chunk
1610        // in the same payment batch. This lets the external signer pay for
1611        // the data chunks and the DataMap chunk in one flow, and lets the
1612        // finalize step return the DataMap's chunk address as the shareable
1613        // retrieval address. It joins the spill like any data chunk so the
1614        // merkle path stays disk-backed; `push` dedups by address.
1615        let data_map_address = match visibility {
1616            Visibility::Private => None,
1617            Visibility::Public => {
1618                let serialized = rmp_serde::to_vec(&data_map).map_err(|e| {
1619                    Error::Serialization(format!("Failed to serialize DataMap: {e}"))
1620                })?;
1621                let address = compute_address(&serialized);
1622                info!(
1623                    "Public upload: bundling DataMap chunk ({} bytes) at address {}",
1624                    serialized.len(),
1625                    hex::encode(address)
1626                );
1627                spill.push(&serialized)?;
1628                Some(address)
1629            }
1630        };
1631
1632        let chunk_count = spill.len();
1633
1634        if let Some(ref tx) = progress {
1635            let _ = tx
1636                .send(UploadEvent::Encrypted {
1637                    total_chunks: chunk_count,
1638                })
1639                .await;
1640        }
1641
1642        let (payment_info, already_stored_addresses) = if should_use_merkle(chunk_count, mode) {
1643            // Merkle path: build tree(s), collect candidate pools, return for
1644            // external payment. Chunk bodies stay in the spill on disk.
1645            info!("Using merkle batch preparation for {chunk_count} file chunks");
1646
1647            let chunk_entries = spill.chunk_entries()?;
1648
1649            let merkle_plan = self
1650                .plan_merkle_upload(chunk_entries, DATA_TYPE_CHUNK, progress.as_ref())
1651                .await?;
1652
1653            if merkle_plan.to_upload.is_empty() {
1654                info!("All {chunk_count} file chunks already stored; no external payment needed");
1655                (
1656                    ExternalPaymentInfo::WaveBatch {
1657                        prepared_chunks: Vec::new(),
1658                        payment_intent: PaymentIntent::from_prepared_chunks(&[]),
1659                    },
1660                    merkle_plan.already_stored,
1661                )
1662            } else if !should_use_merkle(merkle_plan.to_upload.len(), mode) {
1663                info!(
1664                    "{} file chunks need upload after merkle preflight; preparing wave-batch payment",
1665                    merkle_plan.to_upload.len()
1666                );
1667                let chunk_data = spill.read_chunks(&merkle_plan.to_upload)?;
1668                let (payment_info, mut wave_already_stored) = self
1669                    .prepare_wave_batch_external_chunks(chunk_data, progress.as_ref(), chunk_count)
1670                    .await?;
1671                let mut already_stored = merkle_plan.already_stored;
1672                already_stored.append(&mut wave_already_stored);
1673                (payment_info, already_stored)
1674            } else {
1675                // One signature pays one tree, so the to-upload set is
1676                // partitioned into `MerkleTree`-sized sub-batches and the
1677                // signer pays each — the external equivalent of the wallet
1678                // path's multi-transaction split (ADR-0003).
1679                match self
1680                    .prepare_merkle_batches_external(
1681                        &merkle_plan.to_upload,
1682                        DATA_TYPE_CHUNK,
1683                        merkle_plan.to_upload_avg_size(),
1684                        self.merkle_external_batch_cap(),
1685                    )
1686                    .await
1687                {
1688                    Ok(prepared_batches) => {
1689                        info!(
1690                            "File prepared for external merkle signing: {} chunks in {} sub-batch(es) ({})",
1691                            merkle_plan.to_upload.len(),
1692                            prepared_batches.len(),
1693                            path.display()
1694                        );
1695
1696                        (
1697                            ExternalPaymentInfo::Merkle {
1698                                prepared_batches,
1699                                chunk_store: ExternalChunkStore::from_spill(spill),
1700                                chunk_addresses: merkle_plan.to_upload,
1701                            },
1702                            merkle_plan.already_stored,
1703                        )
1704                    }
1705                    Err(Error::InsufficientPeers(ref msg)) => {
1706                        info!(
1707                            "External merkle preparation needs more peers ({msg}); preparing wave-batch payment"
1708                        );
1709                        let chunk_data = spill.read_chunks(&merkle_plan.to_upload)?;
1710                        let (payment_info, mut wave_already_stored) = self
1711                            .prepare_wave_batch_external_chunks(
1712                                chunk_data,
1713                                progress.as_ref(),
1714                                chunk_count,
1715                            )
1716                            .await?;
1717                        let mut already_stored = merkle_plan.already_stored;
1718                        already_stored.append(&mut wave_already_stored);
1719                        (payment_info, already_stored)
1720                    }
1721                    Err(e) => return Err(e),
1722                }
1723            }
1724        } else {
1725            // Wave path: below the merkle threshold (or PaymentMode::Single),
1726            // chunk bodies come back resident for per-chunk quoting.
1727            let chunk_data = spill.read_all_chunks()?;
1728            self.prepare_wave_batch_external_chunks(chunk_data, progress.as_ref(), chunk_count)
1729                .await?
1730        };
1731
1732        // Surface the "DataMap chunk was already on the network" case
1733        // so debugging "why is data_map_address set but no storage cost
1734        // appears for it?" doesn't require reading the source. See the
1735        // `data_map_address` doc comment for why this is still a valid
1736        // `Some(addr)` outcome.
1737        if let Some(addr) = data_map_address {
1738            let data_map_needs_payment = match &payment_info {
1739                ExternalPaymentInfo::WaveBatch {
1740                    prepared_chunks, ..
1741                } => prepared_chunks.iter().any(|c| c.address == addr),
1742                ExternalPaymentInfo::Merkle {
1743                    chunk_addresses, ..
1744                } => chunk_addresses.contains(&addr),
1745            };
1746            if !data_map_needs_payment {
1747                info!(
1748                    "Public upload: DataMap chunk {} was already stored \
1749                     on the network — address is retrievable without a \
1750                     new payment",
1751                    hex::encode(addr)
1752                );
1753            }
1754        }
1755
1756        Ok(PreparedUpload {
1757            data_map,
1758            payment_info,
1759            data_map_address,
1760            already_stored_addresses,
1761            total_chunks: chunk_count,
1762        })
1763    }
1764
1765    async fn prepare_wave_batch_external_chunks(
1766        &self,
1767        chunk_data: Vec<Bytes>,
1768        progress: Option<&mpsc::Sender<UploadEvent>>,
1769        progress_total: usize,
1770    ) -> Result<(ExternalPaymentInfo, Vec<[u8; 32]>)> {
1771        let chunk_count = chunk_data.len();
1772        let chunks_with_addr: Vec<(Bytes, [u8; 32])> = chunk_data
1773            .into_iter()
1774            .map(|content| {
1775                let address = compute_address(&content);
1776                (content, address)
1777            })
1778            .collect();
1779
1780        // Wave-batch path: collect quotes per chunk concurrently, emitting
1781        // a `ChunkQuoted` event after each completion so callers can drive
1782        // a progress bar through the slow quote phase.
1783        let quote_limiter = self.controller().quote.clone();
1784        let quote_concurrency = quote_limiter.current().min(chunk_count.max(1));
1785        let mut quote_stream = stream::iter(chunks_with_addr)
1786            .map(|(content, address)| {
1787                let limiter = quote_limiter.clone();
1788                async move {
1789                    let result = observe_op(
1790                        &limiter,
1791                        || async move { self.prepare_chunk_payment(content).await },
1792                        classify_error,
1793                    )
1794                    .await;
1795                    (address, result)
1796                }
1797            })
1798            .buffer_unordered(quote_concurrency);
1799
1800        let mut prepared_chunks = Vec::with_capacity(chunk_count);
1801        let mut already_stored = Vec::new();
1802        let mut quoted = 0usize;
1803        while let Some((address, result)) = quote_stream.next().await {
1804            match result? {
1805                Some(prepared) => prepared_chunks.push(prepared),
1806                None => already_stored.push(address),
1807            }
1808            quoted += 1;
1809            if let Some(tx) = progress {
1810                let _ = tx.try_send(UploadEvent::ChunkQuoted {
1811                    quoted,
1812                    total: progress_total,
1813                });
1814            }
1815        }
1816
1817        let payment_intent = PaymentIntent::from_prepared_chunks(&prepared_chunks);
1818        info!(
1819            "Prepared external wave-batch payment: {} chunks, {} already stored, total {} atto",
1820            prepared_chunks.len(),
1821            already_stored.len(),
1822            payment_intent.total_amount,
1823        );
1824
1825        Ok((
1826            ExternalPaymentInfo::WaveBatch {
1827                prepared_chunks,
1828                payment_intent,
1829            },
1830            already_stored,
1831        ))
1832    }
1833
1834    /// Phase 2 of external-signer upload (wave-batch): finalize with externally-signed tx hashes.
1835    ///
1836    /// Takes a [`PreparedUpload`] that used wave-batch payment and a map
1837    /// of `quote_hash -> tx_hash` provided by the external signer after on-chain
1838    /// payment. Builds payment proofs and stores chunks on the network.
1839    ///
1840    /// # Errors
1841    ///
1842    /// Returns an error if the prepared upload used merkle payment (use
1843    /// [`Client::finalize_upload_merkle`] instead), proof construction fails,
1844    /// or any chunk cannot be stored.
1845    pub async fn finalize_upload(
1846        &self,
1847        prepared: PreparedUpload,
1848        tx_hash_map: &HashMap<QuoteHash, TxHash>,
1849    ) -> Result<FileUploadResult> {
1850        self.finalize_upload_with_progress(prepared, tx_hash_map, None)
1851            .await
1852    }
1853
1854    /// Phase 2 of external-signer upload (wave-batch) with progress events.
1855    ///
1856    /// Same as [`Client::finalize_upload`] but emits [`UploadEvent::ChunkStored`]
1857    /// on the provided channel as each chunk is successfully stored.
1858    ///
1859    /// # Errors
1860    ///
1861    /// Same as [`Client::finalize_upload`].
1862    pub async fn finalize_upload_with_progress(
1863        &self,
1864        prepared: PreparedUpload,
1865        tx_hash_map: &HashMap<QuoteHash, TxHash>,
1866        progress: Option<mpsc::Sender<UploadEvent>>,
1867    ) -> Result<FileUploadResult> {
1868        let data_map_address = prepared.data_map_address;
1869        let already_stored_addresses = prepared.already_stored_addresses;
1870        let already_stored_count = already_stored_addresses.len();
1871        let total_chunks = prepared.total_chunks;
1872        match prepared.payment_info {
1873            ExternalPaymentInfo::WaveBatch {
1874                prepared_chunks,
1875                payment_intent,
1876            } => {
1877                let paid_chunks = finalize_batch_payment(prepared_chunks, tx_hash_map)?;
1878                let wave_result = self
1879                    .store_paid_chunks_with_events(
1880                        paid_chunks,
1881                        progress.as_ref(),
1882                        already_stored_count,
1883                        total_chunks,
1884                    )
1885                    .await;
1886                if !wave_result.failed.is_empty() {
1887                    let failed_count = wave_result.failed.len();
1888                    let stored_count = already_stored_count + wave_result.stored.len();
1889                    let mut stored = already_stored_addresses;
1890                    stored.extend(wave_result.stored);
1891                    return Err(Error::PartialUpload {
1892                        stored,
1893                        stored_count,
1894                        failed: wave_result.failed,
1895                        failed_count,
1896                        total_chunks,
1897                        // Report the storage spend known from the payment intent
1898                        // the external signer was handed. Gas is paid by the
1899                        // signer out-of-band, so it stays unknown (0).
1900                        spend: Box::new(PartialUploadSpend {
1901                            storage_cost_atto: payment_intent.total_amount.to_string(),
1902                            gas_cost_wei: 0,
1903                        }),
1904                        reason: "finalize_upload: chunk storage failed after retries".into(),
1905                    });
1906                }
1907                let chunks_stored = already_stored_count + wave_result.stored.len();
1908
1909                info!("External-signer upload finalized: {chunks_stored} chunks stored");
1910
1911                let mut stats = WaveAggregateStats::default();
1912                stats.absorb(&wave_result);
1913
1914                Ok(FileUploadResult {
1915                    data_map: prepared.data_map,
1916                    chunks_stored,
1917                    chunks_failed: 0,
1918                    total_chunks,
1919                    payment_mode_used: PaymentMode::Single,
1920                    // Storage spend is known from the payment intent; gas is
1921                    // paid by the external signer out-of-band (unknown here).
1922                    storage_cost_atto: payment_intent.total_amount.to_string(),
1923                    gas_cost_wei: 0,
1924                    data_map_address,
1925                    chunk_attempts_total: stats.chunk_attempts_total,
1926                    store_durations_ms: stats.store_durations_ms,
1927                    retries_histogram: stats.retries_histogram,
1928                })
1929            }
1930            ExternalPaymentInfo::Merkle { .. } => Err(Error::Payment(
1931                "Cannot finalize merkle upload with wave-batch tx hashes. \
1932                 Use finalize_upload_merkle() instead."
1933                    .to_string(),
1934            )),
1935        }
1936    }
1937
1938    /// Per-batch leaf cap for external merkle preparation: the configured
1939    /// test override clamped to `3..=MAX_LEAVES` (see
1940    /// [`merkle_batch_sizes_with_cap`] for why 3 is the floor), or
1941    /// `MAX_LEAVES` (ADR-0003).
1942    fn merkle_external_batch_cap(&self) -> usize {
1943        self.config()
1944            .merkle_external_batch_cap
1945            .map_or(MAX_LEAVES, |cap| cap.clamp(3, MAX_LEAVES))
1946    }
1947
1948    /// Phase 2 of external-signer upload (merkle): finalize with winner pool hash.
1949    ///
1950    /// The single-batch special case of
1951    /// [`Client::finalize_upload_merkle_multi`]: valid only for uploads that
1952    /// prepared as exactly one merkle sub-batch (any fresh upload below
1953    /// `MAX_LEAVES` chunks). Generates proofs and stores chunks on the
1954    /// network.
1955    ///
1956    /// # Errors
1957    ///
1958    /// Returns an error if the prepared upload used wave-batch payment (use
1959    /// [`Client::finalize_upload`] instead), was prepared as more than one
1960    /// sub-batch (use [`Client::finalize_upload_merkle_multi`]), or proof
1961    /// generation fails. Chunks still short of quorum after all retries
1962    /// surface as [`Error::PartialUpload`] carrying the stored and failed
1963    /// addresses — the same contract as [`Client::finalize_upload`].
1964    /// Re-preparing the same file skips chunks that are already stored.
1965    pub async fn finalize_upload_merkle(
1966        &self,
1967        prepared: PreparedUpload,
1968        winner_pool_hash: [u8; 32],
1969    ) -> Result<FileUploadResult> {
1970        self.finalize_upload_merkle_with_progress(prepared, winner_pool_hash, None)
1971            .await
1972    }
1973
1974    /// Phase 2 of external-signer upload (merkle) with progress events.
1975    ///
1976    /// Same as [`Client::finalize_upload_merkle`] but emits [`UploadEvent::ChunkStored`]
1977    /// on the provided channel as each chunk is successfully stored.
1978    ///
1979    /// # Errors
1980    ///
1981    /// Same as [`Client::finalize_upload_merkle`].
1982    pub async fn finalize_upload_merkle_with_progress(
1983        &self,
1984        prepared: PreparedUpload,
1985        winner_pool_hash: [u8; 32],
1986        progress: Option<mpsc::Sender<UploadEvent>>,
1987    ) -> Result<FileUploadResult> {
1988        if let ExternalPaymentInfo::Merkle {
1989            prepared_batches, ..
1990        } = &prepared.payment_info
1991        {
1992            let batches = prepared_batches.len();
1993            if batches != 1 {
1994                return Err(Error::Payment(format!(
1995                    "This upload was prepared as {batches} merkle sub-batches; \
1996                     pay each and call finalize_upload_merkle_multi() with one \
1997                     winner hash per batch."
1998                )));
1999            }
2000        }
2001        self.finalize_upload_merkle_multi_with_progress(
2002            prepared,
2003            vec![Some(winner_pool_hash)],
2004            progress,
2005        )
2006        .await
2007    }
2008
2009    /// Phase 2 of external-signer upload (merkle): finalize with one winner
2010    /// pool hash per prepared sub-batch.
2011    ///
2012    /// `winner_pool_hashes` aligns with
2013    /// [`ExternalPaymentInfo::Merkle::prepared_batches`]: entry `i` is the
2014    /// `MerklePaymentMade` winner hash of batch `i`'s on-chain payment, or
2015    /// `None` if the signer never paid that batch (e.g. the user abandoned
2016    /// the flow midway). Paid batches make forward progress: their proofs
2017    /// are folded — mirroring the wallet path's multi-batch fold — and their
2018    /// chunks stored from the on-disk spill in a bounded fan-out; chunks of
2019    /// unpaid batches are reported through [`Error::PartialUpload`]
2020    /// (ADR-0003).
2021    ///
2022    /// # Errors
2023    ///
2024    /// Returns an error if the prepared upload used wave-batch payment, the
2025    /// hash count does not match the batch count, every entry is `None`, or
2026    /// proof generation fails. Chunks short of quorum after all retries —
2027    /// and all chunks of unpaid batches — surface as
2028    /// [`Error::PartialUpload`] carrying the stored and failed addresses.
2029    /// Re-preparing the same file skips chunks that are already stored.
2030    pub async fn finalize_upload_merkle_multi(
2031        &self,
2032        prepared: PreparedUpload,
2033        winner_pool_hashes: Vec<Option<[u8; 32]>>,
2034    ) -> Result<FileUploadResult> {
2035        self.finalize_upload_merkle_multi_with_progress(prepared, winner_pool_hashes, None)
2036            .await
2037    }
2038
2039    /// Same as [`Client::finalize_upload_merkle_multi`] but emits
2040    /// [`UploadEvent::ChunkStored`] on the provided channel as each chunk is
2041    /// successfully stored.
2042    ///
2043    /// # Errors
2044    ///
2045    /// Same as [`Client::finalize_upload_merkle_multi`].
2046    pub async fn finalize_upload_merkle_multi_with_progress(
2047        &self,
2048        prepared: PreparedUpload,
2049        winner_pool_hashes: Vec<Option<[u8; 32]>>,
2050        progress: Option<mpsc::Sender<UploadEvent>>,
2051    ) -> Result<FileUploadResult> {
2052        let data_map_address = prepared.data_map_address;
2053        let already_stored_addresses = prepared.already_stored_addresses;
2054        let total_chunks = prepared.total_chunks;
2055        match prepared.payment_info {
2056            ExternalPaymentInfo::Merkle {
2057                prepared_batches,
2058                chunk_store,
2059                chunk_addresses,
2060            } => {
2061                let batch_result =
2062                    fold_external_merkle_payments(prepared_batches, winner_pool_hashes)?;
2063
2064                let (chunks_stored, _storage_cost, _gas_cost, stats) = self
2065                    .upload_merkle_from_spill(
2066                        chunk_store.spill(),
2067                        &chunk_addresses,
2068                        &batch_result,
2069                        &already_stored_addresses,
2070                        progress.as_ref(),
2071                    )
2072                    .await?;
2073
2074                info!("External-signer merkle upload finalized: {chunks_stored} chunks stored");
2075
2076                Ok(FileUploadResult {
2077                    data_map: prepared.data_map,
2078                    chunks_stored,
2079                    chunks_failed: 0,
2080                    total_chunks,
2081                    payment_mode_used: PaymentMode::Merkle,
2082                    // The external signer pays on-chain out-of-band, so the
2083                    // spend is unknown to the library here.
2084                    storage_cost_atto: "0".into(),
2085                    gas_cost_wei: 0,
2086                    data_map_address,
2087                    chunk_attempts_total: stats.chunk_attempts_total,
2088                    store_durations_ms: stats.store_durations_ms,
2089                    retries_histogram: stats.retries_histogram,
2090                })
2091            }
2092            ExternalPaymentInfo::WaveBatch { .. } => Err(Error::Payment(
2093                "Cannot finalize wave-batch upload with merkle winner hashes. \
2094                 Use finalize_upload() instead."
2095                    .to_string(),
2096            )),
2097        }
2098    }
2099
2100    /// Upload a file with a specific payment mode.
2101    ///
2102    /// Before encryption, checks that the temp directory has enough free
2103    /// disk space for the spilled chunks (~1.1× source file size).
2104    ///
2105    /// Encrypted chunks are spilled to a temp directory during encryption
2106    /// so that only their 32-byte addresses stay in memory. At upload time,
2107    /// chunks are read back one wave at a time (~64 × 4 MB ≈ 256 MB peak).
2108    ///
2109    /// # Errors
2110    ///
2111    /// Returns an error if there is insufficient disk space, the file cannot
2112    /// be read, encryption fails, or any chunk cannot be stored.
2113    #[allow(clippy::too_many_lines)]
2114    pub async fn file_upload_with_mode(
2115        &self,
2116        path: &Path,
2117        mode: PaymentMode,
2118    ) -> Result<FileUploadResult> {
2119        self.file_upload_with_progress(path, mode, None).await
2120    }
2121
2122    /// Upload a file publicly, storing the serialized [`DataMap`] as part of
2123    /// the same upload payment batch.
2124    ///
2125    /// The returned [`FileUploadResult::data_map_address`] can be shared for
2126    /// public downloads via [`Client::data_map_fetch`].
2127    #[allow(clippy::too_many_lines)]
2128    pub async fn file_upload_public_with_mode(
2129        &self,
2130        path: &Path,
2131        mode: PaymentMode,
2132    ) -> Result<FileUploadResult> {
2133        self.file_upload_with_visibility_and_progress(path, mode, Visibility::Public, None)
2134            .await
2135    }
2136
2137    /// Upload a file with progress events sent to the given channel.
2138    ///
2139    /// Same as [`Client::file_upload_with_mode`] but sends [`UploadEvent`]s to the
2140    /// provided channel for UI progress feedback.
2141    #[allow(clippy::too_many_lines)]
2142    pub async fn file_upload_with_progress(
2143        &self,
2144        path: &Path,
2145        mode: PaymentMode,
2146        progress: Option<mpsc::Sender<UploadEvent>>,
2147    ) -> Result<FileUploadResult> {
2148        self.file_upload_with_visibility_and_progress(path, mode, Visibility::Private, progress)
2149            .await
2150    }
2151
2152    /// Public file upload with progress events.
2153    ///
2154    /// Same as [`Client::file_upload_public_with_mode`] but sends
2155    /// [`UploadEvent`]s to the provided channel for UI progress feedback.
2156    #[allow(clippy::too_many_lines)]
2157    pub async fn file_upload_public_with_progress(
2158        &self,
2159        path: &Path,
2160        mode: PaymentMode,
2161        progress: Option<mpsc::Sender<UploadEvent>>,
2162    ) -> Result<FileUploadResult> {
2163        self.file_upload_with_visibility_and_progress(path, mode, Visibility::Public, progress)
2164            .await
2165    }
2166
2167    #[allow(clippy::too_many_lines)]
2168    async fn file_upload_with_visibility_and_progress(
2169        &self,
2170        path: &Path,
2171        mode: PaymentMode,
2172        visibility: Visibility,
2173        progress: Option<mpsc::Sender<UploadEvent>>,
2174    ) -> Result<FileUploadResult> {
2175        debug!(
2176            "Streaming file upload with mode {mode:?}, visibility {visibility:?}: {}",
2177            path.display()
2178        );
2179
2180        // Pre-flight: verify enough temp disk space for the chunk spill.
2181        let file_size = std::fs::metadata(path)?.len();
2182        check_disk_space_for_spill(file_size)?;
2183
2184        // Phase 1: Encrypt file and spill chunks to temp directory.
2185        // Only 32-byte addresses stay in memory — chunk data lives on disk.
2186        let (mut spill, data_map) = self.encrypt_file_to_spill(path, progress.as_ref()).await?;
2187
2188        let data_map_address = match visibility {
2189            Visibility::Private => None,
2190            Visibility::Public => {
2191                let serialized = rmp_serde::to_vec(&data_map).map_err(|e| {
2192                    Error::Serialization(format!("Failed to serialize DataMap: {e}"))
2193                })?;
2194                let address = compute_address(&serialized);
2195                info!(
2196                    "Public upload: adding DataMap chunk ({} bytes) at address {} to payment batch",
2197                    serialized.len(),
2198                    hex::encode(address)
2199                );
2200                spill.push(&serialized)?;
2201                Some(address)
2202            }
2203        };
2204
2205        let chunk_count = spill.len();
2206        info!(
2207            "Encrypted {} into {chunk_count} chunks (spilled to disk)",
2208            path.display()
2209        );
2210        if let Some(ref tx) = progress {
2211            let _ = tx
2212                .send(UploadEvent::Encrypted {
2213                    total_chunks: chunk_count,
2214                })
2215                .await;
2216        }
2217
2218        // Phase 2: Decide payment mode and upload in waves from disk.
2219        //
2220        // For the merkle path, attempt to resume from a cached
2221        // receipt before paying again. The cache is keyed by the
2222        // CANONICAL source path so `./foo`, `/abs/foo`, and any
2223        // symlink alias all resolve to the same cache entry — a
2224        // crash-and-retry from a different cwd or via a different
2225        // alias still hits the receipt. Canonicalize may fail (the
2226        // file could have been moved between phase 1 and here); we
2227        // fall back to the display string in that case, which
2228        // preserves pre-fix behaviour rather than dropping cache
2229        // resume entirely.
2230        let file_path_key = std::fs::canonicalize(path)
2231            .map(|p| p.display().to_string())
2232            .unwrap_or_else(|_| path.display().to_string());
2233        let (chunks_stored, actual_mode, storage_cost_atto, gas_cost_wei, stats) = if self
2234            .should_use_merkle(chunk_count, mode)
2235        {
2236            info!("Using merkle batch payment for {chunk_count} file chunks");
2237
2238            let cached_merkle =
2239                crate::data::client::cached_merkle::try_load_for_file(&file_path_key)
2240                    .map(|(_cache_path, cached)| cached);
2241
2242            let merkle_plan = match self
2243                .plan_merkle_upload(spill.chunk_entries()?, DATA_TYPE_CHUNK, progress.as_ref())
2244                .await
2245            {
2246                Ok(plan) => plan,
2247                Err(e) => {
2248                    if let Some(cached) = cached_merkle
2249                        .as_ref()
2250                        .filter(|cached| cached_merkle_covers_addresses(cached, &spill.addresses))
2251                    {
2252                        info!(
2253                            "Merkle preflight failed ({e}); \
2254                             resuming with cached merkle proofs"
2255                        );
2256                        let (stored, sc, gc, stats) = self
2257                            .upload_merkle_from_spill(
2258                                &spill,
2259                                &spill.addresses,
2260                                cached,
2261                                &[],
2262                                progress.as_ref(),
2263                            )
2264                            .await?;
2265                        crate::data::client::cached_merkle::try_delete_for_file(&file_path_key);
2266                        return Ok(FileUploadResult {
2267                            data_map,
2268                            chunks_stored: stored,
2269                            chunks_failed: 0,
2270                            total_chunks: chunk_count,
2271                            payment_mode_used: PaymentMode::Merkle,
2272                            storage_cost_atto: sc,
2273                            gas_cost_wei: gc,
2274                            data_map_address,
2275                            chunk_attempts_total: stats.chunk_attempts_total,
2276                            store_durations_ms: stats.store_durations_ms,
2277                            retries_histogram: stats.retries_histogram,
2278                        });
2279                    }
2280                    match &e {
2281                        Error::InsufficientPeers(msg) if mode == PaymentMode::Auto => {
2282                            info!(
2283                                "Merkle preflight needs more peers ({msg}), \
2284                                 falling back to wave-batch"
2285                            );
2286                            let (stored, sc, gc, fb_stats) = self
2287                                .upload_waves_single(
2288                                    &spill,
2289                                    progress.as_ref(),
2290                                    Some(&file_path_key),
2291                                )
2292                                .await?;
2293                            crate::data::client::cached_single::try_delete_for_file(&file_path_key);
2294                            return Ok(FileUploadResult {
2295                                data_map,
2296                                chunks_stored: stored,
2297                                chunks_failed: 0,
2298                                total_chunks: chunk_count,
2299                                payment_mode_used: PaymentMode::Single,
2300                                storage_cost_atto: sc,
2301                                gas_cost_wei: gc,
2302                                data_map_address,
2303                                chunk_attempts_total: fb_stats.chunk_attempts_total,
2304                                store_durations_ms: fb_stats.store_durations_ms,
2305                                retries_histogram: fb_stats.retries_histogram,
2306                            });
2307                        }
2308                        _ => return Err(e),
2309                    }
2310                }
2311            };
2312
2313            if merkle_plan.to_upload.is_empty() {
2314                info!("All {chunk_count} merkle chunks already stored; skipping payment");
2315                crate::data::client::cached_merkle::try_delete_for_file(&file_path_key);
2316                crate::data::client::cached_single::try_delete_for_file(&file_path_key);
2317                (
2318                    chunk_count,
2319                    PaymentMode::Merkle,
2320                    "0".to_string(),
2321                    0,
2322                    WaveAggregateStats::default(),
2323                )
2324            } else if !self.should_use_merkle(merkle_plan.to_upload.len(), mode) {
2325                let remaining_chunks = merkle_plan.to_upload.len();
2326                if let Some(cached) = cached_merkle
2327                    .as_ref()
2328                    .filter(|cached| cached_merkle_covers_addresses(cached, &merkle_plan.to_upload))
2329                {
2330                    info!(
2331                        "{remaining_chunks} chunks remain below merkle threshold; \
2332                         reusing cached merkle proofs"
2333                    );
2334                    let (stored, sc, gc, stats) = self
2335                        .upload_merkle_from_spill(
2336                            &spill,
2337                            &merkle_plan.to_upload,
2338                            cached,
2339                            &merkle_plan.already_stored,
2340                            progress.as_ref(),
2341                        )
2342                        .await?;
2343                    crate::data::client::cached_merkle::try_delete_for_file(&file_path_key);
2344                    (stored, PaymentMode::Merkle, sc, gc, stats)
2345                } else {
2346                    if cached_merkle.is_some() {
2347                        info!(
2348                            "{remaining_chunks} chunks remain below merkle threshold, \
2349                             and the cached merkle receipt does not cover them. \
2350                             Discarding cache and using single-node payment."
2351                        );
2352                        crate::data::client::cached_merkle::try_delete_for_file(&file_path_key);
2353                    } else {
2354                        info!(
2355                            "{remaining_chunks} chunks need upload after merkle preflight; \
2356                             using single-node payment"
2357                        );
2358                    }
2359                    let (stored, sc, gc, stats) = self
2360                        .upload_spill_addresses_single(
2361                            &spill,
2362                            &merkle_plan.to_upload,
2363                            progress.as_ref(),
2364                            &merkle_plan.already_stored,
2365                            chunk_count,
2366                            Some(&file_path_key),
2367                        )
2368                        .await?;
2369                    crate::data::client::cached_single::try_delete_for_file(&file_path_key);
2370                    (stored, PaymentMode::Single, sc, gc, stats)
2371                }
2372            } else {
2373                let batch_result = if let Some(cached) = cached_merkle.as_ref() {
2374                    // Validate the cache against the chunks that still need
2375                    // storage. Extra proofs are harmless: a previous attempt
2376                    // may have paid for chunks that are now already stored.
2377                    if cached_merkle_covers_addresses(cached, &merkle_plan.to_upload) {
2378                        info!(
2379                            "Skipping merkle payment phase; resuming with \
2380                             cached proofs for {} remaining chunks",
2381                            merkle_plan.to_upload.len()
2382                        );
2383                        Ok(cached.clone())
2384                    } else {
2385                        info!(
2386                            "Cached merkle receipt does not cover the current \
2387                             remaining chunks (cached={}, remaining={}). \
2388                             Discarding cache and paying fresh.",
2389                            cached.proofs.len(),
2390                            merkle_plan.to_upload.len()
2391                        );
2392                        crate::data::client::cached_merkle::try_delete_for_file(&file_path_key);
2393                        self.pay_for_merkle_batch(
2394                            &merkle_plan.to_upload,
2395                            DATA_TYPE_CHUNK,
2396                            merkle_plan.to_upload_avg_size(),
2397                        )
2398                        .await
2399                        .inspect(|result| {
2400                            crate::data::client::cached_merkle::try_save(&file_path_key, result);
2401                        })
2402                    }
2403                } else {
2404                    self.pay_for_merkle_batch(
2405                        &merkle_plan.to_upload,
2406                        DATA_TYPE_CHUNK,
2407                        merkle_plan.to_upload_avg_size(),
2408                    )
2409                    .await
2410                    .inspect(|result| {
2411                        // Save BEFORE the store phase so a crash
2412                        // mid-upload leaves a resumable receipt.
2413                        crate::data::client::cached_merkle::try_save(&file_path_key, result);
2414                    })
2415                };
2416
2417                let batch_result = match batch_result {
2418                    Ok(result) => result,
2419                    Err(Error::InsufficientPeers(ref msg)) if mode == PaymentMode::Auto => {
2420                        info!("Merkle needs more peers ({msg}), falling back to wave-batch");
2421                        let (stored, sc, gc, fb_stats) = self
2422                            .upload_spill_addresses_single(
2423                                &spill,
2424                                &merkle_plan.to_upload,
2425                                progress.as_ref(),
2426                                &merkle_plan.already_stored,
2427                                chunk_count,
2428                                Some(&file_path_key),
2429                            )
2430                            .await?;
2431                        crate::data::client::cached_single::try_delete_for_file(&file_path_key);
2432                        return Ok(FileUploadResult {
2433                            data_map,
2434                            chunks_stored: stored,
2435                            chunks_failed: 0,
2436                            total_chunks: chunk_count,
2437                            payment_mode_used: PaymentMode::Single,
2438                            storage_cost_atto: sc,
2439                            gas_cost_wei: gc,
2440                            data_map_address,
2441                            chunk_attempts_total: fb_stats.chunk_attempts_total,
2442                            store_durations_ms: fb_stats.store_durations_ms,
2443                            retries_histogram: fb_stats.retries_histogram,
2444                        });
2445                    }
2446                    Err(e) => return Err(e),
2447                };
2448
2449                let (stored, sc, gc, stats) = self
2450                    .upload_merkle_from_spill(
2451                        &spill,
2452                        &merkle_plan.to_upload,
2453                        &batch_result,
2454                        &merkle_plan.already_stored,
2455                        progress.as_ref(),
2456                    )
2457                    .await?;
2458                // Upload succeeded end-to-end; the cached receipt is
2459                // no longer needed.
2460                crate::data::client::cached_merkle::try_delete_for_file(&file_path_key);
2461                (stored, PaymentMode::Merkle, sc, gc, stats)
2462            }
2463        } else {
2464            let (stored, sc, gc, stats) = self
2465                .upload_waves_single(&spill, progress.as_ref(), Some(&file_path_key))
2466                .await?;
2467            // Full file success: drop any cached single-node receipt.
2468            crate::data::client::cached_single::try_delete_for_file(&file_path_key);
2469            (stored, PaymentMode::Single, sc, gc, stats)
2470        };
2471
2472        info!(
2473            "File uploaded with {actual_mode:?}: {chunks_stored} chunks stored ({})",
2474            path.display()
2475        );
2476
2477        Ok(FileUploadResult {
2478            data_map,
2479            chunks_stored,
2480            chunks_failed: 0,
2481            total_chunks: chunk_count,
2482            payment_mode_used: actual_mode,
2483            storage_cost_atto,
2484            gas_cost_wei,
2485            data_map_address,
2486            chunk_attempts_total: stats.chunk_attempts_total,
2487            store_durations_ms: stats.store_durations_ms,
2488            retries_histogram: stats.retries_histogram,
2489        })
2490    }
2491
2492    /// Encrypt a file and spill chunks to a temp directory.
2493    ///
2494    /// Logs progress every 100 chunks so users get feedback during
2495    /// multi-GB encryptions.
2496    ///
2497    /// Returns the spill buffer (addresses on disk) and the `DataMap`.
2498    async fn encrypt_file_to_spill(
2499        &self,
2500        path: &Path,
2501        progress: Option<&mpsc::Sender<UploadEvent>>,
2502    ) -> Result<(ChunkSpill, DataMap)> {
2503        let (mut chunk_rx, datamap_rx, handle) = spawn_file_encryption(path.to_path_buf())?;
2504
2505        let mut spill = ChunkSpill::new()?;
2506        while let Some(content) = chunk_rx.recv().await {
2507            spill.push(&content)?;
2508            let chunks_done = spill.len();
2509            if let Some(tx) = progress {
2510                if chunks_done.is_multiple_of(10) {
2511                    let _ = tx.send(UploadEvent::Encrypting { chunks_done }).await;
2512                }
2513            }
2514            if chunks_done % 100 == 0 {
2515                let mb = spill.total_bytes() / (1024 * 1024);
2516                info!(
2517                    "Encryption progress: {chunks_done} chunks spilled ({mb} MB) — {}",
2518                    path.display()
2519                );
2520            }
2521        }
2522
2523        // Await encryption completion to catch errors before paying.
2524        handle
2525            .await
2526            .map_err(|e| Error::Encryption(format!("encryption task panicked: {e}")))?
2527            .map_err(|e| Error::Encryption(format!("encryption failed: {e}")))?;
2528
2529        let data_map = datamap_rx
2530            .await
2531            .map_err(|_| Error::Encryption("no DataMap from encryption thread".to_string()))?;
2532
2533        Ok((spill, data_map))
2534    }
2535
2536    /// Upload chunks from a spill using wave-based per-chunk (single) payments.
2537    ///
2538    /// Reads one wave at a time from disk, prepares quotes, pays, and stores.
2539    /// Peak memory: ~`UPLOAD_WAVE_SIZE × MAX_CHUNK_SIZE` (~256 MB).
2540    ///
2541    /// Returns `(chunks_stored, storage_cost_atto, gas_cost_wei)`.
2542    async fn upload_waves_single(
2543        &self,
2544        spill: &ChunkSpill,
2545        progress: Option<&mpsc::Sender<UploadEvent>>,
2546        resume_key: Option<&str>,
2547    ) -> Result<(usize, String, u128, WaveAggregateStats)> {
2548        self.upload_spill_addresses_single(
2549            spill,
2550            &spill.addresses,
2551            progress,
2552            &[],
2553            spill.len(),
2554            resume_key,
2555        )
2556        .await
2557    }
2558
2559    async fn upload_spill_addresses_single(
2560        &self,
2561        spill: &ChunkSpill,
2562        addresses: &[[u8; 32]],
2563        progress: Option<&mpsc::Sender<UploadEvent>>,
2564        already_stored_addresses: &[[u8; 32]],
2565        total_chunks: usize,
2566        resume_key: Option<&str>,
2567    ) -> Result<(usize, String, u128, WaveAggregateStats)> {
2568        let mut total_stored = already_stored_addresses.len();
2569        let mut total_storage = Amount::ZERO;
2570        let mut total_gas: u128 = 0;
2571        let mut agg_stats = WaveAggregateStats::default();
2572        // A wave whose chunks fall short of quorum after retries must not abort
2573        // the file: its failures are accumulated here and surfaced as a single
2574        // `PartialUpload` only after every wave has been attempted, mirroring
2575        // `upload_merkle_from_spill`. Aborting on the first failed wave (the old `?`)
2576        // discarded all later waves' progress — already self-encrypted, spilled,
2577        // and in some cases already paid for — converting high per-chunk success
2578        // into 0% per-file success.
2579        // Seed with the addresses a preflight already confirmed stored (e.g.
2580        // the merkle-fallback path passes `merkle_plan.already_stored`), so a
2581        // returned `PartialUpload.stored` lists every stored chunk and
2582        // `stored_count == stored.len()` holds for programmatic callers.
2583        let mut stored_addresses: Vec<[u8; 32]> = already_stored_addresses.to_vec();
2584        let mut failed: Vec<([u8; 32], String)> = Vec::new();
2585        let waves: Vec<&[[u8; 32]]> = addresses.chunks(UPLOAD_WAVE_SIZE).collect();
2586        let wave_count = waves.len();
2587
2588        // Unconditional breadcrumb: lets a clean run confirm the continue-on-
2589        // partial single-node path is in effect (the old path aborted the file
2590        // on the first failed wave instead of continuing across all waves).
2591        info!(
2592            "single-node upload: {} chunk(s) in {wave_count} wave(s) (continue-on-partial)",
2593            addresses.len()
2594        );
2595
2596        for (wave_idx, wave_addrs) in waves.into_iter().enumerate() {
2597            let wave_num = wave_idx + 1;
2598            let wave_data: Vec<Bytes> = wave_addrs
2599                .iter()
2600                .map(|addr| spill.read_chunk(addr))
2601                .collect::<Result<Vec<_>>>()?;
2602
2603            info!(
2604                "Wave {wave_num}/{wave_count}: quoting {} chunks — {total_stored}/{total_chunks} stored so far",
2605                wave_data.len()
2606            );
2607            if let Some(tx) = progress {
2608                let _ = tx
2609                    .send(UploadEvent::QuotingChunks {
2610                        wave: wave_num,
2611                        total_waves: wave_count,
2612                        chunks_in_wave: wave_data.len(),
2613                    })
2614                    .await;
2615            }
2616            // Fold this wave's result. A quorum shortfall (`PartialUpload`) is
2617            // recoverable and its parts are returned to be recorded here;
2618            // genuinely fatal errors propagate via `?` and abort the file, as in
2619            // `upload_merkle_from_spill`.
2620            let outcome = fold_single_wave(
2621                self.batch_upload_chunks_with_events(
2622                    wave_data,
2623                    progress,
2624                    total_stored,
2625                    total_chunks,
2626                    resume_key,
2627                )
2628                .await,
2629            )?;
2630
2631            if !outcome.failed.is_empty() {
2632                warn!(
2633                    "Wave {wave_num}/{wave_count}: {} chunk(s) failed to store after retries; \
2634                     continuing with remaining waves",
2635                    outcome.failed.len()
2636                );
2637            }
2638
2639            total_stored += outcome.stored.len();
2640            stored_addresses.extend(outcome.stored);
2641            failed.extend(outcome.failed);
2642            total_storage += outcome.storage_atto;
2643            total_gas = total_gas.saturating_add(outcome.gas_wei);
2644            // Merge per-wave stats (a quorum-short wave contributes none, since
2645            // `PartialUpload` carries no stats).
2646            agg_stats.chunk_attempts_total = agg_stats
2647                .chunk_attempts_total
2648                .saturating_add(outcome.stats.chunk_attempts_total);
2649            agg_stats
2650                .store_durations_ms
2651                .extend(outcome.stats.store_durations_ms);
2652            for (slot, count) in agg_stats
2653                .retries_histogram
2654                .iter_mut()
2655                .zip(outcome.stats.retries_histogram.iter())
2656            {
2657                *slot = slot.saturating_add(*count);
2658            }
2659        }
2660
2661        // Any chunk still failed after every wave was attempted means the file
2662        // is not fully stored — surface it as `PartialUpload` (never silently
2663        // succeed with missing chunks), carrying the real on-chain spend.
2664        if !failed.is_empty() {
2665            let failed_count = failed.len();
2666            warn!(
2667                "single-node upload incomplete: {failed_count}/{total_chunks} chunks failed after retries"
2668            );
2669            return Err(Error::PartialUpload {
2670                stored: stored_addresses,
2671                stored_count: total_stored,
2672                failed,
2673                failed_count,
2674                total_chunks,
2675                spend: Box::new(PartialUploadSpend {
2676                    storage_cost_atto: total_storage.to_string(),
2677                    gas_cost_wei: total_gas,
2678                }),
2679                reason: format!("{failed_count} chunk(s) failed to store after retries"),
2680            });
2681        }
2682
2683        Ok((
2684            total_stored,
2685            total_storage.to_string(),
2686            total_gas,
2687            agg_stats,
2688        ))
2689    }
2690
2691    /// Upload chunks from a spill using pre-computed merkle proofs.
2692    ///
2693    /// Stores the whole file as a **single cap-bounded fan-out** — not in fixed
2694    /// waves. The store concurrency limiter is the only throttle: `store_one`
2695    /// reads each chunk's body from the on-disk spill on demand, so at most
2696    /// `store_cap` (≤ 64) bodies are ever resident, giving the same
2697    /// `~store_cap × MAX_CHUNK_SIZE` peak-memory bound the old 64-chunk waves
2698    /// gave — but with **no wave barrier**, so a slow straggler (e.g. a chunk
2699    /// whose close-group peers are stale relayed addresses that take minutes to
2700    /// revalidate) no longer stalls the rest of the file behind it.
2701    ///
2702    /// A chunk that is transiently short of quorum (`InsufficientPeers` /
2703    /// `CloseGroupShortfall` / `RemotePut`) does **not** abort the file, nor
2704    /// block the pass: the store pass is a **single attempt** (no in-pass
2705    /// backoff), and quorum-short chunks are collected into a deferred set. After
2706    /// the pass, [`merkle_deferred_retry`] retries that set in concurrent rounds
2707    /// ([`DEFERRED_ROUND_DELAYS_SECS`] delays), re-reading each body from the
2708    /// spill and reusing its proof. Non-quorum errors (e.g. a missing proof)
2709    /// stay fatal and abort immediately.
2710    ///
2711    /// Returns `(chunks_stored, storage_cost_atto, gas_cost_wei)` on success.
2712    /// Costs come from the `batch_result` which was populated during payment.
2713    ///
2714    /// # Errors
2715    ///
2716    /// Returns [`Error::PartialUpload`] if any chunk is still short of quorum
2717    /// after the store pass and every deferred round (other chunks remain
2718    /// stored), or the underlying error for a non-quorum failure.
2719    async fn upload_merkle_from_spill(
2720        &self,
2721        spill: &ChunkSpill,
2722        addresses: &[[u8; 32]],
2723        batch_result: &MerkleBatchPaymentResult,
2724        already_stored_addresses: &[[u8; 32]],
2725        progress: Option<&mpsc::Sender<UploadEvent>>,
2726    ) -> Result<(usize, String, u128, WaveAggregateStats)> {
2727        let mut total_stored = already_stored_addresses.len();
2728        let total_chunks = total_stored + addresses.len();
2729        let mut stored_addresses: Vec<[u8; 32]> = already_stored_addresses.to_vec();
2730        let mut failed: Vec<([u8; 32], String)> = Vec::new();
2731        let mut agg_stats = WaveAggregateStats::default();
2732
2733        // Chunks without a merkle proof were never paid for: a partial
2734        // `pay_for_merkle_multi_batch` result carries proofs only for the
2735        // sub-batches whose on-chain payment succeeded. Such a chunk cannot be
2736        // stored, so record it as failed (surfaced via `PartialUpload` once the
2737        // storable chunks have been attempted) rather than letting its
2738        // "missing proof" error abort the whole file and discard every other
2739        // chunk's progress.
2740        let (to_store, missing_proof) =
2741            partition_addresses_by_proof(addresses, &batch_result.proofs);
2742        if !missing_proof.is_empty() {
2743            warn!(
2744                "{} chunk(s) lack a merkle proof (partial payment); reporting them as failed",
2745                missing_proof.len()
2746            );
2747            for addr in &missing_proof {
2748                failed.push((
2749                    *addr,
2750                    format!("Missing merkle proof for chunk {}", hex::encode(addr)),
2751                ));
2752            }
2753        }
2754
2755        let store_limiter = self.controller().store.clone();
2756
2757        // Store one chunk to its (freshly re-collected) close group, reusing the
2758        // chunk's merkle proof. Reads the body from the on-disk spill on demand,
2759        // so the whole-file store runs as ONE cap-bounded fan-out with no per-wave
2760        // barrier: a slow straggler (e.g. a chunk whose close-group peers are
2761        // stale relayed addresses that take minutes to revalidate) no longer
2762        // holds back the rest of the file. Only the ≤cap in-flight stores hold a
2763        // body, so peak resident memory is `cap × MAX_CHUNK_SIZE`; the cap is
2764        // clamped to `MERKLE_STORE_MAX_IN_FLIGHT` (below) so it stays within the
2765        // ~256 MiB bound the fixed 64-chunk waves gave even if `adaptive.max.store`
2766        // is configured above 64.
2767        // Shared across every deferred round so a converged routing table yields
2768        // a fresh group. Only a quorum shortfall is recoverable; a missing proof
2769        // or a failed spill read stays fatal. Mirrors `merkle_upload_chunks`.
2770        let store_one = |addr: [u8; 32]| {
2771            let limiter = store_limiter.clone();
2772            let proof_bytes = batch_result.proofs.get(&addr).cloned();
2773            async move {
2774                let started = std::time::Instant::now();
2775                let proof = proof_bytes.ok_or_else(|| {
2776                    Error::Payment(format!(
2777                        "Missing merkle proof for chunk {}",
2778                        hex::encode(addr)
2779                    ))
2780                })?;
2781                let content = spill.read_chunk(&addr)?;
2782                let peers = self.put_target_peers(&addr).await?;
2783                observe_op(
2784                    &limiter,
2785                    || async move { self.chunk_put_to_close_group(content, proof, &peers).await },
2786                    classify_error,
2787                )
2788                .await
2789                .map(|_| started)
2790            }
2791        };
2792
2793        info!(
2794            "Storing {} chunks (merkle) as a single cap-bounded pass — {total_stored}/{total_chunks} stored so far",
2795            to_store.len()
2796        );
2797
2798        // Store the WHOLE file in one cap-bounded fan-out (`max_attempts = 1`, no
2799        // backoff): no wave barrier, so a slow straggler (dead-relay peers) can't
2800        // hold back the rest of the file. The store cap re-reads the limiter per
2801        // slot, so it maxes at 64 → ≤64 bodies resident (bodies read from spill on
2802        // demand by `store_one`), the same peak-memory bound the fixed 64-chunk
2803        // waves gave. Quorum-short chunks are collected and deferred to the
2804        // post-pass concurrent retry rather than parking slots behind a backoff.
2805        // `merkle_store_cap` clamps to `MERKLE_STORE_MAX_IN_FLIGHT` so a high
2806        // configured `adaptive.max.store` can't hold more than the wave-era
2807        // ~256 MB of spilled bodies resident (PR #137 review).
2808        let cap = || merkle_store_cap(store_limiter.current());
2809        let outcome = merkle_store_with_retry(
2810            to_store.clone(),
2811            cap,
2812            1,
2813            std::time::Duration::ZERO,
2814            progress,
2815            total_stored,
2816            total_chunks,
2817            &store_one,
2818        )
2819        .await?;
2820
2821        // Record confirmed stores from the explicit set the store helper reports.
2822        // Using that set (rather than inferring "chunks minus failed") keeps
2823        // `stored_addresses` correct even when a fatal abort leaves some chunks
2824        // neither stored nor reported short of quorum.
2825        stored_addresses.extend(&outcome.stored_addresses);
2826        total_stored = outcome.stored;
2827
2828        // Merge store stats (durations, attempts, per-round histogram).
2829        agg_stats.chunk_attempts_total = agg_stats
2830            .chunk_attempts_total
2831            .saturating_add(outcome.stats.chunk_attempts_total);
2832        agg_stats
2833            .store_durations_ms
2834            .extend(outcome.stats.store_durations_ms);
2835        for (slot, count) in agg_stats
2836            .retries_histogram
2837            .iter_mut()
2838            .zip(outcome.stats.retries_histogram.iter())
2839        {
2840            *slot = slot.saturating_add(*count);
2841        }
2842
2843        if let Some(e) = outcome.fatal {
2844            // A non-quorum store error is fatal (missing proofs were filtered out
2845            // above, so this is a genuine network/store failure). Preserve every
2846            // chunk stored so far and report every not-stored chunk as failed, so
2847            // the `PartialUpload` counts are accurate.
2848            warn!("merkle store aborted: {e}");
2849            let mut known_failed = failed;
2850            known_failed.extend(outcome.failed_addresses);
2851            return Err(partial_upload_after_fatal(
2852                addresses,
2853                stored_addresses,
2854                total_stored,
2855                total_chunks,
2856                known_failed,
2857                PartialUploadSpend {
2858                    storage_cost_atto: batch_result.storage_cost_atto.clone(),
2859                    gas_cost_wei: batch_result.gas_cost_wei,
2860                },
2861                format!("merkle chunk store aborted: {e}"),
2862            ));
2863        }
2864
2865        // Non-fatal: quorum-short chunks are deferred (not failed yet) for the
2866        // post-pass concurrent retry. A deferred chunk joins `stored_addresses`
2867        // only if/when a later round stores it.
2868        let deferred: Vec<([u8; 32], String)> = outcome.failed_addresses;
2869
2870        // The store pass never blocked on backoff; now retry the deferred set in
2871        // concurrent rounds. Bodies are re-read from the spill by `store_one`
2872        // (peak RAM unchanged) and proofs re-attached. Chunks still short after
2873        // the final round become `failed`; a non-quorum error aborts as
2874        // `PartialUpload`.
2875        if !deferred.is_empty() {
2876            info!(
2877                "Deferring {} merkle chunk(s) short of quorum for concurrent retry after the store pass",
2878                deferred.len()
2879            );
2880            let dr = merkle_deferred_retry(
2881                deferred,
2882                &DEFERRED_ROUND_DELAYS_SECS,
2883                |n: usize| merkle_store_cap(store_limiter.current()).min(n.max(1)),
2884                progress,
2885                total_stored,
2886                total_chunks,
2887                &store_one,
2888            )
2889            .await?;
2890
2891            stored_addresses.extend(dr.stored_addresses);
2892            total_stored = dr.stored;
2893
2894            // Merge the deferred pass's stats — its histogram is already mapped
2895            // to the right per-round slots — into the file aggregate.
2896            agg_stats.chunk_attempts_total = agg_stats
2897                .chunk_attempts_total
2898                .saturating_add(dr.stats.chunk_attempts_total);
2899            agg_stats
2900                .store_durations_ms
2901                .extend(dr.stats.store_durations_ms);
2902            for (slot, count) in agg_stats
2903                .retries_histogram
2904                .iter_mut()
2905                .zip(dr.stats.retries_histogram.iter())
2906            {
2907                *slot = slot.saturating_add(*count);
2908            }
2909
2910            if let Some(reason) = dr.fatal {
2911                // A non-quorum store error during a deferred round is fatal, the
2912                // same as in the wave path: preserve everything stored so far and
2913                // report every not-stored chunk as failed.
2914                warn!("merkle deferred retry aborted: {reason}");
2915                let mut known_failed = failed;
2916                known_failed.extend(dr.failed_addresses);
2917                return Err(partial_upload_after_fatal(
2918                    addresses,
2919                    stored_addresses,
2920                    total_stored,
2921                    total_chunks,
2922                    known_failed,
2923                    PartialUploadSpend {
2924                        storage_cost_atto: batch_result.storage_cost_atto.clone(),
2925                        gas_cost_wei: batch_result.gas_cost_wei,
2926                    },
2927                    format!("merkle chunk store aborted: {reason}"),
2928                ));
2929            }
2930            failed.extend(dr.failed_addresses);
2931        }
2932
2933        // A file with any permanently-failed chunk is not fully stored — surface
2934        // it as `PartialUpload`, but only after the store pass and every deferred
2935        // retry round are exhausted (never silently succeed with missing chunks).
2936        if !failed.is_empty() {
2937            let failed_count = failed.len();
2938            let total_attempts = 1 + DEFERRED_ROUND_DELAYS_SECS.len();
2939            warn!(
2940                "merkle upload incomplete: {failed_count}/{total_chunks} chunks short of quorum after retries"
2941            );
2942            return Err(Error::PartialUpload {
2943                stored: stored_addresses,
2944                stored_count: total_stored,
2945                failed,
2946                failed_count,
2947                total_chunks,
2948                spend: Box::new(PartialUploadSpend {
2949                    storage_cost_atto: batch_result.storage_cost_atto.clone(),
2950                    gas_cost_wei: batch_result.gas_cost_wei,
2951                }),
2952                reason: format!(
2953                    "{failed_count} chunk(s) short of quorum after {total_attempts} attempts"
2954                ),
2955            });
2956        }
2957
2958        Ok((
2959            total_stored,
2960            batch_result.storage_cost_atto.clone(),
2961            batch_result.gas_cost_wei,
2962            agg_stats,
2963        ))
2964    }
2965
2966    /// Download and decrypt a file from the network, writing it to disk.
2967    ///
2968    /// Uses `streaming_decrypt` so that only one batch of chunks lives in
2969    /// memory at a time, avoiding OOM on large files. Chunks are fetched
2970    /// concurrently within each batch, then decrypted data is written to
2971    /// disk incrementally.
2972    ///
2973    /// Returns the number of bytes written.
2974    ///
2975    /// # Panics
2976    ///
2977    /// Requires a multi-threaded Tokio runtime (`flavor = "multi_thread"`).
2978    /// Will panic if called from a `current_thread` runtime because
2979    /// `streaming_decrypt` takes a synchronous callback that must bridge
2980    /// back to async via `block_in_place`.
2981    ///
2982    /// # Errors
2983    ///
2984    /// Returns an error if any chunk cannot be retrieved, decryption fails,
2985    /// or the file cannot be written.
2986    pub async fn file_download(&self, data_map: &DataMap, output: &Path) -> Result<u64> {
2987        self.file_download_with_progress(data_map, output, None)
2988            .await
2989    }
2990
2991    /// Download and decrypt a file, trying the requested number of
2992    /// closest peers for every chunk fetch.
2993    ///
2994    /// Returns the number of bytes written.
2995    ///
2996    /// # Errors
2997    ///
2998    /// Returns an error if any chunk cannot be retrieved, decryption fails,
2999    /// or the file cannot be written.
3000    pub async fn file_download_from_closest_peers(
3001        &self,
3002        data_map: &DataMap,
3003        output: &Path,
3004        peer_count: NonZeroUsize,
3005    ) -> Result<u64> {
3006        self.file_download_with_progress_using_peer_count(data_map, output, None, peer_count.get())
3007            .await
3008    }
3009
3010    /// Download and decrypt a file with progress events, trying the
3011    /// requested number of closest peers for every chunk fetch.
3012    ///
3013    /// Same as [`Client::file_download_from_closest_peers`] but sends
3014    /// [`DownloadEvent`]s for UI feedback.
3015    ///
3016    /// # Errors
3017    ///
3018    /// Returns an error if any chunk cannot be retrieved, decryption fails,
3019    /// or the file cannot be written.
3020    pub async fn file_download_with_progress_from_closest_peers(
3021        &self,
3022        data_map: &DataMap,
3023        output: &Path,
3024        progress: Option<mpsc::Sender<DownloadEvent>>,
3025        peer_count: NonZeroUsize,
3026    ) -> Result<u64> {
3027        self.file_download_with_progress_using_peer_count(
3028            data_map,
3029            output,
3030            progress,
3031            peer_count.get(),
3032        )
3033        .await
3034    }
3035
3036    /// Download and decrypt a file with peer-health diagnostics.
3037    ///
3038    /// Each file chunk is fetched by querying every selected closest peer,
3039    /// not by returning after the first successful peer. The returned report
3040    /// records which peers had each chunk and which did not. DataMap
3041    /// resolution still uses the normal early-return fetch path; diagnostics
3042    /// are for file chunks only.
3043    ///
3044    /// # Errors
3045    ///
3046    /// Returns an error if any chunk cannot be retrieved, decryption fails,
3047    /// or the file cannot be written.
3048    pub async fn file_download_with_peer_report_from_closest_peers(
3049        &self,
3050        data_map: &DataMap,
3051        output: &Path,
3052        progress: Option<mpsc::Sender<DownloadEvent>>,
3053        peer_count: NonZeroUsize,
3054    ) -> Result<FileDownloadWithPeerReport> {
3055        let chunk_reports = Arc::new(Mutex::new(Vec::new()));
3056        let bytes_written = self
3057            .file_download_with_progress_using_peer_count_and_reports(
3058                data_map,
3059                output,
3060                progress,
3061                peer_count.get(),
3062                Some(chunk_reports.clone()),
3063            )
3064            .await?;
3065
3066        let chunk_reports = chunk_reports
3067            .lock()
3068            .map_err(|_| Error::Storage("file chunk peer report lock poisoned".to_string()))?
3069            .clone();
3070        let chunk_reports = file_chunk_reports_from_recorded_sweeps(chunk_reports);
3071
3072        Ok(FileDownloadWithPeerReport {
3073            bytes_written,
3074            chunk_reports,
3075        })
3076    }
3077
3078    async fn download_fetch_file_chunk(
3079        &self,
3080        idx: usize,
3081        hash: XorName,
3082        context: FileDownloadFetchContext,
3083        is_deferred_retry: bool,
3084        attempt: usize,
3085    ) -> std::result::Result<DownloadBatchEntry, self_encryption::Error> {
3086        let addr = hash.0;
3087        let addr_hex = hex::encode(addr);
3088
3089        let chunk_content = if let Some(peer_reports) = context.peer_reports {
3090            match self
3091                .chunk_get_from_closest_peer_group(&addr, context.peer_count)
3092                .await
3093            {
3094                Ok(results) => {
3095                    let (content, sweep) = file_chunk_sweep_report_from_peer_results(
3096                        attempt,
3097                        is_deferred_retry,
3098                        &results,
3099                    );
3100                    peer_reports
3101                        .lock()
3102                        .map_err(|_| {
3103                            self_encryption::Error::Generic(
3104                                "file chunk peer report lock poisoned".to_string(),
3105                            )
3106                        })?
3107                        .push(RecordedFileChunkPeerSweep {
3108                            index: idx + 1,
3109                            address: addr,
3110                            sweep,
3111                        });
3112                    content
3113                }
3114                Err(e) => {
3115                    if is_deferred_retry {
3116                        info!(
3117                            "Deferred all-peer retry for {addr_hex} hit transient error: {e}; re-deferring"
3118                        );
3119                    } else {
3120                        info!("First-pass all-peer fetch error for {addr_hex}: {e}; deferring");
3121                    }
3122                    peer_reports
3123                        .lock()
3124                        .map_err(|_| {
3125                            self_encryption::Error::Generic(
3126                                "file chunk peer report lock poisoned".to_string(),
3127                            )
3128                        })?
3129                        .push(RecordedFileChunkPeerSweep {
3130                            index: idx + 1,
3131                            address: addr,
3132                            sweep: file_chunk_sweep_report_from_error(
3133                                attempt,
3134                                is_deferred_retry,
3135                                &e,
3136                            ),
3137                        });
3138                    None
3139                }
3140            }
3141        } else {
3142            match self
3143                .chunk_get_observed_from_closest_peers(&addr, context.peer_count)
3144                .await
3145            {
3146                Ok(Some(chunk)) => Some(chunk.content),
3147                Ok(None) => None,
3148                Err(e) => {
3149                    if is_deferred_retry {
3150                        info!(
3151                            "Deferred retry for {addr_hex} hit transient error: {e}; re-deferring"
3152                        );
3153                    } else {
3154                        info!("First-pass fetch error for {addr_hex}: {e}; deferring");
3155                    }
3156                    None
3157                }
3158            }
3159        };
3160
3161        let Some(content) = chunk_content else {
3162            return Ok((idx, Err(hash)));
3163        };
3164
3165        let fetched = context
3166            .fetched_ref
3167            .fetch_add(1, std::sync::atomic::Ordering::Relaxed)
3168            + 1;
3169        if is_deferred_retry {
3170            info!(
3171                "Downloaded {fetched}/{} (deferred retry)",
3172                context.total_chunks
3173            );
3174        } else {
3175            let total_chunks = context.total_chunks;
3176            info!("Downloaded {fetched}/{total_chunks}");
3177        }
3178        if let Some(ref tx) = context.progress_ref {
3179            let _ = tx.try_send(DownloadEvent::ChunksFetched {
3180                fetched,
3181                total: context.total_chunks,
3182            });
3183        }
3184
3185        Ok((idx, Ok(content)))
3186    }
3187
3188    /// Shared download core: resolve the DataMap, then fetch + streaming-decrypt
3189    /// the file one batch at a time, handing each decrypted plaintext segment
3190    /// (in order) to `on_chunk`. Constant memory — only one decrypt batch is
3191    /// resident at a time. Returns the total plaintext bytes produced.
3192    ///
3193    /// `on_chunk` is async so a sink can apply backpressure (e.g. a bounded
3194    /// channel). Driving the decrypt iterator runs the batched chunk fetch via
3195    /// `block_in_place`, so this requires a multi-threaded Tokio runtime.
3196    ///
3197    /// Every chunk fetch tries `peer_count` closest peers.
3198    ///
3199    /// Progress reporting (via `progress`):
3200    /// 1. Resolves hierarchical DataMaps to the root level first (reports as
3201    ///    `ChunksFetched` with `total: 0` during resolution)
3202    /// 2. Once the root DataMap is known, sends `total_chunks` with accurate count
3203    /// 3. Fetches data chunks with accurate `fetched/total` progress
3204    async fn download_decrypted_chunks<F, Fut>(
3205        &self,
3206        data_map: &DataMap,
3207        progress: Option<mpsc::Sender<DownloadEvent>>,
3208        peer_count: usize,
3209        peer_reports: Option<Arc<Mutex<Vec<RecordedFileChunkPeerSweep>>>>,
3210        mut on_chunk: F,
3211    ) -> Result<u64>
3212    where
3213        F: FnMut(Bytes) -> Fut,
3214        Fut: std::future::Future<Output = Result<()>>,
3215    {
3216        let handle = Handle::current();
3217
3218        // Phase 1: Resolve hierarchical DataMap to root level.
3219        // This fetches child DataMap chunks (typically 3) to discover the real chunk count.
3220        let root_map = if data_map.is_child() {
3221            let dm_chunks = data_map.len();
3222            if let Some(ref tx) = progress {
3223                let _ = tx.try_send(DownloadEvent::ResolvingDataMap {
3224                    total_map_chunks: dm_chunks,
3225                });
3226            }
3227
3228            let resolve_progress = progress.clone();
3229            let resolve_counter = Arc::new(std::sync::atomic::AtomicUsize::new(0));
3230
3231            let resolved = tokio::task::block_in_place(|| {
3232                let counter_ref = resolve_counter.clone();
3233                let progress_ref = resolve_progress.clone();
3234                let fetch_limiter = self.controller().fetch.clone();
3235                let fetch = |batch: &[(usize, XorName)]| {
3236                    let batch_owned: Vec<(usize, XorName)> = batch.to_vec();
3237                    let counter = counter_ref.clone();
3238                    let prog = progress_ref.clone();
3239                    let limiter = fetch_limiter.clone();
3240                    handle.block_on(async {
3241                        // Use rebucketed_unordered so the in-flight cap
3242                        // is re-read from the limiter as each slot frees.
3243                        // `buffer_unordered` snapshots the cap once at
3244                        // pipeline build, which means observe_op
3245                        // signals from inside chunk_get cannot reduce
3246                        // concurrency on the current batch — exactly
3247                        // the case where load-shedding is needed.
3248                        let mut results = rebucketed_unordered(
3249                            &limiter,
3250                            batch_owned,
3251                            |(idx, hash): (usize, XorName)| {
3252                                let counter = counter.clone();
3253                                let prog = prog.clone();
3254                                async move {
3255                                    let addr = hash.0;
3256                                    // chunk_get_observed feeds the
3257                                    // adaptive fetch limiter once per
3258                                    // call via chunk_get_outcome
3259                                    // (Ok(None) -> Timeout is the
3260                                    // load-shedding signal for
3261                                    // sustained close-group exhaustion).
3262                                    let chunk = self
3263                                        .chunk_get_observed_from_closest_peers(&addr, peer_count)
3264                                        .await
3265                                        .map_err(|e| {
3266                                            self_encryption::Error::Generic(format!(
3267                                                "DataMap resolution failed: {e}"
3268                                            ))
3269                                        })?
3270                                        .ok_or_else(|| {
3271                                            self_encryption::Error::Generic(format!(
3272                                                "DataMap chunk not found: {}",
3273                                                hex::encode(addr)
3274                                            ))
3275                                        })?;
3276                                    let fetched = counter
3277                                        .fetch_add(1, std::sync::atomic::Ordering::Relaxed)
3278                                        + 1;
3279                                    if let Some(ref tx) = prog {
3280                                        let _ =
3281                                            tx.try_send(DownloadEvent::MapChunkFetched { fetched });
3282                                    }
3283                                    Ok::<_, self_encryption::Error>((idx, chunk.content))
3284                                }
3285                            },
3286                        )
3287                        .await?;
3288                        // CRITICAL: self_encryption::get_root_data_map_parallel
3289                        // pairs the returned Vec POSITIONALLY with the input
3290                        // hashes via .zip() and discards our idx field.
3291                        // rebucketed_unordered preserves first-completion
3292                        // order, so sort by idx to restore input order
3293                        // before returning.
3294                        results.sort_by_key(|(idx, _)| *idx);
3295                        Ok(results)
3296                    })
3297                };
3298                get_root_data_map_parallel(data_map.clone(), &fetch)
3299            })
3300            .map_err(|e| Error::Encryption(format!("DataMap resolution failed: {e}")))?;
3301
3302            info!(
3303                "Resolved hierarchical DataMap: {} data chunks",
3304                resolved.len()
3305            );
3306            resolved
3307        } else {
3308            data_map.clone()
3309        };
3310
3311        // Phase 2: Now we know the real chunk count.
3312        let total_chunks = root_map.len();
3313        if let Some(ref tx) = progress {
3314            let _ = tx.try_send(DownloadEvent::DataMapResolved { total_chunks });
3315        }
3316
3317        // Phase 3: Fetch and decrypt data chunks with accurate progress.
3318        let fetched_counter = Arc::new(std::sync::atomic::AtomicUsize::new(0));
3319        let fetched_for_closure = fetched_counter.clone();
3320        let progress_for_closure = progress.clone();
3321        let peer_reports_for_closure = peer_reports.clone();
3322
3323        let fetch_limiter_outer = self.controller().fetch.clone();
3324        let usable_memory = usable_memory_bytes();
3325        let configured_batch_floor = stream_decrypt_batch_size();
3326        let fetch_cap = fetch_limiter_outer.current();
3327        let decrypt_batch_size = adaptive_stream_decrypt_batch_size(
3328            total_chunks,
3329            fetch_cap,
3330            configured_batch_floor,
3331            usable_memory,
3332        );
3333        info!(
3334            total_chunks,
3335            fetch_cap,
3336            configured_batch_floor,
3337            ?usable_memory,
3338            decrypt_batch_size,
3339            "Selected adaptive stream decrypt batch size"
3340        );
3341
3342        let stream = streaming_decrypt_with_batch_size(
3343            &root_map,
3344            |batch: &[(usize, XorName)]| {
3345                let batch_owned: Vec<(usize, XorName)> = batch.to_vec();
3346                let fetch_context = FileDownloadFetchContext {
3347                    total_chunks,
3348                    peer_count,
3349                    fetched_ref: fetched_for_closure.clone(),
3350                    progress_ref: progress_for_closure.clone(),
3351                    peer_reports: peer_reports_for_closure.clone(),
3352                };
3353                let fetch_limiter = fetch_limiter_outer.clone();
3354
3355                tokio::task::block_in_place(|| {
3356                    handle.block_on(async {
3357                        // First pass: try every chunk in the batch. Normal mode
3358                        // uses chunk_get_observed (early-return after a found
3359                        // peer); diagnostic mode asks every selected closest
3360                        // peer and records that sweep before returning bytes.
3361                        // Any missing chunk or transient fetch error is encoded
3362                        // as Err(hash), so one noisy chunk does not abort the
3363                        // whole batch before the deferred retry rounds run.
3364                        let first_fetch_context = fetch_context.clone();
3365                        let raw: Vec<DownloadBatchEntry> = rebucketed_unordered(
3366                            &fetch_limiter,
3367                            batch_owned,
3368                            |(idx, hash): (usize, XorName)| {
3369                                let fetch_context = first_fetch_context.clone();
3370                                async move {
3371                                    self.download_fetch_file_chunk(
3372                                        idx,
3373                                        hash,
3374                                        fetch_context,
3375                                        false,
3376                                        FIRST_DIAGNOSTIC_FETCH_ATTEMPT,
3377                                    )
3378                                    .await
3379                                }
3380                            },
3381                        )
3382                        .await?;
3383
3384                        // Partition: things we already have vs the
3385                        // deferred set we need to retry.
3386                        let mut results: Vec<(usize, bytes::Bytes)> = Vec::new();
3387                        let mut deferred: Vec<(usize, XorName)> = Vec::new();
3388                        for (idx, inner) in raw {
3389                            match inner {
3390                                Ok(bytes) => results.push((idx, bytes)),
3391                                Err(hash) => deferred.push((idx, hash)),
3392                            }
3393                        }
3394
3395                        // Deferred retry pass: retry the deferred chunks
3396                        // in CONCURRENT rounds (reusing the fetch
3397                        // limiter's cap), not serially. The first round
3398                        // fires immediately — most deferrals on a
3399                        // healthy-but-lossy link are peer-side noise
3400                        // that clears in well under a second, and
3401                        // serializing them behind mandatory multi-second
3402                        // sleeps was the single biggest throughput sink
3403                        // on such links (a batch deferring ~20 chunks
3404                        // burned minutes of near-zero throughput even
3405                        // though every chunk succeeded on its first
3406                        // retry). Only chunks that survive a round get a
3407                        // longer back-off before the next, so genuine
3408                        // saturation still gets time to settle.
3409                        if !deferred.is_empty() {
3410                            // Round delays in seconds. Round 0 is
3411                            // immediate; later rounds back off to ride
3412                            // out sustained saturation.
3413                            const DEFERRED_ROUND_DELAYS_SECS: [u64; 3] = [0, 15, 45];
3414                            info!(
3415                                "Deferring {} chunk(s) for concurrent retry after batch settles",
3416                                deferred.len()
3417                            );
3418                            let mut remaining = deferred;
3419                            for (round, &delay_secs) in
3420                                DEFERRED_ROUND_DELAYS_SECS.iter().enumerate()
3421                            {
3422                                if remaining.is_empty() {
3423                                    break;
3424                                }
3425                                if delay_secs > 0 {
3426                                    tokio::time::sleep(std::time::Duration::from_secs(delay_secs))
3427                                        .await;
3428                                }
3429                                info!(
3430                                    "Deferred retry round {}/{}: {} chunk(s)",
3431                                    round + 1,
3432                                    DEFERRED_ROUND_DELAYS_SECS.len(),
3433                                    remaining.len(),
3434                                );
3435                                let round_input = std::mem::take(&mut remaining);
3436                                let retry_fetch_context = fetch_context.clone();
3437                                let round_results: Vec<DownloadBatchEntry> = rebucketed_unordered(
3438                                    &fetch_limiter,
3439                                    round_input,
3440                                    |(idx, hash): (usize, XorName)| {
3441                                        let fetch_context = retry_fetch_context.clone();
3442                                        async move {
3443                                            self.download_fetch_file_chunk(
3444                                                idx,
3445                                                hash,
3446                                                fetch_context,
3447                                                true,
3448                                                round + DEFERRED_RETRY_ATTEMPT_OFFSET,
3449                                            )
3450                                            .await
3451                                        }
3452                                    },
3453                                )
3454                                .await?;
3455                                for (idx, inner) in round_results {
3456                                    match inner {
3457                                        Ok(bytes) => results.push((idx, bytes)),
3458                                        Err(hash) => remaining.push((idx, hash)),
3459                                    }
3460                                }
3461                            }
3462                            if let Some((_, hash)) = remaining.first() {
3463                                return Err(self_encryption::Error::Generic(format!(
3464                                    "Chunk not found after {} deferred retry rounds: {}",
3465                                    DEFERRED_ROUND_DELAYS_SECS.len(),
3466                                    hex::encode(hash.0),
3467                                )));
3468                            }
3469                        }
3470
3471                        // streaming_decrypt itself sort_by_keys before
3472                        // zipping, but the same closure is also passed
3473                        // through get_root_data_map_parallel internally
3474                        // (see self_encryption::stream_decrypt.rs::new), and
3475                        // THAT path zips positionally without sorting. Sort
3476                        // here so both consumers see input order.
3477                        results.sort_by_key(|(idx, _)| *idx);
3478                        Ok(results)
3479                    })
3480                })
3481            },
3482            decrypt_batch_size,
3483        )
3484        .map_err(|e| Error::Encryption(format!("streaming decrypt failed: {e}")))?;
3485
3486        // Drive the iterator (each `next()` runs the batched fetch via
3487        // block_in_place) and hand each decrypted segment to the sink in
3488        // order. Awaiting the sink between items yields back to the runtime so
3489        // a bounded sink can apply backpressure.
3490        let mut bytes_total = 0u64;
3491        for chunk_result in stream {
3492            let chunk: Bytes =
3493                chunk_result.map_err(|e| Error::Encryption(format!("decryption failed: {e}")))?;
3494            bytes_total += chunk.len() as u64;
3495            on_chunk(chunk).await?;
3496        }
3497        Ok(bytes_total)
3498    }
3499
3500    /// Download and decrypt a file to disk, with optional progress events.
3501    ///
3502    /// Same as [`Client::file_download`] but sends [`DownloadEvent`]s for UI
3503    /// feedback. Streams to a temp file (one decrypt batch resident at a time)
3504    /// and renames atomically on success. A `TempDownload` guard removes the
3505    /// staging file on any error path, including a panic.
3506    pub async fn file_download_with_progress(
3507        &self,
3508        data_map: &DataMap,
3509        output: &Path,
3510        progress: Option<mpsc::Sender<DownloadEvent>>,
3511    ) -> Result<u64> {
3512        self.file_download_with_progress_using_peer_count(
3513            data_map,
3514            output,
3515            progress,
3516            self.config().close_group_size,
3517        )
3518        .await
3519    }
3520
3521    /// Download and decrypt a file to disk with progress events, trying
3522    /// `peer_count` closest peers for every chunk fetch.
3523    ///
3524    /// Streams to a temp file (one decrypt batch resident at a time) and
3525    /// renames atomically on success.
3526    async fn file_download_with_progress_using_peer_count(
3527        &self,
3528        data_map: &DataMap,
3529        output: &Path,
3530        progress: Option<mpsc::Sender<DownloadEvent>>,
3531        peer_count: usize,
3532    ) -> Result<u64> {
3533        self.file_download_with_progress_using_peer_count_and_reports(
3534            data_map, output, progress, peer_count, None,
3535        )
3536        .await
3537    }
3538
3539    async fn file_download_with_progress_using_peer_count_and_reports(
3540        &self,
3541        data_map: &DataMap,
3542        output: &Path,
3543        progress: Option<mpsc::Sender<DownloadEvent>>,
3544        peer_count: usize,
3545        peer_reports: Option<Arc<Mutex<Vec<RecordedFileChunkPeerSweep>>>>,
3546    ) -> Result<u64> {
3547        debug!("Downloading file to {}", output.display());
3548
3549        let parent = output.parent().unwrap_or_else(|| Path::new("."));
3550        let unique: u64 = rand::random();
3551        let tmp_path = parent.join(format!(".ant_download_{}_{unique}.tmp", std::process::id()));
3552
3553        // Guard removes the staging file on any early return OR a panic unwind
3554        // out of the `block_in_place` decrypt loop; defused only by a
3555        // successful commit(). Centralizes what used to be three duplicated
3556        // cleanup arms.
3557        let tmp = TempDownload::new(tmp_path);
3558        let mut file = std::fs::File::create(tmp.path())?;
3559
3560        let bytes_written = self
3561            .download_decrypted_chunks(data_map, progress, peer_count, peer_reports, |bytes| {
3562                let r = file.write_all(&bytes).map_err(Error::from);
3563                std::future::ready(r)
3564            })
3565            .await?;
3566        file.flush()?;
3567        drop(file); // close the handle before rename (Windows won't rename an open file)
3568
3569        tmp.commit(output)?;
3570        info!(
3571            "File downloaded: {bytes_written} bytes written to {}",
3572            output.display()
3573        );
3574        Ok(bytes_written)
3575    }
3576
3577    /// Download and decrypt a file, streaming the plaintext to `sink` instead
3578    /// of writing to disk.
3579    ///
3580    /// Constant memory (one decrypt batch resident at a time); the caller
3581    /// receives bytes progressively as each batch decrypts, suitable for
3582    /// forwarding to an HTTP chunked body or a gRPC response stream. The
3583    /// bounded `sink` applies backpressure. If the receiver is dropped (e.g.
3584    /// the client disconnected) the download stops early and returns
3585    /// [`Error::Cancelled`].
3586    ///
3587    /// The channel item type is `Result<Bytes, Error>`, so the caller sets up:
3588    ///
3589    /// ```ignore
3590    /// let (tx, rx) = tokio::sync::mpsc::channel::<Result<Bytes, Error>>(8);
3591    /// ```
3592    ///
3593    /// Typically the caller `tokio::spawn`s this and converts the matching
3594    /// `Receiver` into its response stream. Requires a multi-threaded Tokio
3595    /// runtime (the decrypt iterator uses `block_in_place`).
3596    pub async fn file_download_to_sender(
3597        &self,
3598        data_map: &DataMap,
3599        sink: mpsc::Sender<std::result::Result<Bytes, Error>>,
3600        progress: Option<mpsc::Sender<DownloadEvent>>,
3601    ) -> Result<u64> {
3602        let peer_count = self.config().close_group_size;
3603        self.download_decrypted_chunks(data_map, progress, peer_count, None, |bytes| {
3604            let sink = sink.clone();
3605            async move {
3606                sink.send(Ok(bytes))
3607                    .await
3608                    .map_err(|_| Error::Cancelled("download stream receiver dropped".into()))
3609            }
3610        })
3611        .await
3612    }
3613}
3614
3615#[cfg(test)]
3616#[allow(clippy::unwrap_used)]
3617mod tests {
3618    use super::*;
3619
3620    #[test]
3621    fn merkle_store_cap_clamps_to_memory_bound() {
3622        // Below the ceiling: pass the adaptive cap through unchanged.
3623        assert_eq!(merkle_store_cap(8), 8);
3624        assert_eq!(merkle_store_cap(64), 64);
3625        // A configured `adaptive.max.store` above the ceiling must be clamped so
3626        // the whole-file fan-out can't pin more than ~256 MB of bodies (PR #137).
3627        assert_eq!(merkle_store_cap(512), MERKLE_STORE_MAX_IN_FLIGHT);
3628        assert_eq!(merkle_store_cap(usize::MAX), MERKLE_STORE_MAX_IN_FLIGHT);
3629        // Never zero — always make progress.
3630        assert_eq!(merkle_store_cap(0), 1);
3631    }
3632
3633    #[test]
3634    fn distributed_sample_indices_spreads_across_large_file() {
3635        // cap 5 over 100 chunks: first and last included, evenly spread.
3636        assert_eq!(distributed_sample_indices(100, 5), vec![0, 24, 49, 74, 99]);
3637    }
3638
3639    #[test]
3640    fn distributed_sample_indices_covers_whole_small_file() {
3641        // total <= cap returns every index, preserving the exact
3642        // "whole file sampled" detection in estimate_upload_cost.
3643        assert_eq!(distributed_sample_indices(3, 5), vec![0, 1, 2]);
3644        assert_eq!(distributed_sample_indices(5, 5), vec![0, 1, 2, 3, 4]);
3645    }
3646
3647    /// The estimator bills the padded tree, and the leaf total it bills comes
3648    /// from the batches the payment path really builds — a `[255, 2]` split of
3649    /// 257 chunks, not a `[256, 1]` one that could never be paid.
3650    #[test]
3651    fn estimator_leaf_total_is_the_padded_payment_partition() {
3652        for chunks in [2u64, 64, 65, 100, 129, 255, 256, 257, 300, 512, 513, 769] {
3653            let from_partition: u64 = merkle_batch_sizes(chunks as usize)
3654                .into_iter()
3655                .map(|size| size.next_power_of_two() as u64)
3656                .sum();
3657            assert_eq!(
3658                merkle_billable_leaves(chunks),
3659                from_partition,
3660                "{chunks} chunks must be billed for the partition the payment path pays"
3661            );
3662        }
3663    }
3664
3665    #[test]
3666    fn distributed_sample_indices_is_in_range_and_increasing() {
3667        assert!(distributed_sample_indices(0, 5).is_empty());
3668        assert_eq!(distributed_sample_indices(1, 5), vec![0]);
3669        for total in 1..200usize {
3670            let idx = distributed_sample_indices(total, 5);
3671            assert_eq!(*idx.first().unwrap(), 0);
3672            assert_eq!(*idx.last().unwrap(), total - 1);
3673            assert!(idx.iter().all(|&i| i < total));
3674            assert!(idx.windows(2).all(|w| w[0] < w[1]));
3675        }
3676    }
3677
3678    #[test]
3679    fn disk_space_check_passes_for_small_file() {
3680        // A 1 KB file should always pass the disk space check
3681        check_disk_space_for_spill(1024).unwrap();
3682    }
3683
3684    #[test]
3685    fn disk_space_check_fails_for_absurd_size() {
3686        // Requesting space for a 1 exabyte file should fail on any real system
3687        let result = check_disk_space_for_spill(u64::MAX / 2);
3688        assert!(result.is_err());
3689        let err = result.unwrap_err();
3690        assert!(
3691            matches!(err, Error::InsufficientDiskSpace(_)),
3692            "expected InsufficientDiskSpace, got: {err}"
3693        );
3694    }
3695
3696    /// External multi-batch payment fold: winner-hash validation and
3697    /// paid/unpaid mixes (ADR-0003).
3698    mod external_merkle_fold {
3699        use super::*;
3700        use crate::data::client::merkle::test_support::{
3701            make_prepared_merkle_batch, winner_hash_for,
3702        };
3703
3704        #[test]
3705        fn hash_count_mismatch_is_rejected() {
3706            let batches = vec![make_prepared_merkle_batch(2), make_prepared_merkle_batch(3)];
3707            let err = fold_external_merkle_payments(batches, vec![None]).unwrap_err();
3708            assert!(
3709                err.to_string().contains("winner pool hash entries"),
3710                "unexpected error: {err}"
3711            );
3712        }
3713
3714        #[test]
3715        fn all_unpaid_is_rejected() {
3716            let batches = vec![make_prepared_merkle_batch(2)];
3717            let err = fold_external_merkle_payments(batches, vec![None]).unwrap_err();
3718            assert!(
3719                err.to_string().contains("No merkle sub-batch was paid"),
3720                "unexpected error: {err}"
3721            );
3722        }
3723
3724        /// A k-of-N payment makes forward progress: the paid batch's proofs
3725        /// fold in, the unpaid batch contributes none — so the store phase
3726        /// reports its chunks via `PartialUpload` instead of aborting.
3727        #[test]
3728        fn paid_batches_fold_and_unpaid_contribute_no_proofs() {
3729            let paid = make_prepared_merkle_batch(2);
3730            let unpaid = make_prepared_merkle_batch(3);
3731            let winner = winner_hash_for(&paid);
3732            let merged =
3733                fold_external_merkle_payments(vec![paid, unpaid], vec![Some(winner), None])
3734                    .unwrap();
3735            assert_eq!(merged.proofs.len(), 2, "proofs cover only the paid batch");
3736            assert_eq!(merged.chunk_count, 2);
3737        }
3738    }
3739
3740    #[test]
3741    fn adaptive_stream_decrypt_batch_size_tracks_fetch_headroom() {
3742        let batch_size = adaptive_stream_decrypt_batch_size(1_000, 64, 10, Some(u64::MAX));
3743
3744        assert_eq!(batch_size, 64 * DOWNLOAD_STREAM_BATCH_FETCH_MULTIPLIER);
3745    }
3746
3747    #[test]
3748    fn adaptive_stream_decrypt_batch_size_caps_to_total_chunks() {
3749        let batch_size = adaptive_stream_decrypt_batch_size(12, 64, 10, Some(u64::MAX));
3750
3751        assert_eq!(batch_size, 12);
3752    }
3753
3754    #[test]
3755    fn adaptive_stream_decrypt_batch_size_honours_configured_floor() {
3756        let batch_size = adaptive_stream_decrypt_batch_size(1_000, 1, 32, None);
3757
3758        assert_eq!(batch_size, 32);
3759    }
3760
3761    #[test]
3762    fn adaptive_stream_decrypt_batch_size_does_not_expand_without_memory_reading() {
3763        let batch_size = adaptive_stream_decrypt_batch_size(1_000, 64, 10, None);
3764
3765        assert_eq!(batch_size, 10);
3766    }
3767
3768    #[test]
3769    fn adaptive_stream_decrypt_batch_size_caps_to_memory_budget() {
3770        let estimated_bytes_per_chunk = (self_encryption::MAX_CHUNK_SIZE as u64)
3771            .saturating_mul(DOWNLOAD_STREAM_BATCH_BYTES_PER_CHUNK_MULTIPLIER)
3772            .max(1);
3773        let usable_memory = estimated_bytes_per_chunk
3774            .saturating_mul(16)
3775            .saturating_mul(DOWNLOAD_STREAM_BATCH_MEMORY_BUDGET_DIVISOR);
3776        let batch_size = adaptive_stream_decrypt_batch_size(1_000, 256, 10, Some(usable_memory));
3777
3778        assert_eq!(batch_size, 16);
3779    }
3780
3781    #[test]
3782    fn adaptive_stream_decrypt_batch_size_keeps_one_chunk_when_memory_is_tight() {
3783        let batch_size = adaptive_stream_decrypt_batch_size(1_000, 64, 10, Some(1));
3784
3785        assert_eq!(batch_size, 1);
3786    }
3787
3788    #[test]
3789    fn cached_merkle_covers_only_when_all_addresses_have_proofs() {
3790        let covered = compute_address(&Bytes::from_static(b"covered"));
3791        let extra = compute_address(&Bytes::from_static(b"extra"));
3792        let missing = compute_address(&Bytes::from_static(b"missing"));
3793        let cached = MerkleBatchPaymentResult {
3794            proofs: HashMap::from([(covered, vec![1]), (extra, vec![2])]),
3795            chunk_count: 2,
3796            storage_cost_atto: "0".to_string(),
3797            gas_cost_wei: 0,
3798            merkle_payment_timestamp: 0,
3799        };
3800
3801        assert!(cached_merkle_covers_addresses(&cached, &[covered]));
3802        assert!(cached_merkle_covers_addresses(&cached, &[covered, extra]));
3803        assert!(!cached_merkle_covers_addresses(
3804            &cached,
3805            &[covered, missing]
3806        ));
3807    }
3808
3809    /// A partial merkle payment leaves some addresses without a proof. Those
3810    /// must be split out so `upload_merkle_from_spill` reports them as failed
3811    /// (`PartialUpload`) instead of aborting the whole file — preserving the
3812    /// addresses' original order in each group.
3813    #[test]
3814    fn partition_addresses_by_proof_splits_paid_and_unpaid() {
3815        let paid_a = [1u8; 32];
3816        let unpaid_b = [2u8; 32];
3817        let paid_c = [3u8; 32];
3818        let unpaid_d = [4u8; 32];
3819        let proofs: HashMap<[u8; 32], Vec<u8>> =
3820            HashMap::from([(paid_a, vec![0xaa]), (paid_c, vec![0xcc])]);
3821
3822        let (to_store, missing) =
3823            partition_addresses_by_proof(&[paid_a, unpaid_b, paid_c, unpaid_d], &proofs);
3824
3825        assert_eq!(to_store, vec![paid_a, paid_c]);
3826        assert_eq!(missing, vec![unpaid_b, unpaid_d]);
3827    }
3828
3829    /// A wave that returns `Ok` contributes its stored chunks, parsed cost, and
3830    /// stats; nothing is recorded as failed.
3831    #[test]
3832    fn fold_single_wave_keeps_ok_wave() {
3833        let stored = vec![[1u8; 32], [2u8; 32]];
3834        let stats = WaveAggregateStats {
3835            chunk_attempts_total: 7,
3836            ..Default::default()
3837        };
3838
3839        let outcome = fold_single_wave(Ok((stored.clone(), "100".to_string(), 9, stats))).unwrap();
3840
3841        assert_eq!(outcome.stored, stored);
3842        assert!(outcome.failed.is_empty());
3843        assert_eq!(outcome.storage_atto.to_string(), "100");
3844        assert_eq!(outcome.gas_wei, 9);
3845        assert_eq!(outcome.stats.chunk_attempts_total, 7);
3846    }
3847
3848    /// The core V2-461 semantic: a wave short of quorum (`PartialUpload`) is
3849    /// recoverable — its stored chunks, failed chunks, and on-chain spend are
3850    /// folded so the caller can continue to the next wave rather than aborting
3851    /// the whole file.
3852    #[test]
3853    fn fold_single_wave_folds_partial_upload() {
3854        let stored = vec![[3u8; 32]];
3855        let failed = vec![([4u8; 32], "short of quorum".to_string())];
3856        let err = Error::PartialUpload {
3857            stored: stored.clone(),
3858            stored_count: 1,
3859            failed: failed.clone(),
3860            failed_count: 1,
3861            total_chunks: 2,
3862            spend: Box::new(PartialUploadSpend {
3863                storage_cost_atto: "250".to_string(),
3864                gas_cost_wei: 11,
3865            }),
3866            reason: "wave store failed after retries".to_string(),
3867        };
3868
3869        let outcome = fold_single_wave(Err(err)).unwrap();
3870
3871        assert_eq!(outcome.stored, stored);
3872        assert_eq!(outcome.failed, failed);
3873        assert_eq!(outcome.storage_atto.to_string(), "250");
3874        assert_eq!(outcome.gas_wei, 11);
3875        // `PartialUpload` carries no stats, so the failed wave contributes none.
3876        assert_eq!(outcome.stats.chunk_attempts_total, 0);
3877    }
3878
3879    /// A non-`PartialUpload` error (wallet/payment-infrastructure failure) is
3880    /// fatal and must abort the file, not be folded into the failed set.
3881    #[test]
3882    fn fold_single_wave_propagates_fatal_error() {
3883        let result = fold_single_wave(Err(Error::Payment("wallet unavailable".to_string())));
3884
3885        assert!(
3886            matches!(result, Err(Error::Payment(_))),
3887            "fatal payment error must propagate, got: {result:?}"
3888        );
3889    }
3890
3891    #[test]
3892    fn partition_addresses_by_proof_handles_all_or_nothing() {
3893        let a = [5u8; 32];
3894        let b = [6u8; 32];
3895
3896        // No proofs at all → every address is missing.
3897        let empty: HashMap<[u8; 32], Vec<u8>> = HashMap::new();
3898        let (to_store, missing) = partition_addresses_by_proof(&[a, b], &empty);
3899        assert!(to_store.is_empty());
3900        assert_eq!(missing, vec![a, b]);
3901
3902        // All proofs present → nothing missing.
3903        let full: HashMap<[u8; 32], Vec<u8>> = HashMap::from([(a, vec![1]), (b, vec![2])]);
3904        let (to_store, missing) = partition_addresses_by_proof(&[a, b], &full);
3905        assert_eq!(to_store, vec![a, b]);
3906        assert!(missing.is_empty());
3907    }
3908
3909    #[test]
3910    fn chunk_spill_round_trip() {
3911        let mut spill = ChunkSpill::new().unwrap();
3912        let data1 = vec![0xAA; 1024];
3913        let data2 = vec![0xBB; 2048];
3914
3915        spill.push(&data1).unwrap();
3916        spill.push(&data2).unwrap();
3917
3918        assert_eq!(spill.len(), 2);
3919        assert_eq!(spill.total_bytes(), 1024 + 2048);
3920        let chunk_entries = spill.chunk_entries().unwrap();
3921        let entry_total: u64 = chunk_entries.iter().map(|(_, size)| *size).sum();
3922        assert_eq!(entry_total, 1024 + 2048);
3923
3924        // Read back and verify
3925        let chunk1 = spill.read_chunk(spill.addresses.first().unwrap()).unwrap();
3926        assert_eq!(&chunk1[..], &data1[..]);
3927
3928        let chunk2 = spill.read_chunk(spill.addresses.get(1).unwrap()).unwrap();
3929        assert_eq!(&chunk2[..], &data2[..]);
3930
3931        // Verify waves with 1-chunk wave size
3932        let waves: Vec<_> = spill.addresses.chunks(1).collect();
3933        assert_eq!(waves.len(), 2);
3934    }
3935
3936    #[test]
3937    fn chunk_spill_cleanup_on_drop() {
3938        let dir;
3939        {
3940            let spill = ChunkSpill::new().unwrap();
3941            dir = spill.dir.clone();
3942            assert!(dir.exists());
3943        }
3944        // After drop, the directory should be cleaned up
3945        assert!(!dir.exists(), "spill dir should be removed on drop");
3946    }
3947
3948    #[test]
3949    fn chunk_spill_deduplicates_identical_content() {
3950        let mut spill = ChunkSpill::new().unwrap();
3951        let data = vec![0xCC; 512];
3952
3953        spill.push(&data).unwrap();
3954        spill.push(&data).unwrap(); // same content, should be skipped
3955        spill.push(&data).unwrap(); // again
3956
3957        assert_eq!(spill.len(), 1, "duplicate chunks should be deduplicated");
3958        assert_eq!(
3959            spill.total_bytes(),
3960            512,
3961            "total_bytes should count unique only"
3962        );
3963
3964        // Different content should still be added
3965        let data2 = vec![0xDD; 256];
3966        spill.push(&data2).unwrap();
3967        assert_eq!(spill.len(), 2);
3968        assert_eq!(spill.total_bytes(), 512 + 256);
3969    }
3970}
3971
3972/// Compile-time assertions that Client file method futures are Send.
3973#[cfg(test)]
3974mod send_assertions {
3975    use super::*;
3976
3977    fn _assert_send<T: Send>(_: &T) {}
3978
3979    #[allow(dead_code, unreachable_code, clippy::diverging_sub_expression)]
3980    async fn _file_upload_is_send(client: &Client) {
3981        let fut = client.file_upload(Path::new("/dev/null"));
3982        _assert_send(&fut);
3983    }
3984
3985    #[allow(dead_code, unreachable_code, clippy::diverging_sub_expression)]
3986    async fn _file_upload_with_mode_is_send(client: &Client) {
3987        let fut = client.file_upload_with_mode(Path::new("/dev/null"), PaymentMode::Auto);
3988        _assert_send(&fut);
3989    }
3990
3991    #[allow(
3992        dead_code,
3993        unreachable_code,
3994        unused_variables,
3995        clippy::diverging_sub_expression
3996    )]
3997    async fn _file_download_is_send(client: &Client) {
3998        let dm: DataMap = todo!();
3999        let fut = client.file_download(&dm, Path::new("/dev/null"));
4000        _assert_send(&fut);
4001    }
4002}