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    chunk_contents_for_upload_addresses, finalize_merkle_batch, merkle_deferred_retry,
21    merkle_store_with_retry, should_use_merkle, MerkleBatchPaymentResult, PaymentMode,
22    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    /// Clean up the spill directory.
613    fn cleanup(&self) {
614        if let Err(e) = std::fs::remove_dir_all(&self.dir) {
615            warn!(
616                "Failed to clean up chunk spill dir {}: {e}",
617                self.dir.display()
618            );
619        }
620    }
621}
622
623impl Drop for ChunkSpill {
624    fn drop(&mut self) {
625        self.cleanup();
626    }
627}
628
629fn cached_merkle_covers_addresses(
630    cached: &MerkleBatchPaymentResult,
631    addresses: &[[u8; 32]],
632) -> bool {
633    addresses
634        .iter()
635        .all(|addr| cached.proofs.contains_key(addr))
636}
637
638/// Split `addresses` into `(to_store, missing_proof)`: those that have a merkle
639/// proof in `proofs`, and those that don't.
640///
641/// A partial [`MerkleBatchPaymentResult`] (from a `pay_for_merkle_multi_batch`
642/// where a later sub-batch's payment failed) carries proofs only for the
643/// already-paid sub-batches, so unpaid chunks reach the upload path with no
644/// proof. `upload_merkle_from_spill` reports those as failed via
645/// [`Error::PartialUpload`] rather than aborting the whole file. Order within
646/// each group follows `addresses`.
647fn partition_addresses_by_proof(
648    addresses: &[[u8; 32]],
649    proofs: &HashMap<[u8; 32], Vec<u8>>,
650) -> (Vec<[u8; 32]>, Vec<[u8; 32]>) {
651    addresses
652        .iter()
653        .copied()
654        .partition(|addr| proofs.contains_key(addr))
655}
656
657/// Build a `PartialUpload` after a fatal merkle store error, with accurate
658/// counts.
659///
660/// A fatal abort can leave chunks in three states: confirmed stored (in
661/// `stored_addresses`), known-failed (in `known_failed` — missing proofs, the
662/// quorum shortfalls and the fatal chunk seen so far), and "in flight when the
663/// abort hit" (neither). Rather than trust the helpers to enumerate the last
664/// group, this derives the failed set authoritatively as *every* `addresses`
665/// entry not in `stored_addresses`, preferring a known per-chunk message and
666/// falling back to the fatal `reason`. That guarantees
667/// `stored_count + failed_count` accounts for the whole file — fixing the
668/// under-reporting where a fatal wave could surface `failed_count = 0` and omit
669/// same-pass successes.
670fn partial_upload_after_fatal(
671    addresses: &[[u8; 32]],
672    stored_addresses: Vec<[u8; 32]>,
673    stored_count: usize,
674    total_chunks: usize,
675    known_failed: Vec<([u8; 32], String)>,
676    spend: PartialUploadSpend,
677    reason: String,
678) -> Error {
679    let stored_set: HashSet<[u8; 32]> = stored_addresses.iter().copied().collect();
680    let mut failed_map: HashMap<[u8; 32], String> = HashMap::new();
681    for (addr, msg) in known_failed {
682        if !stored_set.contains(&addr) {
683            failed_map.entry(addr).or_insert(msg);
684        }
685    }
686    for addr in addresses {
687        if !stored_set.contains(addr) {
688            failed_map.entry(*addr).or_insert_with(|| reason.clone());
689        }
690    }
691    let failed: Vec<([u8; 32], String)> = failed_map.into_iter().collect();
692    let failed_count = failed.len();
693    Error::PartialUpload {
694        stored: stored_addresses,
695        stored_count,
696        failed,
697        failed_count,
698        total_chunks,
699        spend: Box::new(spend),
700        reason,
701    }
702}
703
704/// One wave's contribution to a single-node upload, distilled from its
705/// `batch_upload_chunks_with_events` result.
706#[derive(Debug)]
707struct SingleWaveOutcome {
708    /// Addresses confirmed stored in this wave.
709    stored: Vec<[u8; 32]>,
710    /// Chunks that failed after retries in this wave.
711    failed: Vec<([u8; 32], String)>,
712    /// Storage cost paid on-chain for this wave, in atto-tokens.
713    storage_atto: Amount,
714    /// Gas paid on-chain for this wave, in wei.
715    gas_wei: u128,
716    /// Per-wave store/retry statistics. Empty for a quorum-short wave, whose
717    /// `PartialUpload` carries no stats.
718    stats: WaveAggregateStats,
719}
720
721/// Fold one wave's batch-upload result for the single-node path.
722///
723/// A `PartialUpload` (chunks short of quorum after retries) is **recoverable**:
724/// its stored/failed chunks and on-chain spend are returned so the caller
725/// records them and continues to the next wave, making the file make maximum
726/// progress exactly like `upload_merkle_from_spill`. Every other error is **fatal**
727/// (wallet/payment-infrastructure failures, missing proofs, spill reads) and is
728/// returned via `Err` to abort the file. Because `UPLOAD_WAVE_SIZE ==
729/// PAYMENT_WAVE_SIZE`, each batch call is exactly one payment wave, so folding a
730/// `PartialUpload` leaves nothing un-attempted within the wave.
731fn fold_single_wave(
732    result: Result<(Vec<[u8; 32]>, String, u128, WaveAggregateStats)>,
733) -> Result<SingleWaveOutcome> {
734    match result {
735        Ok((stored, storage, gas, stats)) => Ok(SingleWaveOutcome {
736            stored,
737            failed: Vec::new(),
738            storage_atto: storage.parse().unwrap_or(Amount::ZERO),
739            gas_wei: gas,
740            stats,
741        }),
742        Err(Error::PartialUpload {
743            stored,
744            failed,
745            spend,
746            ..
747        }) => Ok(SingleWaveOutcome {
748            stored,
749            failed,
750            storage_atto: spend.storage_cost_atto.parse().unwrap_or(Amount::ZERO),
751            gas_wei: spend.gas_cost_wei,
752            stats: WaveAggregateStats::default(),
753        }),
754        Err(e) => Err(e),
755    }
756}
757
758/// Check that the spill directory has enough free space for the spilled chunks.
759///
760/// `file_size` is the source file's byte count. We require
761/// `file_size + 10%` free space to account for self-encryption overhead.
762fn check_disk_space_for_spill(file_size: u64) -> Result<()> {
763    let spill_root = ChunkSpill::spill_root()?;
764
765    // Ensure the root exists so fs2 can query it.
766    std::fs::create_dir_all(&spill_root)?;
767
768    let available = fs2::available_space(&spill_root).map_err(|e| {
769        Error::Io(std::io::Error::new(
770            e.kind(),
771            format!(
772                "failed to query disk space on {}: {e}",
773                spill_root.display()
774            ),
775        ))
776    })?;
777
778    // Use integer arithmetic to avoid f64 precision loss on large file sizes.
779    let headroom = file_size / DISK_SPACE_HEADROOM_PERCENT;
780    let required = file_size.saturating_add(headroom);
781
782    if available < required {
783        let avail_mb = available / (1024 * 1024);
784        let req_mb = required / (1024 * 1024);
785        return Err(Error::InsufficientDiskSpace(format!(
786            "need ~{req_mb} MB in spill dir ({}) but only {avail_mb} MB available",
787            spill_root.display()
788        )));
789    }
790
791    debug!(
792        "Disk space check passed: {available} bytes available, {required} bytes required (spill: {})",
793        spill_root.display()
794    );
795    Ok(())
796}
797
798fn usable_memory_bytes() -> Option<u64> {
799    let mut system = sysinfo::System::new();
800    system.refresh_memory();
801
802    let available_memory = system.available_memory();
803    let free_memory = system.free_memory();
804    let used_memory = system.used_memory();
805    let total_memory = system.total_memory();
806    let unused_memory = total_memory.saturating_sub(used_memory);
807
808    let mut usable = [available_memory, free_memory, unused_memory]
809        .into_iter()
810        .filter(|bytes| *bytes > 0)
811        .max();
812
813    let cgroup_free_memory = system
814        .cgroup_limits()
815        .filter(|limits| limits.total_memory > 0)
816        .map(|limits| limits.free_memory);
817    if let Some(cgroup_free_memory) = cgroup_free_memory {
818        usable = Some(usable.unwrap_or(u64::MAX).min(cgroup_free_memory));
819    }
820
821    debug!(
822        available_memory,
823        free_memory,
824        used_memory,
825        total_memory,
826        cgroup_free_memory,
827        usable_memory = ?usable,
828        "Detected usable memory for stream decrypt batch sizing"
829    );
830
831    usable
832}
833
834fn stream_decrypt_batch_memory_cap(usable_memory_bytes: u64) -> usize {
835    let budget = usable_memory_bytes / DOWNLOAD_STREAM_BATCH_MEMORY_BUDGET_DIVISOR;
836    let estimated_bytes_per_chunk = (self_encryption::MAX_CHUNK_SIZE as u64)
837        .saturating_mul(DOWNLOAD_STREAM_BATCH_BYTES_PER_CHUNK_MULTIPLIER)
838        .max(1);
839    let cap = (budget / estimated_bytes_per_chunk).max(1);
840
841    usize::try_from(cap).unwrap_or(usize::MAX)
842}
843
844fn adaptive_stream_decrypt_batch_size(
845    total_chunks: usize,
846    fetch_cap: usize,
847    configured_batch_floor: usize,
848    usable_memory_bytes: Option<u64>,
849) -> usize {
850    let fetch_target = fetch_cap
851        .max(1)
852        .saturating_mul(DOWNLOAD_STREAM_BATCH_FETCH_MULTIPLIER);
853    let requested = match usable_memory_bytes {
854        Some(bytes) => {
855            let memory_cap = stream_decrypt_batch_memory_cap(bytes);
856            configured_batch_floor
857                .max(fetch_target)
858                .max(1)
859                .min(memory_cap)
860        }
861        None => configured_batch_floor.max(1),
862    };
863
864    requested.min(total_chunks.max(1)).max(1)
865}
866
867/// Whether the data map is published to the network for address-based retrieval.
868///
869/// A private upload stores only the data chunks and returns the `DataMap` to
870/// the caller — only someone holding that `DataMap` can reconstruct the file.
871/// A public upload additionally stores the serialized `DataMap` as a chunk on
872/// the network, yielding a single chunk address that anyone can use to
873/// retrieve the `DataMap` (via [`Client::data_map_fetch`]) and then the file.
874#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
875pub enum Visibility {
876    /// Keep the data map local; only the holder can retrieve the file.
877    #[default]
878    Private,
879    /// Publish the data map as a network chunk so anyone with the returned
880    /// address can retrieve and decrypt the file.
881    Public,
882}
883
884/// Confidence attached to an [`UploadCostEstimate`]'s `storage_cost_atto`.
885///
886/// `estimate_upload_cost` prices a file by sampling a few of its chunk
887/// addresses and extrapolating. When every sampled chunk is already stored
888/// there is no live price to extrapolate from, so a `"0"` cost can mean either
889/// "provably free" (the whole file was sampled) or only "probably free" (the
890/// tail was unsampled). This lets callers tell those apart instead of treating
891/// every `"0"` as unconditionally free.
892#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
893#[serde(rename_all = "snake_case")]
894pub enum CostEstimateConfidence {
895    /// At least one sampled chunk returned a live quote; `storage_cost_atto`
896    /// is extrapolated from a real per-chunk price. The normal case.
897    #[default]
898    PricedSample,
899    /// Every chunk in the file was sampled and every one was already stored.
900    /// `storage_cost_atto` is exactly `"0"` — the upload is genuinely free.
901    VerifiedAllAlreadyStored,
902    /// Every *sampled* chunk was already stored, but not all chunks were
903    /// sampled. `storage_cost_atto` is `"0"` as a best-effort guess; the real
904    /// upload reconciles the true cost at payment time. Render this as "likely
905    /// already stored", not a guaranteed-free price.
906    AllSamplesAlreadyStoredIncomplete,
907}
908
909/// Estimated cost of uploading a file, returned by
910/// [`Client::estimate_upload_cost`].
911///
912/// Marked `#[non_exhaustive]` so adding a field later is not a breaking change
913/// for downstream consumers that construct or pattern-match on this struct.
914#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
915#[non_exhaustive]
916pub struct UploadCostEstimate {
917    /// Original file size in bytes.
918    pub file_size: u64,
919    /// Number of chunks the file would be split into (data chunks only,
920    /// does not include the DataMap chunk added during public uploads).
921    pub chunk_count: usize,
922    /// Estimated total storage cost in atto (token smallest unit).
923    pub storage_cost_atto: String,
924    /// Estimated gas cost in wei as a string. This is a rough heuristic
925    /// based on chunk count and payment mode, NOT a live gas price query.
926    pub estimated_gas_cost_wei: String,
927    /// Payment mode that would be used.
928    pub payment_mode: PaymentMode,
929    /// How much to trust `storage_cost_atto`. See [`CostEstimateConfidence`].
930    #[serde(default)]
931    pub confidence: CostEstimateConfidence,
932}
933
934/// Result of a file upload: the `DataMap` needed to retrieve the file.
935///
936/// Marked `#[non_exhaustive]` so adding a new field in future is not a
937/// breaking change for downstream consumers that construct or pattern-match
938/// on this struct.
939#[derive(Debug, Clone)]
940#[non_exhaustive]
941pub struct FileUploadResult {
942    /// The data map containing chunk metadata for reconstruction.
943    pub data_map: DataMap,
944    /// Number of chunks stored on the network.
945    pub chunks_stored: usize,
946    /// Number of chunks that failed to store. Always 0 for a successful
947    /// upload — partial-failure information is conveyed via
948    /// [`crate::data::Error::PartialUpload`] instead.
949    pub chunks_failed: usize,
950    /// Total number of chunks in the upload, including chunks that were
951    /// already stored and skipped. On full success this equals `chunks_stored`.
952    pub total_chunks: usize,
953    /// Which payment mode was actually used (not just requested).
954    pub payment_mode_used: PaymentMode,
955    /// Total storage cost paid in token units (atto). "0" if all chunks already existed.
956    pub storage_cost_atto: String,
957    /// Total gas cost in wei. 0 if no on-chain transactions were made.
958    pub gas_cost_wei: u128,
959    /// Chunk address of the serialized `DataMap`, set only for
960    /// [`Visibility::Public`] uploads. **`Some` means this address is
961    /// retrievable from the network (via [`Client::data_map_fetch`])**, not
962    /// necessarily that *this* upload paid to store it — if the serialized
963    /// `DataMap` hashed to a chunk that was already on the network (same
964    /// file uploaded before; deterministic via self-encryption), the address
965    /// is still returned but no storage payment was made for it.
966    pub data_map_address: Option<[u8; 32]>,
967    /// Sum of chunk-store RPC attempts across the upload
968    /// (`>= chunks_stored` on full success; more if any chunk retried).
969    /// `0` for paths that don't run the wave store loop.
970    pub chunk_attempts_total: usize,
971    /// Per-chunk store wall-clock in ms (length == `chunks_stored` on full
972    /// success, empty for paths that don't run the wave store loop).
973    pub store_durations_ms: Vec<u64>,
974    /// Count of stored chunks that succeeded on each retry round
975    /// (index 0 = first attempt, 1 = first retry, etc.). All zeros for
976    /// paths that don't run the wave store loop.
977    pub retries_histogram: [usize; 4],
978}
979
980/// Payment information for external signing — either wave-batch or merkle.
981// ADR-0004 added the signed commitment fields (`committed_key_count`,
982// `commitment_pin`) to the merkle candidate quotes carried inside
983// `PreparedMerkleBatch`, which grew the `Merkle` variant past the
984// `large_enum_variant` threshold. This enum is constructed one-off per payment
985// (never held in bulk collections), so the size delta is harmless; allow it
986// rather than box a field on the security-sensitive merkle-finalize path.
987#[allow(clippy::large_enum_variant)]
988#[derive(Debug)]
989pub enum ExternalPaymentInfo {
990    /// Wave-batch: individual (quote_hash, rewards_address, amount) tuples.
991    WaveBatch {
992        /// Chunks ready for payment (needed for finalize).
993        prepared_chunks: Vec<PreparedChunk>,
994        /// Payment intent for external signing.
995        payment_intent: PaymentIntent,
996    },
997    /// Merkle: single on-chain call with depth, pool commitments, timestamp.
998    Merkle {
999        /// The prepared merkle batch (public fields sent to frontend, private fields stay in Rust).
1000        prepared_batch: PreparedMerkleBatch,
1001        /// Raw chunk contents that still need upload after the preflight check.
1002        chunk_contents: Vec<Bytes>,
1003        /// Chunk addresses that still need upload after the preflight check.
1004        chunk_addresses: Vec<[u8; 32]>,
1005    },
1006}
1007
1008/// Prepared upload ready for external payment.
1009///
1010/// Contains everything needed to construct the on-chain payment transaction
1011/// externally (e.g. via WalletConnect in a desktop app) and then finalize
1012/// the upload without a Rust-side wallet.
1013///
1014/// Note: This struct stays in Rust memory — only the public fields of
1015/// `payment_info` are sent to the frontend. `PreparedChunk` contains
1016/// non-serializable network types, so the full struct cannot derive `Serialize`.
1017///
1018/// Marked `#[non_exhaustive]` so adding a new field in future is not a
1019/// breaking change for downstream consumers.
1020#[derive(Debug)]
1021#[non_exhaustive]
1022pub struct PreparedUpload {
1023    /// The data map for later retrieval.
1024    pub data_map: DataMap,
1025    /// Payment information for chunks that still need payment after the
1026    /// already-stored preflight. This may be wave-batch even when the original
1027    /// chunk count was merkle-eligible if the remaining count is below the
1028    /// merkle threshold.
1029    pub payment_info: ExternalPaymentInfo,
1030    /// Chunk address of the serialized `DataMap` when this upload was
1031    /// prepared with [`Visibility::Public`]. `Some` means the address is
1032    /// retrievable on the network after finalization — either because this
1033    /// upload paid to store the chunk in `payment_info`, or because the
1034    /// chunk was already on the network (deterministic self-encryption).
1035    /// Carried through to [`FileUploadResult::data_map_address`].
1036    pub data_map_address: Option<[u8; 32]>,
1037    /// Chunk addresses already present on the network when this upload was
1038    /// prepared. These do not require payment or PUT during finalization.
1039    pub already_stored_addresses: Vec<[u8; 32]>,
1040    /// Total chunk count for the upload, including already-stored chunks.
1041    pub total_chunks: usize,
1042}
1043
1044/// Return type for [`spawn_file_encryption`]: chunk receiver, `DataMap` oneshot, join handle.
1045type EncryptionChannels = (
1046    tokio::sync::mpsc::Receiver<Bytes>,
1047    tokio::sync::oneshot::Receiver<DataMap>,
1048    tokio::task::JoinHandle<Result<()>>,
1049);
1050
1051/// Spawn a blocking task that streams file encryption through a channel.
1052fn spawn_file_encryption(path: PathBuf) -> Result<EncryptionChannels> {
1053    let metadata = std::fs::metadata(&path)?;
1054    let data_size = usize::try_from(metadata.len())
1055        .map_err(|e| Error::Encryption(format!("file size exceeds platform usize: {e}")))?;
1056
1057    let (chunk_tx, chunk_rx) = tokio::sync::mpsc::channel(2);
1058    let (datamap_tx, datamap_rx) = tokio::sync::oneshot::channel();
1059
1060    let handle = tokio::task::spawn_blocking(move || {
1061        let file = std::fs::File::open(&path)?;
1062        let mut reader = std::io::BufReader::new(file);
1063
1064        let read_error: Arc<Mutex<Option<std::io::Error>>> = Arc::new(Mutex::new(None));
1065        let read_error_clone = Arc::clone(&read_error);
1066
1067        let data_iter = std::iter::from_fn(move || {
1068            let mut buffer = vec![0u8; 8192];
1069            match std::io::Read::read(&mut reader, &mut buffer) {
1070                Ok(0) => None,
1071                Ok(n) => {
1072                    buffer.truncate(n);
1073                    Some(Bytes::from(buffer))
1074                }
1075                Err(e) => {
1076                    let mut guard = read_error_clone
1077                        .lock()
1078                        .unwrap_or_else(|poisoned| poisoned.into_inner());
1079                    *guard = Some(e);
1080                    None
1081                }
1082            }
1083        });
1084
1085        let mut stream = stream_encrypt(data_size, data_iter)
1086            .map_err(|e| Error::Encryption(format!("stream_encrypt failed: {e}")))?;
1087
1088        for chunk_result in stream.chunks() {
1089            // Check for captured read errors immediately after each chunk.
1090            // stream_encrypt sees None (EOF) when a read fails, so it stops
1091            // producing chunks. We must detect this before sending the
1092            // partial results to avoid uploading a truncated DataMap.
1093            {
1094                let guard = read_error
1095                    .lock()
1096                    .unwrap_or_else(|poisoned| poisoned.into_inner());
1097                if let Some(ref e) = *guard {
1098                    return Err(Error::Io(std::io::Error::new(e.kind(), e.to_string())));
1099                }
1100            }
1101
1102            let (_hash, content) = chunk_result
1103                .map_err(|e| Error::Encryption(format!("chunk encryption failed: {e}")))?;
1104            if chunk_tx.blocking_send(content).is_err() {
1105                return Err(Error::Encryption("upload receiver dropped".to_string()));
1106            }
1107        }
1108
1109        // Final check: read error after last chunk (stream saw EOF).
1110        {
1111            let guard = read_error
1112                .lock()
1113                .unwrap_or_else(|poisoned| poisoned.into_inner());
1114            if let Some(ref e) = *guard {
1115                return Err(Error::Io(std::io::Error::new(e.kind(), e.to_string())));
1116            }
1117        }
1118
1119        let datamap = stream
1120            .into_datamap()
1121            .ok_or_else(|| Error::Encryption("no DataMap after encryption".to_string()))?;
1122        if datamap_tx.send(datamap).is_err() {
1123            warn!("DataMap receiver dropped — upload may have been cancelled");
1124        }
1125        Ok(())
1126    });
1127
1128    Ok((chunk_rx, datamap_rx, handle))
1129}
1130
1131/// RAII guard for the staging temp file used during a disk download.
1132///
1133/// Removes the file on drop — including a panic unwind out of the
1134/// `block_in_place` decrypt loop — unless [`commit`](Self::commit) has
1135/// promoted it to its final path. Centralizes the cleanup the explicit error
1136/// arms used to repeat.
1137struct TempDownload {
1138    /// `Some` while the staging file may need cleanup; `None` once committed.
1139    path: Option<PathBuf>,
1140}
1141
1142impl TempDownload {
1143    fn new(path: PathBuf) -> Self {
1144        Self { path: Some(path) }
1145    }
1146
1147    /// Path of the staging file (valid until `commit`).
1148    fn path(&self) -> &Path {
1149        self.path
1150            .as_deref()
1151            .expect("TempDownload::path called after commit")
1152    }
1153
1154    /// Rename the staged file to `dest`. On success the guard is defused so
1155    /// `Drop` is a no-op; on failure the guard stays armed and `Drop` removes
1156    /// the orphaned temp file.
1157    fn commit(mut self, dest: &Path) -> std::io::Result<()> {
1158        std::fs::rename(self.path(), dest)?; // err → guard armed → Drop cleans up
1159        self.path = None; // success → nothing left to clean
1160        Ok(())
1161    }
1162}
1163
1164impl Drop for TempDownload {
1165    fn drop(&mut self) {
1166        if let Some(path) = self.path.take() {
1167            if let Err(e) = std::fs::remove_file(&path) {
1168                // Absent file is fine (never created / already gone).
1169                if e.kind() != std::io::ErrorKind::NotFound {
1170                    warn!(
1171                        "Failed to remove temp download file {}: {e}",
1172                        path.display()
1173                    );
1174                }
1175            }
1176        }
1177    }
1178}
1179
1180impl Client {
1181    /// Upload a file to the network using streaming self-encryption.
1182    ///
1183    /// Automatically selects merkle batch payment for files that produce
1184    /// 64+ chunks (saves gas). Encrypted chunks are spilled to a temp
1185    /// directory so peak memory stays at ~256 MB regardless of file size.
1186    ///
1187    /// # Errors
1188    ///
1189    /// Returns an error if the file cannot be read, encryption fails,
1190    /// or any chunk cannot be stored.
1191    pub async fn file_upload(&self, path: &Path) -> Result<FileUploadResult> {
1192        self.file_upload_with_mode(path, PaymentMode::Auto).await
1193    }
1194
1195    /// Estimate the cost of uploading a file without actually uploading.
1196    ///
1197    /// Encrypts the file to determine chunk count and sizes, then requests
1198    /// a single quote from the network for a representative chunk. The
1199    /// per-chunk price is extrapolated to the total chunk count.
1200    ///
1201    /// The estimate is fast (~2-5s) and does not require a wallet. Spilled
1202    /// chunks are cleaned up automatically when the function returns.
1203    ///
1204    /// Gas cost is an advisory heuristic, not a live gas-oracle query. It is
1205    /// derived from realistic per-transaction budgets (`GAS_PER_WAVE_TX`,
1206    /// `GAS_PER_MERKLE_TX`) priced at `ARBITRUM_GAS_PRICE_WEI`. Real gas
1207    /// varies with network conditions.
1208    ///
1209    /// Sampled chunk addresses are spread across the whole file (not the first
1210    /// N) so a shared leading prefix doesn't bias the sample. When a sample
1211    /// returns a live quote the per-chunk price is extrapolated and the result
1212    /// is tagged [`CostEstimateConfidence::PricedSample`].
1213    ///
1214    /// When every sampled chunk is already stored the result is still `Ok`
1215    /// with `storage_cost_atto: "0"`, tagged either
1216    /// [`CostEstimateConfidence::VerifiedAllAlreadyStored`] when the whole file
1217    /// was sampled (exactly free) or
1218    /// [`CostEstimateConfidence::AllSamplesAlreadyStoredIncomplete`] when the
1219    /// tail was unsampled (a best-effort guess that payment reconciles).
1220    ///
1221    /// # Errors
1222    ///
1223    /// Returns an error if the file cannot be read, encryption fails, or the
1224    /// network cannot provide a quote.
1225    pub async fn estimate_upload_cost(
1226        &self,
1227        path: &Path,
1228        mode: PaymentMode,
1229        progress: Option<mpsc::Sender<UploadEvent>>,
1230    ) -> Result<UploadCostEstimate> {
1231        let file_size = std::fs::metadata(path).map_err(Error::Io)?.len();
1232
1233        if file_size < 3 {
1234            return Err(Error::InvalidData(
1235                "File too small: self-encryption requires at least 3 bytes".into(),
1236            ));
1237        }
1238
1239        check_disk_space_for_spill(file_size)?;
1240
1241        info!(
1242            "Estimating upload cost for {} ({file_size} bytes)",
1243            path.display()
1244        );
1245
1246        let (spill, _data_map) = self.encrypt_file_to_spill(path, progress.as_ref()).await?;
1247        let chunk_count = spill.len();
1248
1249        if let Some(ref tx) = progress {
1250            let _ = tx
1251                .send(UploadEvent::Encrypted {
1252                    total_chunks: chunk_count,
1253                })
1254                .await;
1255        }
1256
1257        info!("Encrypted into {chunk_count} chunks, requesting quote");
1258        let uses_merkle = should_use_merkle(chunk_count, mode);
1259
1260        // Sample chunk addresses spread evenly across the file (see
1261        // `distributed_sample_indices`) rather than the first N. A single
1262        // AlreadyStored result says nothing about the rest of the file, and a
1263        // positional sample lands on a shared leading prefix in the worst case,
1264        // so we spread the probe and only treat the whole file as "fully
1265        // stored" when every sample comes back stored.
1266        let sample_indices = distributed_sample_indices(spill.addresses.len(), ESTIMATE_SAMPLE_CAP);
1267        let mut sampled = 0usize;
1268        let mut all_already_stored = true;
1269        let mut quotes_opt: Option<Vec<QuoteEntry>> = None;
1270
1271        for &idx in &sample_indices {
1272            let addr = &spill.addresses[idx];
1273            sampled += 1;
1274            let chunk_bytes = spill.read_chunk(addr)?;
1275            let data_size = u64::try_from(chunk_bytes.len())
1276                .map_err(|e| Error::InvalidData(format!("chunk size too large: {e}")))?;
1277            let result = if uses_merkle {
1278                self.get_store_quotes_with_fault_tolerance(addr, data_size, DATA_TYPE_CHUNK)
1279                    .await
1280            } else {
1281                self.get_store_quotes(addr, data_size, DATA_TYPE_CHUNK)
1282                    .await
1283            };
1284            match result {
1285                Ok(q) => {
1286                    quotes_opt = Some(q);
1287                    all_already_stored = false;
1288                    break;
1289                }
1290                Err(Error::AlreadyStored) => {
1291                    debug!(
1292                        "Sample chunk {} already stored; trying next address ({sampled}/{})",
1293                        hex::encode(addr),
1294                        sample_indices.len()
1295                    );
1296                    continue;
1297                }
1298                Err(e) => return Err(e),
1299            }
1300        }
1301
1302        let quotes = match quotes_opt {
1303            Some(q) => q,
1304            None if all_already_stored && sampled == chunk_count => {
1305                // Every address in the file was sampled and every one is
1306                // already on the network — a zero-cost estimate is exact here.
1307                info!("All {chunk_count} chunks already stored; returning zero-cost estimate");
1308                return Ok(UploadCostEstimate {
1309                    file_size,
1310                    chunk_count,
1311                    storage_cost_atto: "0".into(),
1312                    estimated_gas_cost_wei: "0".into(),
1313                    payment_mode: if uses_merkle {
1314                        PaymentMode::Merkle
1315                    } else {
1316                        PaymentMode::Single
1317                    },
1318                    confidence: CostEstimateConfidence::VerifiedAllAlreadyStored,
1319                });
1320            }
1321            None => {
1322                // Every sampled chunk was already stored but the tail was not
1323                // sampled, so there is no live price to extrapolate. The
1324                // estimate is display-only and payment reconciles the true
1325                // cost, so return an optimistic zero flagged as incomplete
1326                // rather than erroring — callers still get a value to show.
1327                info!(
1328                    "All {sampled}/{chunk_count} sampled chunks already stored; \
1329                     returning incomplete zero-cost estimate"
1330                );
1331                return Ok(UploadCostEstimate {
1332                    file_size,
1333                    chunk_count,
1334                    storage_cost_atto: "0".into(),
1335                    estimated_gas_cost_wei: "0".into(),
1336                    payment_mode: if uses_merkle {
1337                        PaymentMode::Merkle
1338                    } else {
1339                        PaymentMode::Single
1340                    },
1341                    confidence: CostEstimateConfidence::AllSamplesAlreadyStoredIncomplete,
1342                });
1343            }
1344        };
1345
1346        // Use the median price × 3 (matches the single-node payment builder
1347        // which pays 3x the median to incentivize reliable storage).
1348        let mut prices: Vec<Amount> = quotes.iter().map(|(_, _, _, price, _)| *price).collect();
1349        prices.sort();
1350        let median_price = prices
1351            .get(prices.len() / 2)
1352            .copied()
1353            .unwrap_or(Amount::ZERO);
1354        let per_chunk_cost = median_price * Amount::from(SINGLE_NODE_PAYMENT_MULTIPLIER);
1355
1356        let chunk_count_u64 = u64::try_from(chunk_count).unwrap_or(u64::MAX);
1357        let total_storage = per_chunk_cost * Amount::from(chunk_count_u64);
1358
1359        // Estimate gas cost from realistic per-transaction budgets rather
1360        // than a flat per-chunk or per-wave number.
1361        //
1362        // - Single mode: `batch_pay` packs up to UPLOAD_WAVE_SIZE chunks'
1363        //   close-group quotes into one `pay_for_quotes` call on Arbitrum.
1364        //   The dominant cost is one SSTORE per entry plus base tx overhead,
1365        //   so we use GAS_PER_WAVE_TX (≈1.5M) as a conservative upper bound
1366        //   on a full wave and multiply by the number of waves. The previous
1367        //   per-wave figure of 150k was closer to a single-entry transfer
1368        //   and understated cost by 5–10x for full waves.
1369        // - Merkle mode: one tx per sub-batch that verifies a merkle tree
1370        //   and posts a pool commitment (GAS_PER_MERKLE_TX ≈ 500k each).
1371        //
1372        // Gas is priced at ARBITRUM_GAS_PRICE_WEI (~0.1 gwei, a typical
1373        // Arbitrum baseline). Treat the result as advisory, not a commitment.
1374        let waves = u128::try_from(chunk_count.div_ceil(UPLOAD_WAVE_SIZE)).unwrap_or(u128::MAX);
1375        let merkle_batches = u128::try_from(chunk_count.div_ceil(MAX_LEAVES)).unwrap_or(u128::MAX);
1376        let estimated_gas: u128 = if uses_merkle {
1377            merkle_batches
1378                .saturating_mul(GAS_PER_MERKLE_TX)
1379                .saturating_mul(ARBITRUM_GAS_PRICE_WEI)
1380        } else {
1381            waves
1382                .saturating_mul(GAS_PER_WAVE_TX)
1383                .saturating_mul(ARBITRUM_GAS_PRICE_WEI)
1384        };
1385
1386        info!(
1387            "Estimate: {chunk_count} chunks, storage={total_storage} atto, gas~={estimated_gas} wei"
1388        );
1389
1390        Ok(UploadCostEstimate {
1391            file_size,
1392            chunk_count,
1393            storage_cost_atto: total_storage.to_string(),
1394            estimated_gas_cost_wei: estimated_gas.to_string(),
1395            payment_mode: if uses_merkle {
1396                PaymentMode::Merkle
1397            } else {
1398                PaymentMode::Single
1399            },
1400            confidence: CostEstimateConfidence::PricedSample,
1401        })
1402    }
1403
1404    /// Phase 1 of external-signer upload: encrypt file and prepare chunks.
1405    ///
1406    /// Equivalent to [`Client::file_prepare_upload_with_visibility`] with
1407    /// [`Visibility::Private`] — see that method for details.
1408    pub async fn file_prepare_upload(&self, path: &Path) -> Result<PreparedUpload> {
1409        self.file_prepare_upload_with_progress(path, Visibility::Private, None)
1410            .await
1411    }
1412
1413    /// Phase 1 of external-signer upload with explicit [`Visibility`] control.
1414    ///
1415    /// Equivalent to [`Client::file_prepare_upload_with_progress`] with
1416    /// `progress: None` — see that method for details.
1417    pub async fn file_prepare_upload_with_visibility(
1418        &self,
1419        path: &Path,
1420        visibility: Visibility,
1421    ) -> Result<PreparedUpload> {
1422        self.file_prepare_upload_with_progress(path, visibility, None)
1423            .await
1424    }
1425
1426    /// Phase 1 of external-signer upload with progress events.
1427    ///
1428    /// Requires an EVM network (for contract price queries) but NOT a wallet.
1429    /// Returns a [`PreparedUpload`] containing the data map, prepared chunks,
1430    /// and a [`PaymentIntent`] that the external signer uses to construct
1431    /// and submit the on-chain payment transaction.
1432    ///
1433    /// When `visibility` is [`Visibility::Public`], the serialized `DataMap`
1434    /// is bundled into the payment batch as an additional chunk and its
1435    /// address is recorded on the returned [`PreparedUpload`]. After
1436    /// [`Client::finalize_upload`] (or `_merkle`) succeeds, that address is
1437    /// surfaced via [`FileUploadResult::data_map_address`] so the uploader
1438    /// can share a single address from which anyone can retrieve the file.
1439    ///
1440    /// When `progress` is `Some`, [`UploadEvent`]s are emitted on the channel
1441    /// during encryption ([`UploadEvent::Encrypting`] / [`UploadEvent::Encrypted`])
1442    /// and per-chunk quoting ([`UploadEvent::ChunkQuoted`]). Storage events are
1443    /// emitted later by [`Client::finalize_upload_with_progress`] /
1444    /// [`Client::finalize_upload_merkle_with_progress`].
1445    ///
1446    /// **Memory note:** Encryption uses disk spilling for bounded memory, but
1447    /// the returned [`PreparedUpload`] holds all chunk content in memory (each
1448    /// [`PreparedChunk`] contains a `Bytes` with the full chunk data). This is
1449    /// inherent to the two-phase external-signer protocol — the chunks must
1450    /// stay in memory until [`Client::finalize_upload`] stores them. For very
1451    /// large files, prefer [`Client::file_upload`] which streams directly.
1452    ///
1453    /// # Errors
1454    ///
1455    /// Returns an error if there is insufficient disk space, the file cannot
1456    /// be read, encryption fails, or quote collection fails.
1457    pub async fn file_prepare_upload_with_progress(
1458        &self,
1459        path: &Path,
1460        visibility: Visibility,
1461        progress: Option<mpsc::Sender<UploadEvent>>,
1462    ) -> Result<PreparedUpload> {
1463        debug!(
1464            "Preparing file upload for external signing (visibility={visibility:?}): {}",
1465            path.display()
1466        );
1467
1468        let file_size = std::fs::metadata(path)?.len();
1469        check_disk_space_for_spill(file_size)?;
1470
1471        let (spill, data_map) = self.encrypt_file_to_spill(path, progress.as_ref()).await?;
1472
1473        info!(
1474            "Encrypted {} into {} chunks for external signing (spilled to disk)",
1475            path.display(),
1476            spill.len()
1477        );
1478
1479        // Read each chunk from disk and collect quotes concurrently.
1480        // Note: all PreparedChunks accumulate in memory because the external-signer
1481        // protocol requires them for finalize_upload. NOT memory-bounded for large files.
1482        let mut chunk_data: Vec<Bytes> = spill
1483            .addresses
1484            .iter()
1485            .map(|addr| spill.read_chunk(addr))
1486            .collect::<std::result::Result<Vec<_>, _>>()?;
1487
1488        // For public uploads, bundle the serialized DataMap as an extra chunk
1489        // in the same payment batch. This lets the external signer pay for
1490        // the data chunks and the DataMap chunk in one flow, and lets the
1491        // finalize step return the DataMap's chunk address as the shareable
1492        // retrieval address.
1493        let data_map_address = match visibility {
1494            Visibility::Private => None,
1495            Visibility::Public => {
1496                let serialized = rmp_serde::to_vec(&data_map).map_err(|e| {
1497                    Error::Serialization(format!("Failed to serialize DataMap: {e}"))
1498                })?;
1499                let bytes = Bytes::from(serialized);
1500                let address = compute_address(&bytes);
1501                info!(
1502                    "Public upload: bundling DataMap chunk ({} bytes) at address {}",
1503                    bytes.len(),
1504                    hex::encode(address)
1505                );
1506                chunk_data.push(bytes);
1507                Some(address)
1508            }
1509        };
1510
1511        let chunk_count = chunk_data.len();
1512
1513        if let Some(ref tx) = progress {
1514            let _ = tx
1515                .send(UploadEvent::Encrypted {
1516                    total_chunks: chunk_count,
1517                })
1518                .await;
1519        }
1520
1521        let (payment_info, already_stored_addresses) = if should_use_merkle(
1522            chunk_count,
1523            PaymentMode::Auto,
1524        ) {
1525            // Merkle path: build tree, collect candidate pools, return for external payment.
1526            info!("Using merkle batch preparation for {chunk_count} file chunks");
1527
1528            let chunk_entries: Vec<([u8; 32], u64)> = chunk_data
1529                .iter()
1530                .map(|chunk| {
1531                    let size = u64::try_from(chunk.len())
1532                        .map_err(|e| Error::InvalidData(format!("chunk size too large: {e}")))?;
1533                    Ok((compute_address(chunk), size))
1534                })
1535                .collect::<Result<Vec<_>>>()?;
1536
1537            let merkle_plan = self
1538                .plan_merkle_upload(chunk_entries, DATA_TYPE_CHUNK, progress.as_ref())
1539                .await?;
1540
1541            if merkle_plan.to_upload.is_empty() {
1542                info!("All {chunk_count} file chunks already stored; no external payment needed");
1543                (
1544                    ExternalPaymentInfo::WaveBatch {
1545                        prepared_chunks: Vec::new(),
1546                        payment_intent: PaymentIntent::from_prepared_chunks(&[]),
1547                    },
1548                    merkle_plan.already_stored,
1549                )
1550            } else {
1551                let chunk_data =
1552                    chunk_contents_for_upload_addresses(chunk_data, &merkle_plan.to_upload)?;
1553
1554                if !should_use_merkle(merkle_plan.to_upload.len(), PaymentMode::Auto) {
1555                    info!(
1556                        "{} file chunks need upload after merkle preflight; preparing wave-batch payment",
1557                        merkle_plan.to_upload.len()
1558                    );
1559                    let (payment_info, mut wave_already_stored) = self
1560                        .prepare_wave_batch_external_chunks(
1561                            chunk_data,
1562                            progress.as_ref(),
1563                            chunk_count,
1564                        )
1565                        .await?;
1566                    let mut already_stored = merkle_plan.already_stored;
1567                    already_stored.append(&mut wave_already_stored);
1568                    (payment_info, already_stored)
1569                } else {
1570                    match self
1571                        .prepare_merkle_batch_external(
1572                            &merkle_plan.to_upload,
1573                            DATA_TYPE_CHUNK,
1574                            merkle_plan.to_upload_avg_size(),
1575                        )
1576                        .await
1577                    {
1578                        Ok(prepared_batch) => {
1579                            info!(
1580                                "File prepared for external merkle signing: {} chunks, depth={} ({})",
1581                                merkle_plan.to_upload.len(),
1582                                prepared_batch.depth,
1583                                path.display()
1584                            );
1585
1586                            (
1587                                ExternalPaymentInfo::Merkle {
1588                                    prepared_batch,
1589                                    chunk_contents: chunk_data,
1590                                    chunk_addresses: merkle_plan.to_upload,
1591                                },
1592                                merkle_plan.already_stored,
1593                            )
1594                        }
1595                        Err(Error::InsufficientPeers(ref msg)) => {
1596                            info!(
1597                                "External merkle preparation needs more peers ({msg}); preparing wave-batch payment"
1598                            );
1599                            let (payment_info, mut wave_already_stored) = self
1600                                .prepare_wave_batch_external_chunks(
1601                                    chunk_data,
1602                                    progress.as_ref(),
1603                                    chunk_count,
1604                                )
1605                                .await?;
1606                            let mut already_stored = merkle_plan.already_stored;
1607                            already_stored.append(&mut wave_already_stored);
1608                            (payment_info, already_stored)
1609                        }
1610                        Err(e) => return Err(e),
1611                    }
1612                }
1613            }
1614        } else {
1615            self.prepare_wave_batch_external_chunks(chunk_data, progress.as_ref(), chunk_count)
1616                .await?
1617        };
1618
1619        // Surface the "DataMap chunk was already on the network" case
1620        // so debugging "why is data_map_address set but no storage cost
1621        // appears for it?" doesn't require reading the source. See the
1622        // `data_map_address` doc comment for why this is still a valid
1623        // `Some(addr)` outcome.
1624        if let Some(addr) = data_map_address {
1625            let data_map_needs_payment = match &payment_info {
1626                ExternalPaymentInfo::WaveBatch {
1627                    prepared_chunks, ..
1628                } => prepared_chunks.iter().any(|c| c.address == addr),
1629                ExternalPaymentInfo::Merkle {
1630                    chunk_addresses, ..
1631                } => chunk_addresses.contains(&addr),
1632            };
1633            if !data_map_needs_payment {
1634                info!(
1635                    "Public upload: DataMap chunk {} was already stored \
1636                     on the network — address is retrievable without a \
1637                     new payment",
1638                    hex::encode(addr)
1639                );
1640            }
1641        }
1642
1643        Ok(PreparedUpload {
1644            data_map,
1645            payment_info,
1646            data_map_address,
1647            already_stored_addresses,
1648            total_chunks: chunk_count,
1649        })
1650    }
1651
1652    async fn prepare_wave_batch_external_chunks(
1653        &self,
1654        chunk_data: Vec<Bytes>,
1655        progress: Option<&mpsc::Sender<UploadEvent>>,
1656        progress_total: usize,
1657    ) -> Result<(ExternalPaymentInfo, Vec<[u8; 32]>)> {
1658        let chunk_count = chunk_data.len();
1659        let chunks_with_addr: Vec<(Bytes, [u8; 32])> = chunk_data
1660            .into_iter()
1661            .map(|content| {
1662                let address = compute_address(&content);
1663                (content, address)
1664            })
1665            .collect();
1666
1667        // Wave-batch path: collect quotes per chunk concurrently, emitting
1668        // a `ChunkQuoted` event after each completion so callers can drive
1669        // a progress bar through the slow quote phase.
1670        let quote_limiter = self.controller().quote.clone();
1671        let quote_concurrency = quote_limiter.current().min(chunk_count.max(1));
1672        let mut quote_stream = stream::iter(chunks_with_addr)
1673            .map(|(content, address)| {
1674                let limiter = quote_limiter.clone();
1675                async move {
1676                    let result = observe_op(
1677                        &limiter,
1678                        || async move { self.prepare_chunk_payment(content).await },
1679                        classify_error,
1680                    )
1681                    .await;
1682                    (address, result)
1683                }
1684            })
1685            .buffer_unordered(quote_concurrency);
1686
1687        let mut prepared_chunks = Vec::with_capacity(chunk_count);
1688        let mut already_stored = Vec::new();
1689        let mut quoted = 0usize;
1690        while let Some((address, result)) = quote_stream.next().await {
1691            match result? {
1692                Some(prepared) => prepared_chunks.push(prepared),
1693                None => already_stored.push(address),
1694            }
1695            quoted += 1;
1696            if let Some(tx) = progress {
1697                let _ = tx.try_send(UploadEvent::ChunkQuoted {
1698                    quoted,
1699                    total: progress_total,
1700                });
1701            }
1702        }
1703
1704        let payment_intent = PaymentIntent::from_prepared_chunks(&prepared_chunks);
1705        info!(
1706            "Prepared external wave-batch payment: {} chunks, {} already stored, total {} atto",
1707            prepared_chunks.len(),
1708            already_stored.len(),
1709            payment_intent.total_amount,
1710        );
1711
1712        Ok((
1713            ExternalPaymentInfo::WaveBatch {
1714                prepared_chunks,
1715                payment_intent,
1716            },
1717            already_stored,
1718        ))
1719    }
1720
1721    /// Phase 2 of external-signer upload (wave-batch): finalize with externally-signed tx hashes.
1722    ///
1723    /// Takes a [`PreparedUpload`] that used wave-batch payment and a map
1724    /// of `quote_hash -> tx_hash` provided by the external signer after on-chain
1725    /// payment. Builds payment proofs and stores chunks on the network.
1726    ///
1727    /// # Errors
1728    ///
1729    /// Returns an error if the prepared upload used merkle payment (use
1730    /// [`Client::finalize_upload_merkle`] instead), proof construction fails,
1731    /// or any chunk cannot be stored.
1732    pub async fn finalize_upload(
1733        &self,
1734        prepared: PreparedUpload,
1735        tx_hash_map: &HashMap<QuoteHash, TxHash>,
1736    ) -> Result<FileUploadResult> {
1737        self.finalize_upload_with_progress(prepared, tx_hash_map, None)
1738            .await
1739    }
1740
1741    /// Phase 2 of external-signer upload (wave-batch) with progress events.
1742    ///
1743    /// Same as [`Client::finalize_upload`] but emits [`UploadEvent::ChunkStored`]
1744    /// on the provided channel as each chunk is successfully stored.
1745    ///
1746    /// # Errors
1747    ///
1748    /// Same as [`Client::finalize_upload`].
1749    pub async fn finalize_upload_with_progress(
1750        &self,
1751        prepared: PreparedUpload,
1752        tx_hash_map: &HashMap<QuoteHash, TxHash>,
1753        progress: Option<mpsc::Sender<UploadEvent>>,
1754    ) -> Result<FileUploadResult> {
1755        let data_map_address = prepared.data_map_address;
1756        let already_stored_addresses = prepared.already_stored_addresses;
1757        let already_stored_count = already_stored_addresses.len();
1758        let total_chunks = prepared.total_chunks;
1759        match prepared.payment_info {
1760            ExternalPaymentInfo::WaveBatch {
1761                prepared_chunks,
1762                payment_intent,
1763            } => {
1764                let paid_chunks = finalize_batch_payment(prepared_chunks, tx_hash_map)?;
1765                let wave_result = self
1766                    .store_paid_chunks_with_events(
1767                        paid_chunks,
1768                        progress.as_ref(),
1769                        already_stored_count,
1770                        total_chunks,
1771                    )
1772                    .await;
1773                if !wave_result.failed.is_empty() {
1774                    let failed_count = wave_result.failed.len();
1775                    let stored_count = already_stored_count + wave_result.stored.len();
1776                    let mut stored = already_stored_addresses;
1777                    stored.extend(wave_result.stored);
1778                    return Err(Error::PartialUpload {
1779                        stored,
1780                        stored_count,
1781                        failed: wave_result.failed,
1782                        failed_count,
1783                        total_chunks,
1784                        // Report the storage spend known from the payment intent
1785                        // the external signer was handed. Gas is paid by the
1786                        // signer out-of-band, so it stays unknown (0).
1787                        spend: Box::new(PartialUploadSpend {
1788                            storage_cost_atto: payment_intent.total_amount.to_string(),
1789                            gas_cost_wei: 0,
1790                        }),
1791                        reason: "finalize_upload: chunk storage failed after retries".into(),
1792                    });
1793                }
1794                let chunks_stored = already_stored_count + wave_result.stored.len();
1795
1796                info!("External-signer upload finalized: {chunks_stored} chunks stored");
1797
1798                let mut stats = WaveAggregateStats::default();
1799                stats.absorb(&wave_result);
1800
1801                Ok(FileUploadResult {
1802                    data_map: prepared.data_map,
1803                    chunks_stored,
1804                    chunks_failed: 0,
1805                    total_chunks,
1806                    payment_mode_used: PaymentMode::Single,
1807                    // Storage spend is known from the payment intent; gas is
1808                    // paid by the external signer out-of-band (unknown here).
1809                    storage_cost_atto: payment_intent.total_amount.to_string(),
1810                    gas_cost_wei: 0,
1811                    data_map_address,
1812                    chunk_attempts_total: stats.chunk_attempts_total,
1813                    store_durations_ms: stats.store_durations_ms,
1814                    retries_histogram: stats.retries_histogram,
1815                })
1816            }
1817            ExternalPaymentInfo::Merkle { .. } => Err(Error::Payment(
1818                "Cannot finalize merkle upload with wave-batch tx hashes. \
1819                 Use finalize_upload_merkle() instead."
1820                    .to_string(),
1821            )),
1822        }
1823    }
1824
1825    /// Phase 2 of external-signer upload (merkle): finalize with winner pool hash.
1826    ///
1827    /// Takes a [`PreparedUpload`] that used merkle payment and the `winner_pool_hash`
1828    /// returned by the on-chain merkle payment transaction. Generates proofs and
1829    /// stores chunks on the network.
1830    ///
1831    /// # Errors
1832    ///
1833    /// Returns an error if the prepared upload used wave-batch payment (use
1834    /// [`Client::finalize_upload`] instead), proof generation fails,
1835    /// or any chunk cannot be stored.
1836    pub async fn finalize_upload_merkle(
1837        &self,
1838        prepared: PreparedUpload,
1839        winner_pool_hash: [u8; 32],
1840    ) -> Result<FileUploadResult> {
1841        self.finalize_upload_merkle_with_progress(prepared, winner_pool_hash, None)
1842            .await
1843    }
1844
1845    /// Phase 2 of external-signer upload (merkle) with progress events.
1846    ///
1847    /// Same as [`Client::finalize_upload_merkle`] but emits [`UploadEvent::ChunkStored`]
1848    /// on the provided channel as each chunk is successfully stored.
1849    ///
1850    /// # Errors
1851    ///
1852    /// Same as [`Client::finalize_upload_merkle`].
1853    pub async fn finalize_upload_merkle_with_progress(
1854        &self,
1855        prepared: PreparedUpload,
1856        winner_pool_hash: [u8; 32],
1857        progress: Option<mpsc::Sender<UploadEvent>>,
1858    ) -> Result<FileUploadResult> {
1859        let data_map_address = prepared.data_map_address;
1860        let already_stored_count = prepared.already_stored_addresses.len();
1861        let total_chunks = prepared.total_chunks;
1862        match prepared.payment_info {
1863            ExternalPaymentInfo::Merkle {
1864                prepared_batch,
1865                chunk_contents,
1866                chunk_addresses,
1867            } => {
1868                let batch_result = finalize_merkle_batch(prepared_batch, winner_pool_hash)?;
1869                let outcome = self
1870                    .merkle_upload_chunks(
1871                        chunk_contents,
1872                        chunk_addresses,
1873                        &batch_result,
1874                        progress.as_ref(),
1875                        already_stored_count,
1876                        total_chunks,
1877                    )
1878                    .await?;
1879
1880                info!(
1881                    "External-signer merkle upload finalized: {} chunks stored, {} failed",
1882                    outcome.stored, outcome.failed
1883                );
1884
1885                Ok(FileUploadResult {
1886                    data_map: prepared.data_map,
1887                    chunks_stored: outcome.stored,
1888                    chunks_failed: outcome.failed,
1889                    total_chunks,
1890                    payment_mode_used: PaymentMode::Merkle,
1891                    storage_cost_atto: "0".into(),
1892                    gas_cost_wei: 0,
1893                    data_map_address,
1894                    chunk_attempts_total: outcome.stats.chunk_attempts_total,
1895                    store_durations_ms: outcome.stats.store_durations_ms,
1896                    retries_histogram: outcome.stats.retries_histogram,
1897                })
1898            }
1899            ExternalPaymentInfo::WaveBatch { .. } => Err(Error::Payment(
1900                "Cannot finalize wave-batch upload with merkle winner hash. \
1901                 Use finalize_upload() instead."
1902                    .to_string(),
1903            )),
1904        }
1905    }
1906
1907    /// Upload a file with a specific payment mode.
1908    ///
1909    /// Before encryption, checks that the temp directory has enough free
1910    /// disk space for the spilled chunks (~1.1× source file size).
1911    ///
1912    /// Encrypted chunks are spilled to a temp directory during encryption
1913    /// so that only their 32-byte addresses stay in memory. At upload time,
1914    /// chunks are read back one wave at a time (~64 × 4 MB ≈ 256 MB peak).
1915    ///
1916    /// # Errors
1917    ///
1918    /// Returns an error if there is insufficient disk space, the file cannot
1919    /// be read, encryption fails, or any chunk cannot be stored.
1920    #[allow(clippy::too_many_lines)]
1921    pub async fn file_upload_with_mode(
1922        &self,
1923        path: &Path,
1924        mode: PaymentMode,
1925    ) -> Result<FileUploadResult> {
1926        self.file_upload_with_progress(path, mode, None).await
1927    }
1928
1929    /// Upload a file publicly, storing the serialized [`DataMap`] as part of
1930    /// the same upload payment batch.
1931    ///
1932    /// The returned [`FileUploadResult::data_map_address`] can be shared for
1933    /// public downloads via [`Client::data_map_fetch`].
1934    #[allow(clippy::too_many_lines)]
1935    pub async fn file_upload_public_with_mode(
1936        &self,
1937        path: &Path,
1938        mode: PaymentMode,
1939    ) -> Result<FileUploadResult> {
1940        self.file_upload_with_visibility_and_progress(path, mode, Visibility::Public, None)
1941            .await
1942    }
1943
1944    /// Upload a file with progress events sent to the given channel.
1945    ///
1946    /// Same as [`Client::file_upload_with_mode`] but sends [`UploadEvent`]s to the
1947    /// provided channel for UI progress feedback.
1948    #[allow(clippy::too_many_lines)]
1949    pub async fn file_upload_with_progress(
1950        &self,
1951        path: &Path,
1952        mode: PaymentMode,
1953        progress: Option<mpsc::Sender<UploadEvent>>,
1954    ) -> Result<FileUploadResult> {
1955        self.file_upload_with_visibility_and_progress(path, mode, Visibility::Private, progress)
1956            .await
1957    }
1958
1959    /// Public file upload with progress events.
1960    ///
1961    /// Same as [`Client::file_upload_public_with_mode`] but sends
1962    /// [`UploadEvent`]s to the provided channel for UI progress feedback.
1963    #[allow(clippy::too_many_lines)]
1964    pub async fn file_upload_public_with_progress(
1965        &self,
1966        path: &Path,
1967        mode: PaymentMode,
1968        progress: Option<mpsc::Sender<UploadEvent>>,
1969    ) -> Result<FileUploadResult> {
1970        self.file_upload_with_visibility_and_progress(path, mode, Visibility::Public, progress)
1971            .await
1972    }
1973
1974    #[allow(clippy::too_many_lines)]
1975    async fn file_upload_with_visibility_and_progress(
1976        &self,
1977        path: &Path,
1978        mode: PaymentMode,
1979        visibility: Visibility,
1980        progress: Option<mpsc::Sender<UploadEvent>>,
1981    ) -> Result<FileUploadResult> {
1982        debug!(
1983            "Streaming file upload with mode {mode:?}, visibility {visibility:?}: {}",
1984            path.display()
1985        );
1986
1987        // Pre-flight: verify enough temp disk space for the chunk spill.
1988        let file_size = std::fs::metadata(path)?.len();
1989        check_disk_space_for_spill(file_size)?;
1990
1991        // Phase 1: Encrypt file and spill chunks to temp directory.
1992        // Only 32-byte addresses stay in memory — chunk data lives on disk.
1993        let (mut spill, data_map) = self.encrypt_file_to_spill(path, progress.as_ref()).await?;
1994
1995        let data_map_address = match visibility {
1996            Visibility::Private => None,
1997            Visibility::Public => {
1998                let serialized = rmp_serde::to_vec(&data_map).map_err(|e| {
1999                    Error::Serialization(format!("Failed to serialize DataMap: {e}"))
2000                })?;
2001                let address = compute_address(&serialized);
2002                info!(
2003                    "Public upload: adding DataMap chunk ({} bytes) at address {} to payment batch",
2004                    serialized.len(),
2005                    hex::encode(address)
2006                );
2007                spill.push(&serialized)?;
2008                Some(address)
2009            }
2010        };
2011
2012        let chunk_count = spill.len();
2013        info!(
2014            "Encrypted {} into {chunk_count} chunks (spilled to disk)",
2015            path.display()
2016        );
2017        if let Some(ref tx) = progress {
2018            let _ = tx
2019                .send(UploadEvent::Encrypted {
2020                    total_chunks: chunk_count,
2021                })
2022                .await;
2023        }
2024
2025        // Phase 2: Decide payment mode and upload in waves from disk.
2026        //
2027        // For the merkle path, attempt to resume from a cached
2028        // receipt before paying again. The cache is keyed by the
2029        // CANONICAL source path so `./foo`, `/abs/foo`, and any
2030        // symlink alias all resolve to the same cache entry — a
2031        // crash-and-retry from a different cwd or via a different
2032        // alias still hits the receipt. Canonicalize may fail (the
2033        // file could have been moved between phase 1 and here); we
2034        // fall back to the display string in that case, which
2035        // preserves pre-fix behaviour rather than dropping cache
2036        // resume entirely.
2037        let file_path_key = std::fs::canonicalize(path)
2038            .map(|p| p.display().to_string())
2039            .unwrap_or_else(|_| path.display().to_string());
2040        let (chunks_stored, actual_mode, storage_cost_atto, gas_cost_wei, stats) = if self
2041            .should_use_merkle(chunk_count, mode)
2042        {
2043            info!("Using merkle batch payment for {chunk_count} file chunks");
2044
2045            let cached_merkle =
2046                crate::data::client::cached_merkle::try_load_for_file(&file_path_key)
2047                    .map(|(_cache_path, cached)| cached);
2048
2049            let merkle_plan = match self
2050                .plan_merkle_upload(spill.chunk_entries()?, DATA_TYPE_CHUNK, progress.as_ref())
2051                .await
2052            {
2053                Ok(plan) => plan,
2054                Err(e) => {
2055                    if let Some(cached) = cached_merkle
2056                        .as_ref()
2057                        .filter(|cached| cached_merkle_covers_addresses(cached, &spill.addresses))
2058                    {
2059                        info!(
2060                            "Merkle preflight failed ({e}); \
2061                             resuming with cached merkle proofs"
2062                        );
2063                        let (stored, sc, gc, stats) = self
2064                            .upload_merkle_from_spill(
2065                                &spill,
2066                                &spill.addresses,
2067                                cached,
2068                                &[],
2069                                progress.as_ref(),
2070                            )
2071                            .await?;
2072                        crate::data::client::cached_merkle::try_delete_for_file(&file_path_key);
2073                        return Ok(FileUploadResult {
2074                            data_map,
2075                            chunks_stored: stored,
2076                            chunks_failed: 0,
2077                            total_chunks: chunk_count,
2078                            payment_mode_used: PaymentMode::Merkle,
2079                            storage_cost_atto: sc,
2080                            gas_cost_wei: gc,
2081                            data_map_address,
2082                            chunk_attempts_total: stats.chunk_attempts_total,
2083                            store_durations_ms: stats.store_durations_ms,
2084                            retries_histogram: stats.retries_histogram,
2085                        });
2086                    }
2087                    match &e {
2088                        Error::InsufficientPeers(msg) if mode == PaymentMode::Auto => {
2089                            info!(
2090                                "Merkle preflight needs more peers ({msg}), \
2091                                 falling back to wave-batch"
2092                            );
2093                            let (stored, sc, gc, fb_stats) = self
2094                                .upload_waves_single(
2095                                    &spill,
2096                                    progress.as_ref(),
2097                                    Some(&file_path_key),
2098                                )
2099                                .await?;
2100                            crate::data::client::cached_single::try_delete_for_file(&file_path_key);
2101                            return Ok(FileUploadResult {
2102                                data_map,
2103                                chunks_stored: stored,
2104                                chunks_failed: 0,
2105                                total_chunks: chunk_count,
2106                                payment_mode_used: PaymentMode::Single,
2107                                storage_cost_atto: sc,
2108                                gas_cost_wei: gc,
2109                                data_map_address,
2110                                chunk_attempts_total: fb_stats.chunk_attempts_total,
2111                                store_durations_ms: fb_stats.store_durations_ms,
2112                                retries_histogram: fb_stats.retries_histogram,
2113                            });
2114                        }
2115                        _ => return Err(e),
2116                    }
2117                }
2118            };
2119
2120            if merkle_plan.to_upload.is_empty() {
2121                info!("All {chunk_count} merkle chunks already stored; skipping payment");
2122                crate::data::client::cached_merkle::try_delete_for_file(&file_path_key);
2123                crate::data::client::cached_single::try_delete_for_file(&file_path_key);
2124                (
2125                    chunk_count,
2126                    PaymentMode::Merkle,
2127                    "0".to_string(),
2128                    0,
2129                    WaveAggregateStats::default(),
2130                )
2131            } else if !self.should_use_merkle(merkle_plan.to_upload.len(), mode) {
2132                let remaining_chunks = merkle_plan.to_upload.len();
2133                if let Some(cached) = cached_merkle
2134                    .as_ref()
2135                    .filter(|cached| cached_merkle_covers_addresses(cached, &merkle_plan.to_upload))
2136                {
2137                    info!(
2138                        "{remaining_chunks} chunks remain below merkle threshold; \
2139                         reusing cached merkle proofs"
2140                    );
2141                    let (stored, sc, gc, stats) = self
2142                        .upload_merkle_from_spill(
2143                            &spill,
2144                            &merkle_plan.to_upload,
2145                            cached,
2146                            &merkle_plan.already_stored,
2147                            progress.as_ref(),
2148                        )
2149                        .await?;
2150                    crate::data::client::cached_merkle::try_delete_for_file(&file_path_key);
2151                    (stored, PaymentMode::Merkle, sc, gc, stats)
2152                } else {
2153                    if cached_merkle.is_some() {
2154                        info!(
2155                            "{remaining_chunks} chunks remain below merkle threshold, \
2156                             and the cached merkle receipt does not cover them. \
2157                             Discarding cache and using single-node payment."
2158                        );
2159                        crate::data::client::cached_merkle::try_delete_for_file(&file_path_key);
2160                    } else {
2161                        info!(
2162                            "{remaining_chunks} chunks need upload after merkle preflight; \
2163                             using single-node payment"
2164                        );
2165                    }
2166                    let (stored, sc, gc, stats) = self
2167                        .upload_spill_addresses_single(
2168                            &spill,
2169                            &merkle_plan.to_upload,
2170                            progress.as_ref(),
2171                            &merkle_plan.already_stored,
2172                            chunk_count,
2173                            Some(&file_path_key),
2174                        )
2175                        .await?;
2176                    crate::data::client::cached_single::try_delete_for_file(&file_path_key);
2177                    (stored, PaymentMode::Single, sc, gc, stats)
2178                }
2179            } else {
2180                let batch_result = if let Some(cached) = cached_merkle.as_ref() {
2181                    // Validate the cache against the chunks that still need
2182                    // storage. Extra proofs are harmless: a previous attempt
2183                    // may have paid for chunks that are now already stored.
2184                    if cached_merkle_covers_addresses(cached, &merkle_plan.to_upload) {
2185                        info!(
2186                            "Skipping merkle payment phase; resuming with \
2187                             cached proofs for {} remaining chunks",
2188                            merkle_plan.to_upload.len()
2189                        );
2190                        Ok(cached.clone())
2191                    } else {
2192                        info!(
2193                            "Cached merkle receipt does not cover the current \
2194                             remaining chunks (cached={}, remaining={}). \
2195                             Discarding cache and paying fresh.",
2196                            cached.proofs.len(),
2197                            merkle_plan.to_upload.len()
2198                        );
2199                        crate::data::client::cached_merkle::try_delete_for_file(&file_path_key);
2200                        self.pay_for_merkle_batch(
2201                            &merkle_plan.to_upload,
2202                            DATA_TYPE_CHUNK,
2203                            merkle_plan.to_upload_avg_size(),
2204                        )
2205                        .await
2206                        .inspect(|result| {
2207                            crate::data::client::cached_merkle::try_save(&file_path_key, result);
2208                        })
2209                    }
2210                } else {
2211                    self.pay_for_merkle_batch(
2212                        &merkle_plan.to_upload,
2213                        DATA_TYPE_CHUNK,
2214                        merkle_plan.to_upload_avg_size(),
2215                    )
2216                    .await
2217                    .inspect(|result| {
2218                        // Save BEFORE the store phase so a crash
2219                        // mid-upload leaves a resumable receipt.
2220                        crate::data::client::cached_merkle::try_save(&file_path_key, result);
2221                    })
2222                };
2223
2224                let batch_result = match batch_result {
2225                    Ok(result) => result,
2226                    Err(Error::InsufficientPeers(ref msg)) if mode == PaymentMode::Auto => {
2227                        info!("Merkle needs more peers ({msg}), falling back to wave-batch");
2228                        let (stored, sc, gc, fb_stats) = self
2229                            .upload_spill_addresses_single(
2230                                &spill,
2231                                &merkle_plan.to_upload,
2232                                progress.as_ref(),
2233                                &merkle_plan.already_stored,
2234                                chunk_count,
2235                                Some(&file_path_key),
2236                            )
2237                            .await?;
2238                        crate::data::client::cached_single::try_delete_for_file(&file_path_key);
2239                        return Ok(FileUploadResult {
2240                            data_map,
2241                            chunks_stored: stored,
2242                            chunks_failed: 0,
2243                            total_chunks: chunk_count,
2244                            payment_mode_used: PaymentMode::Single,
2245                            storage_cost_atto: sc,
2246                            gas_cost_wei: gc,
2247                            data_map_address,
2248                            chunk_attempts_total: fb_stats.chunk_attempts_total,
2249                            store_durations_ms: fb_stats.store_durations_ms,
2250                            retries_histogram: fb_stats.retries_histogram,
2251                        });
2252                    }
2253                    Err(e) => return Err(e),
2254                };
2255
2256                let (stored, sc, gc, stats) = self
2257                    .upload_merkle_from_spill(
2258                        &spill,
2259                        &merkle_plan.to_upload,
2260                        &batch_result,
2261                        &merkle_plan.already_stored,
2262                        progress.as_ref(),
2263                    )
2264                    .await?;
2265                // Upload succeeded end-to-end; the cached receipt is
2266                // no longer needed.
2267                crate::data::client::cached_merkle::try_delete_for_file(&file_path_key);
2268                (stored, PaymentMode::Merkle, sc, gc, stats)
2269            }
2270        } else {
2271            let (stored, sc, gc, stats) = self
2272                .upload_waves_single(&spill, progress.as_ref(), Some(&file_path_key))
2273                .await?;
2274            // Full file success: drop any cached single-node receipt.
2275            crate::data::client::cached_single::try_delete_for_file(&file_path_key);
2276            (stored, PaymentMode::Single, sc, gc, stats)
2277        };
2278
2279        info!(
2280            "File uploaded with {actual_mode:?}: {chunks_stored} chunks stored ({})",
2281            path.display()
2282        );
2283
2284        Ok(FileUploadResult {
2285            data_map,
2286            chunks_stored,
2287            chunks_failed: 0,
2288            total_chunks: chunk_count,
2289            payment_mode_used: actual_mode,
2290            storage_cost_atto,
2291            gas_cost_wei,
2292            data_map_address,
2293            chunk_attempts_total: stats.chunk_attempts_total,
2294            store_durations_ms: stats.store_durations_ms,
2295            retries_histogram: stats.retries_histogram,
2296        })
2297    }
2298
2299    /// Encrypt a file and spill chunks to a temp directory.
2300    ///
2301    /// Logs progress every 100 chunks so users get feedback during
2302    /// multi-GB encryptions.
2303    ///
2304    /// Returns the spill buffer (addresses on disk) and the `DataMap`.
2305    async fn encrypt_file_to_spill(
2306        &self,
2307        path: &Path,
2308        progress: Option<&mpsc::Sender<UploadEvent>>,
2309    ) -> Result<(ChunkSpill, DataMap)> {
2310        let (mut chunk_rx, datamap_rx, handle) = spawn_file_encryption(path.to_path_buf())?;
2311
2312        let mut spill = ChunkSpill::new()?;
2313        while let Some(content) = chunk_rx.recv().await {
2314            spill.push(&content)?;
2315            let chunks_done = spill.len();
2316            if let Some(tx) = progress {
2317                if chunks_done.is_multiple_of(10) {
2318                    let _ = tx.send(UploadEvent::Encrypting { chunks_done }).await;
2319                }
2320            }
2321            if chunks_done % 100 == 0 {
2322                let mb = spill.total_bytes() / (1024 * 1024);
2323                info!(
2324                    "Encryption progress: {chunks_done} chunks spilled ({mb} MB) — {}",
2325                    path.display()
2326                );
2327            }
2328        }
2329
2330        // Await encryption completion to catch errors before paying.
2331        handle
2332            .await
2333            .map_err(|e| Error::Encryption(format!("encryption task panicked: {e}")))?
2334            .map_err(|e| Error::Encryption(format!("encryption failed: {e}")))?;
2335
2336        let data_map = datamap_rx
2337            .await
2338            .map_err(|_| Error::Encryption("no DataMap from encryption thread".to_string()))?;
2339
2340        Ok((spill, data_map))
2341    }
2342
2343    /// Upload chunks from a spill using wave-based per-chunk (single) payments.
2344    ///
2345    /// Reads one wave at a time from disk, prepares quotes, pays, and stores.
2346    /// Peak memory: ~`UPLOAD_WAVE_SIZE × MAX_CHUNK_SIZE` (~256 MB).
2347    ///
2348    /// Returns `(chunks_stored, storage_cost_atto, gas_cost_wei)`.
2349    async fn upload_waves_single(
2350        &self,
2351        spill: &ChunkSpill,
2352        progress: Option<&mpsc::Sender<UploadEvent>>,
2353        resume_key: Option<&str>,
2354    ) -> Result<(usize, String, u128, WaveAggregateStats)> {
2355        self.upload_spill_addresses_single(
2356            spill,
2357            &spill.addresses,
2358            progress,
2359            &[],
2360            spill.len(),
2361            resume_key,
2362        )
2363        .await
2364    }
2365
2366    async fn upload_spill_addresses_single(
2367        &self,
2368        spill: &ChunkSpill,
2369        addresses: &[[u8; 32]],
2370        progress: Option<&mpsc::Sender<UploadEvent>>,
2371        already_stored_addresses: &[[u8; 32]],
2372        total_chunks: usize,
2373        resume_key: Option<&str>,
2374    ) -> Result<(usize, String, u128, WaveAggregateStats)> {
2375        let mut total_stored = already_stored_addresses.len();
2376        let mut total_storage = Amount::ZERO;
2377        let mut total_gas: u128 = 0;
2378        let mut agg_stats = WaveAggregateStats::default();
2379        // A wave whose chunks fall short of quorum after retries must not abort
2380        // the file: its failures are accumulated here and surfaced as a single
2381        // `PartialUpload` only after every wave has been attempted, mirroring
2382        // `upload_merkle_from_spill`. Aborting on the first failed wave (the old `?`)
2383        // discarded all later waves' progress — already self-encrypted, spilled,
2384        // and in some cases already paid for — converting high per-chunk success
2385        // into 0% per-file success.
2386        // Seed with the addresses a preflight already confirmed stored (e.g.
2387        // the merkle-fallback path passes `merkle_plan.already_stored`), so a
2388        // returned `PartialUpload.stored` lists every stored chunk and
2389        // `stored_count == stored.len()` holds for programmatic callers.
2390        let mut stored_addresses: Vec<[u8; 32]> = already_stored_addresses.to_vec();
2391        let mut failed: Vec<([u8; 32], String)> = Vec::new();
2392        let waves: Vec<&[[u8; 32]]> = addresses.chunks(UPLOAD_WAVE_SIZE).collect();
2393        let wave_count = waves.len();
2394
2395        // Unconditional breadcrumb: lets a clean run confirm the continue-on-
2396        // partial single-node path is in effect (the old path aborted the file
2397        // on the first failed wave instead of continuing across all waves).
2398        info!(
2399            "single-node upload: {} chunk(s) in {wave_count} wave(s) (continue-on-partial)",
2400            addresses.len()
2401        );
2402
2403        for (wave_idx, wave_addrs) in waves.into_iter().enumerate() {
2404            let wave_num = wave_idx + 1;
2405            let wave_data: Vec<Bytes> = wave_addrs
2406                .iter()
2407                .map(|addr| spill.read_chunk(addr))
2408                .collect::<Result<Vec<_>>>()?;
2409
2410            info!(
2411                "Wave {wave_num}/{wave_count}: quoting {} chunks — {total_stored}/{total_chunks} stored so far",
2412                wave_data.len()
2413            );
2414            if let Some(tx) = progress {
2415                let _ = tx
2416                    .send(UploadEvent::QuotingChunks {
2417                        wave: wave_num,
2418                        total_waves: wave_count,
2419                        chunks_in_wave: wave_data.len(),
2420                    })
2421                    .await;
2422            }
2423            // Fold this wave's result. A quorum shortfall (`PartialUpload`) is
2424            // recoverable and its parts are returned to be recorded here;
2425            // genuinely fatal errors propagate via `?` and abort the file, as in
2426            // `upload_merkle_from_spill`.
2427            let outcome = fold_single_wave(
2428                self.batch_upload_chunks_with_events(
2429                    wave_data,
2430                    progress,
2431                    total_stored,
2432                    total_chunks,
2433                    resume_key,
2434                )
2435                .await,
2436            )?;
2437
2438            if !outcome.failed.is_empty() {
2439                warn!(
2440                    "Wave {wave_num}/{wave_count}: {} chunk(s) failed to store after retries; \
2441                     continuing with remaining waves",
2442                    outcome.failed.len()
2443                );
2444            }
2445
2446            total_stored += outcome.stored.len();
2447            stored_addresses.extend(outcome.stored);
2448            failed.extend(outcome.failed);
2449            total_storage += outcome.storage_atto;
2450            total_gas = total_gas.saturating_add(outcome.gas_wei);
2451            // Merge per-wave stats (a quorum-short wave contributes none, since
2452            // `PartialUpload` carries no stats).
2453            agg_stats.chunk_attempts_total = agg_stats
2454                .chunk_attempts_total
2455                .saturating_add(outcome.stats.chunk_attempts_total);
2456            agg_stats
2457                .store_durations_ms
2458                .extend(outcome.stats.store_durations_ms);
2459            for (slot, count) in agg_stats
2460                .retries_histogram
2461                .iter_mut()
2462                .zip(outcome.stats.retries_histogram.iter())
2463            {
2464                *slot = slot.saturating_add(*count);
2465            }
2466        }
2467
2468        // Any chunk still failed after every wave was attempted means the file
2469        // is not fully stored — surface it as `PartialUpload` (never silently
2470        // succeed with missing chunks), carrying the real on-chain spend.
2471        if !failed.is_empty() {
2472            let failed_count = failed.len();
2473            warn!(
2474                "single-node upload incomplete: {failed_count}/{total_chunks} chunks failed after retries"
2475            );
2476            return Err(Error::PartialUpload {
2477                stored: stored_addresses,
2478                stored_count: total_stored,
2479                failed,
2480                failed_count,
2481                total_chunks,
2482                spend: Box::new(PartialUploadSpend {
2483                    storage_cost_atto: total_storage.to_string(),
2484                    gas_cost_wei: total_gas,
2485                }),
2486                reason: format!("{failed_count} chunk(s) failed to store after retries"),
2487            });
2488        }
2489
2490        Ok((
2491            total_stored,
2492            total_storage.to_string(),
2493            total_gas,
2494            agg_stats,
2495        ))
2496    }
2497
2498    /// Upload chunks from a spill using pre-computed merkle proofs.
2499    ///
2500    /// Stores the whole file as a **single cap-bounded fan-out** — not in fixed
2501    /// waves. The store concurrency limiter is the only throttle: `store_one`
2502    /// reads each chunk's body from the on-disk spill on demand, so at most
2503    /// `store_cap` (≤ 64) bodies are ever resident, giving the same
2504    /// `~store_cap × MAX_CHUNK_SIZE` peak-memory bound the old 64-chunk waves
2505    /// gave — but with **no wave barrier**, so a slow straggler (e.g. a chunk
2506    /// whose close-group peers are stale relayed addresses that take minutes to
2507    /// revalidate) no longer stalls the rest of the file behind it.
2508    ///
2509    /// A chunk that is transiently short of quorum (`InsufficientPeers` /
2510    /// `CloseGroupShortfall` / `RemotePut`) does **not** abort the file, nor
2511    /// block the pass: the store pass is a **single attempt** (no in-pass
2512    /// backoff), and quorum-short chunks are collected into a deferred set. After
2513    /// the pass, [`merkle_deferred_retry`] retries that set in concurrent rounds
2514    /// ([`DEFERRED_ROUND_DELAYS_SECS`] delays), re-reading each body from the
2515    /// spill and reusing its proof. Non-quorum errors (e.g. a missing proof)
2516    /// stay fatal and abort immediately.
2517    ///
2518    /// Returns `(chunks_stored, storage_cost_atto, gas_cost_wei)` on success.
2519    /// Costs come from the `batch_result` which was populated during payment.
2520    ///
2521    /// # Errors
2522    ///
2523    /// Returns [`Error::PartialUpload`] if any chunk is still short of quorum
2524    /// after the store pass and every deferred round (other chunks remain
2525    /// stored), or the underlying error for a non-quorum failure.
2526    async fn upload_merkle_from_spill(
2527        &self,
2528        spill: &ChunkSpill,
2529        addresses: &[[u8; 32]],
2530        batch_result: &MerkleBatchPaymentResult,
2531        already_stored_addresses: &[[u8; 32]],
2532        progress: Option<&mpsc::Sender<UploadEvent>>,
2533    ) -> Result<(usize, String, u128, WaveAggregateStats)> {
2534        let mut total_stored = already_stored_addresses.len();
2535        let total_chunks = total_stored + addresses.len();
2536        let mut stored_addresses: Vec<[u8; 32]> = already_stored_addresses.to_vec();
2537        let mut failed: Vec<([u8; 32], String)> = Vec::new();
2538        let mut agg_stats = WaveAggregateStats::default();
2539
2540        // Chunks without a merkle proof were never paid for: a partial
2541        // `pay_for_merkle_multi_batch` result carries proofs only for the
2542        // sub-batches whose on-chain payment succeeded. Such a chunk cannot be
2543        // stored, so record it as failed (surfaced via `PartialUpload` once the
2544        // storable chunks have been attempted) rather than letting its
2545        // "missing proof" error abort the whole file and discard every other
2546        // chunk's progress.
2547        let (to_store, missing_proof) =
2548            partition_addresses_by_proof(addresses, &batch_result.proofs);
2549        if !missing_proof.is_empty() {
2550            warn!(
2551                "{} chunk(s) lack a merkle proof (partial payment); reporting them as failed",
2552                missing_proof.len()
2553            );
2554            for addr in &missing_proof {
2555                failed.push((
2556                    *addr,
2557                    format!("Missing merkle proof for chunk {}", hex::encode(addr)),
2558                ));
2559            }
2560        }
2561
2562        let store_limiter = self.controller().store.clone();
2563
2564        // Store one chunk to its (freshly re-collected) close group, reusing the
2565        // chunk's merkle proof. Reads the body from the on-disk spill on demand,
2566        // so the whole-file store runs as ONE cap-bounded fan-out with no per-wave
2567        // barrier: a slow straggler (e.g. a chunk whose close-group peers are
2568        // stale relayed addresses that take minutes to revalidate) no longer
2569        // holds back the rest of the file. Only the ≤cap in-flight stores hold a
2570        // body, so peak resident memory is `cap × MAX_CHUNK_SIZE`; the cap is
2571        // clamped to `MERKLE_STORE_MAX_IN_FLIGHT` (below) so it stays within the
2572        // ~256 MiB bound the fixed 64-chunk waves gave even if `adaptive.max.store`
2573        // is configured above 64.
2574        // Shared across every deferred round so a converged routing table yields
2575        // a fresh group. Only a quorum shortfall is recoverable; a missing proof
2576        // or a failed spill read stays fatal. Mirrors `merkle_upload_chunks`.
2577        let store_one = |addr: [u8; 32]| {
2578            let limiter = store_limiter.clone();
2579            let proof_bytes = batch_result.proofs.get(&addr).cloned();
2580            async move {
2581                let started = std::time::Instant::now();
2582                let proof = proof_bytes.ok_or_else(|| {
2583                    Error::Payment(format!(
2584                        "Missing merkle proof for chunk {}",
2585                        hex::encode(addr)
2586                    ))
2587                })?;
2588                let content = spill.read_chunk(&addr)?;
2589                let peers = self.put_target_peers(&addr).await?;
2590                observe_op(
2591                    &limiter,
2592                    || async move { self.chunk_put_to_close_group(content, proof, &peers).await },
2593                    classify_error,
2594                )
2595                .await
2596                .map(|_| started)
2597            }
2598        };
2599
2600        info!(
2601            "Storing {} chunks (merkle) as a single cap-bounded pass — {total_stored}/{total_chunks} stored so far",
2602            to_store.len()
2603        );
2604
2605        // Store the WHOLE file in one cap-bounded fan-out (`max_attempts = 1`, no
2606        // backoff): no wave barrier, so a slow straggler (dead-relay peers) can't
2607        // hold back the rest of the file. The store cap re-reads the limiter per
2608        // slot, so it maxes at 64 → ≤64 bodies resident (bodies read from spill on
2609        // demand by `store_one`), the same peak-memory bound the fixed 64-chunk
2610        // waves gave. Quorum-short chunks are collected and deferred to the
2611        // post-pass concurrent retry rather than parking slots behind a backoff.
2612        // `merkle_store_cap` clamps to `MERKLE_STORE_MAX_IN_FLIGHT` so a high
2613        // configured `adaptive.max.store` can't hold more than the wave-era
2614        // ~256 MB of spilled bodies resident (PR #137 review).
2615        let cap = || merkle_store_cap(store_limiter.current());
2616        let outcome = merkle_store_with_retry(
2617            to_store.clone(),
2618            cap,
2619            1,
2620            std::time::Duration::ZERO,
2621            progress,
2622            total_stored,
2623            total_chunks,
2624            &store_one,
2625        )
2626        .await?;
2627
2628        // Record confirmed stores from the explicit set the store helper reports.
2629        // Using that set (rather than inferring "chunks minus failed") keeps
2630        // `stored_addresses` correct even when a fatal abort leaves some chunks
2631        // neither stored nor reported short of quorum.
2632        stored_addresses.extend(&outcome.stored_addresses);
2633        total_stored = outcome.stored;
2634
2635        // Merge store stats (durations, attempts, per-round histogram).
2636        agg_stats.chunk_attempts_total = agg_stats
2637            .chunk_attempts_total
2638            .saturating_add(outcome.stats.chunk_attempts_total);
2639        agg_stats
2640            .store_durations_ms
2641            .extend(outcome.stats.store_durations_ms);
2642        for (slot, count) in agg_stats
2643            .retries_histogram
2644            .iter_mut()
2645            .zip(outcome.stats.retries_histogram.iter())
2646        {
2647            *slot = slot.saturating_add(*count);
2648        }
2649
2650        if let Some(e) = outcome.fatal {
2651            // A non-quorum store error is fatal (missing proofs were filtered out
2652            // above, so this is a genuine network/store failure). Preserve every
2653            // chunk stored so far and report every not-stored chunk as failed, so
2654            // the `PartialUpload` counts are accurate.
2655            warn!("merkle store aborted: {e}");
2656            let mut known_failed = failed;
2657            known_failed.extend(outcome.failed_addresses);
2658            return Err(partial_upload_after_fatal(
2659                addresses,
2660                stored_addresses,
2661                total_stored,
2662                total_chunks,
2663                known_failed,
2664                PartialUploadSpend {
2665                    storage_cost_atto: batch_result.storage_cost_atto.clone(),
2666                    gas_cost_wei: batch_result.gas_cost_wei,
2667                },
2668                format!("merkle chunk store aborted: {e}"),
2669            ));
2670        }
2671
2672        // Non-fatal: quorum-short chunks are deferred (not failed yet) for the
2673        // post-pass concurrent retry. A deferred chunk joins `stored_addresses`
2674        // only if/when a later round stores it.
2675        let deferred: Vec<([u8; 32], String)> = outcome.failed_addresses;
2676
2677        // The store pass never blocked on backoff; now retry the deferred set in
2678        // concurrent rounds. Bodies are re-read from the spill by `store_one`
2679        // (peak RAM unchanged) and proofs re-attached. Chunks still short after
2680        // the final round become `failed`; a non-quorum error aborts as
2681        // `PartialUpload`.
2682        if !deferred.is_empty() {
2683            info!(
2684                "Deferring {} merkle chunk(s) short of quorum for concurrent retry after the store pass",
2685                deferred.len()
2686            );
2687            let dr = merkle_deferred_retry(
2688                deferred,
2689                &DEFERRED_ROUND_DELAYS_SECS,
2690                |n: usize| merkle_store_cap(store_limiter.current()).min(n.max(1)),
2691                progress,
2692                total_stored,
2693                total_chunks,
2694                &store_one,
2695            )
2696            .await?;
2697
2698            stored_addresses.extend(dr.stored_addresses);
2699            total_stored = dr.stored;
2700
2701            // Merge the deferred pass's stats — its histogram is already mapped
2702            // to the right per-round slots — into the file aggregate.
2703            agg_stats.chunk_attempts_total = agg_stats
2704                .chunk_attempts_total
2705                .saturating_add(dr.stats.chunk_attempts_total);
2706            agg_stats
2707                .store_durations_ms
2708                .extend(dr.stats.store_durations_ms);
2709            for (slot, count) in agg_stats
2710                .retries_histogram
2711                .iter_mut()
2712                .zip(dr.stats.retries_histogram.iter())
2713            {
2714                *slot = slot.saturating_add(*count);
2715            }
2716
2717            if let Some(reason) = dr.fatal {
2718                // A non-quorum store error during a deferred round is fatal, the
2719                // same as in the wave path: preserve everything stored so far and
2720                // report every not-stored chunk as failed.
2721                warn!("merkle deferred retry aborted: {reason}");
2722                let mut known_failed = failed;
2723                known_failed.extend(dr.failed_addresses);
2724                return Err(partial_upload_after_fatal(
2725                    addresses,
2726                    stored_addresses,
2727                    total_stored,
2728                    total_chunks,
2729                    known_failed,
2730                    PartialUploadSpend {
2731                        storage_cost_atto: batch_result.storage_cost_atto.clone(),
2732                        gas_cost_wei: batch_result.gas_cost_wei,
2733                    },
2734                    format!("merkle chunk store aborted: {reason}"),
2735                ));
2736            }
2737            failed.extend(dr.failed_addresses);
2738        }
2739
2740        // A file with any permanently-failed chunk is not fully stored — surface
2741        // it as `PartialUpload`, but only after the store pass and every deferred
2742        // retry round are exhausted (never silently succeed with missing chunks).
2743        if !failed.is_empty() {
2744            let failed_count = failed.len();
2745            let total_attempts = 1 + DEFERRED_ROUND_DELAYS_SECS.len();
2746            warn!(
2747                "merkle upload incomplete: {failed_count}/{total_chunks} chunks short of quorum after retries"
2748            );
2749            return Err(Error::PartialUpload {
2750                stored: stored_addresses,
2751                stored_count: total_stored,
2752                failed,
2753                failed_count,
2754                total_chunks,
2755                spend: Box::new(PartialUploadSpend {
2756                    storage_cost_atto: batch_result.storage_cost_atto.clone(),
2757                    gas_cost_wei: batch_result.gas_cost_wei,
2758                }),
2759                reason: format!(
2760                    "{failed_count} chunk(s) short of quorum after {total_attempts} attempts"
2761                ),
2762            });
2763        }
2764
2765        Ok((
2766            total_stored,
2767            batch_result.storage_cost_atto.clone(),
2768            batch_result.gas_cost_wei,
2769            agg_stats,
2770        ))
2771    }
2772
2773    /// Download and decrypt a file from the network, writing it to disk.
2774    ///
2775    /// Uses `streaming_decrypt` so that only one batch of chunks lives in
2776    /// memory at a time, avoiding OOM on large files. Chunks are fetched
2777    /// concurrently within each batch, then decrypted data is written to
2778    /// disk incrementally.
2779    ///
2780    /// Returns the number of bytes written.
2781    ///
2782    /// # Panics
2783    ///
2784    /// Requires a multi-threaded Tokio runtime (`flavor = "multi_thread"`).
2785    /// Will panic if called from a `current_thread` runtime because
2786    /// `streaming_decrypt` takes a synchronous callback that must bridge
2787    /// back to async via `block_in_place`.
2788    ///
2789    /// # Errors
2790    ///
2791    /// Returns an error if any chunk cannot be retrieved, decryption fails,
2792    /// or the file cannot be written.
2793    pub async fn file_download(&self, data_map: &DataMap, output: &Path) -> Result<u64> {
2794        self.file_download_with_progress(data_map, output, None)
2795            .await
2796    }
2797
2798    /// Download and decrypt a file, trying the requested number of
2799    /// closest peers for every chunk fetch.
2800    ///
2801    /// Returns the number of bytes written.
2802    ///
2803    /// # Errors
2804    ///
2805    /// Returns an error if any chunk cannot be retrieved, decryption fails,
2806    /// or the file cannot be written.
2807    pub async fn file_download_from_closest_peers(
2808        &self,
2809        data_map: &DataMap,
2810        output: &Path,
2811        peer_count: NonZeroUsize,
2812    ) -> Result<u64> {
2813        self.file_download_with_progress_using_peer_count(data_map, output, None, peer_count.get())
2814            .await
2815    }
2816
2817    /// Download and decrypt a file with progress events, trying the
2818    /// requested number of closest peers for every chunk fetch.
2819    ///
2820    /// Same as [`Client::file_download_from_closest_peers`] but sends
2821    /// [`DownloadEvent`]s for UI feedback.
2822    ///
2823    /// # Errors
2824    ///
2825    /// Returns an error if any chunk cannot be retrieved, decryption fails,
2826    /// or the file cannot be written.
2827    pub async fn file_download_with_progress_from_closest_peers(
2828        &self,
2829        data_map: &DataMap,
2830        output: &Path,
2831        progress: Option<mpsc::Sender<DownloadEvent>>,
2832        peer_count: NonZeroUsize,
2833    ) -> Result<u64> {
2834        self.file_download_with_progress_using_peer_count(
2835            data_map,
2836            output,
2837            progress,
2838            peer_count.get(),
2839        )
2840        .await
2841    }
2842
2843    /// Download and decrypt a file with peer-health diagnostics.
2844    ///
2845    /// Each file chunk is fetched by querying every selected closest peer,
2846    /// not by returning after the first successful peer. The returned report
2847    /// records which peers had each chunk and which did not. DataMap
2848    /// resolution still uses the normal early-return fetch path; diagnostics
2849    /// are for file chunks only.
2850    ///
2851    /// # Errors
2852    ///
2853    /// Returns an error if any chunk cannot be retrieved, decryption fails,
2854    /// or the file cannot be written.
2855    pub async fn file_download_with_peer_report_from_closest_peers(
2856        &self,
2857        data_map: &DataMap,
2858        output: &Path,
2859        progress: Option<mpsc::Sender<DownloadEvent>>,
2860        peer_count: NonZeroUsize,
2861    ) -> Result<FileDownloadWithPeerReport> {
2862        let chunk_reports = Arc::new(Mutex::new(Vec::new()));
2863        let bytes_written = self
2864            .file_download_with_progress_using_peer_count_and_reports(
2865                data_map,
2866                output,
2867                progress,
2868                peer_count.get(),
2869                Some(chunk_reports.clone()),
2870            )
2871            .await?;
2872
2873        let chunk_reports = chunk_reports
2874            .lock()
2875            .map_err(|_| Error::Storage("file chunk peer report lock poisoned".to_string()))?
2876            .clone();
2877        let chunk_reports = file_chunk_reports_from_recorded_sweeps(chunk_reports);
2878
2879        Ok(FileDownloadWithPeerReport {
2880            bytes_written,
2881            chunk_reports,
2882        })
2883    }
2884
2885    async fn download_fetch_file_chunk(
2886        &self,
2887        idx: usize,
2888        hash: XorName,
2889        context: FileDownloadFetchContext,
2890        is_deferred_retry: bool,
2891        attempt: usize,
2892    ) -> std::result::Result<DownloadBatchEntry, self_encryption::Error> {
2893        let addr = hash.0;
2894        let addr_hex = hex::encode(addr);
2895
2896        let chunk_content = if let Some(peer_reports) = context.peer_reports {
2897            match self
2898                .chunk_get_from_closest_peer_group(&addr, context.peer_count)
2899                .await
2900            {
2901                Ok(results) => {
2902                    let (content, sweep) = file_chunk_sweep_report_from_peer_results(
2903                        attempt,
2904                        is_deferred_retry,
2905                        &results,
2906                    );
2907                    peer_reports
2908                        .lock()
2909                        .map_err(|_| {
2910                            self_encryption::Error::Generic(
2911                                "file chunk peer report lock poisoned".to_string(),
2912                            )
2913                        })?
2914                        .push(RecordedFileChunkPeerSweep {
2915                            index: idx + 1,
2916                            address: addr,
2917                            sweep,
2918                        });
2919                    content
2920                }
2921                Err(e) => {
2922                    if is_deferred_retry {
2923                        info!(
2924                            "Deferred all-peer retry for {addr_hex} hit transient error: {e}; re-deferring"
2925                        );
2926                    } else {
2927                        info!("First-pass all-peer fetch error for {addr_hex}: {e}; deferring");
2928                    }
2929                    peer_reports
2930                        .lock()
2931                        .map_err(|_| {
2932                            self_encryption::Error::Generic(
2933                                "file chunk peer report lock poisoned".to_string(),
2934                            )
2935                        })?
2936                        .push(RecordedFileChunkPeerSweep {
2937                            index: idx + 1,
2938                            address: addr,
2939                            sweep: file_chunk_sweep_report_from_error(
2940                                attempt,
2941                                is_deferred_retry,
2942                                &e,
2943                            ),
2944                        });
2945                    None
2946                }
2947            }
2948        } else {
2949            match self
2950                .chunk_get_observed_from_closest_peers(&addr, context.peer_count)
2951                .await
2952            {
2953                Ok(Some(chunk)) => Some(chunk.content),
2954                Ok(None) => None,
2955                Err(e) => {
2956                    if is_deferred_retry {
2957                        info!(
2958                            "Deferred retry for {addr_hex} hit transient error: {e}; re-deferring"
2959                        );
2960                    } else {
2961                        info!("First-pass fetch error for {addr_hex}: {e}; deferring");
2962                    }
2963                    None
2964                }
2965            }
2966        };
2967
2968        let Some(content) = chunk_content else {
2969            return Ok((idx, Err(hash)));
2970        };
2971
2972        let fetched = context
2973            .fetched_ref
2974            .fetch_add(1, std::sync::atomic::Ordering::Relaxed)
2975            + 1;
2976        if is_deferred_retry {
2977            info!(
2978                "Downloaded {fetched}/{} (deferred retry)",
2979                context.total_chunks
2980            );
2981        } else {
2982            let total_chunks = context.total_chunks;
2983            info!("Downloaded {fetched}/{total_chunks}");
2984        }
2985        if let Some(ref tx) = context.progress_ref {
2986            let _ = tx.try_send(DownloadEvent::ChunksFetched {
2987                fetched,
2988                total: context.total_chunks,
2989            });
2990        }
2991
2992        Ok((idx, Ok(content)))
2993    }
2994
2995    /// Shared download core: resolve the DataMap, then fetch + streaming-decrypt
2996    /// the file one batch at a time, handing each decrypted plaintext segment
2997    /// (in order) to `on_chunk`. Constant memory — only one decrypt batch is
2998    /// resident at a time. Returns the total plaintext bytes produced.
2999    ///
3000    /// `on_chunk` is async so a sink can apply backpressure (e.g. a bounded
3001    /// channel). Driving the decrypt iterator runs the batched chunk fetch via
3002    /// `block_in_place`, so this requires a multi-threaded Tokio runtime.
3003    ///
3004    /// Every chunk fetch tries `peer_count` closest peers.
3005    ///
3006    /// Progress reporting (via `progress`):
3007    /// 1. Resolves hierarchical DataMaps to the root level first (reports as
3008    ///    `ChunksFetched` with `total: 0` during resolution)
3009    /// 2. Once the root DataMap is known, sends `total_chunks` with accurate count
3010    /// 3. Fetches data chunks with accurate `fetched/total` progress
3011    async fn download_decrypted_chunks<F, Fut>(
3012        &self,
3013        data_map: &DataMap,
3014        progress: Option<mpsc::Sender<DownloadEvent>>,
3015        peer_count: usize,
3016        peer_reports: Option<Arc<Mutex<Vec<RecordedFileChunkPeerSweep>>>>,
3017        mut on_chunk: F,
3018    ) -> Result<u64>
3019    where
3020        F: FnMut(Bytes) -> Fut,
3021        Fut: std::future::Future<Output = Result<()>>,
3022    {
3023        let handle = Handle::current();
3024
3025        // Phase 1: Resolve hierarchical DataMap to root level.
3026        // This fetches child DataMap chunks (typically 3) to discover the real chunk count.
3027        let root_map = if data_map.is_child() {
3028            let dm_chunks = data_map.len();
3029            if let Some(ref tx) = progress {
3030                let _ = tx.try_send(DownloadEvent::ResolvingDataMap {
3031                    total_map_chunks: dm_chunks,
3032                });
3033            }
3034
3035            let resolve_progress = progress.clone();
3036            let resolve_counter = Arc::new(std::sync::atomic::AtomicUsize::new(0));
3037
3038            let resolved = tokio::task::block_in_place(|| {
3039                let counter_ref = resolve_counter.clone();
3040                let progress_ref = resolve_progress.clone();
3041                let fetch_limiter = self.controller().fetch.clone();
3042                let fetch = |batch: &[(usize, XorName)]| {
3043                    let batch_owned: Vec<(usize, XorName)> = batch.to_vec();
3044                    let counter = counter_ref.clone();
3045                    let prog = progress_ref.clone();
3046                    let limiter = fetch_limiter.clone();
3047                    handle.block_on(async {
3048                        // Use rebucketed_unordered so the in-flight cap
3049                        // is re-read from the limiter as each slot frees.
3050                        // `buffer_unordered` snapshots the cap once at
3051                        // pipeline build, which means observe_op
3052                        // signals from inside chunk_get cannot reduce
3053                        // concurrency on the current batch — exactly
3054                        // the case where load-shedding is needed.
3055                        let mut results = rebucketed_unordered(
3056                            &limiter,
3057                            batch_owned,
3058                            |(idx, hash): (usize, XorName)| {
3059                                let counter = counter.clone();
3060                                let prog = prog.clone();
3061                                async move {
3062                                    let addr = hash.0;
3063                                    // chunk_get_observed feeds the
3064                                    // adaptive fetch limiter once per
3065                                    // call via chunk_get_outcome
3066                                    // (Ok(None) -> Timeout is the
3067                                    // load-shedding signal for
3068                                    // sustained close-group exhaustion).
3069                                    let chunk = self
3070                                        .chunk_get_observed_from_closest_peers(&addr, peer_count)
3071                                        .await
3072                                        .map_err(|e| {
3073                                            self_encryption::Error::Generic(format!(
3074                                                "DataMap resolution failed: {e}"
3075                                            ))
3076                                        })?
3077                                        .ok_or_else(|| {
3078                                            self_encryption::Error::Generic(format!(
3079                                                "DataMap chunk not found: {}",
3080                                                hex::encode(addr)
3081                                            ))
3082                                        })?;
3083                                    let fetched = counter
3084                                        .fetch_add(1, std::sync::atomic::Ordering::Relaxed)
3085                                        + 1;
3086                                    if let Some(ref tx) = prog {
3087                                        let _ =
3088                                            tx.try_send(DownloadEvent::MapChunkFetched { fetched });
3089                                    }
3090                                    Ok::<_, self_encryption::Error>((idx, chunk.content))
3091                                }
3092                            },
3093                        )
3094                        .await?;
3095                        // CRITICAL: self_encryption::get_root_data_map_parallel
3096                        // pairs the returned Vec POSITIONALLY with the input
3097                        // hashes via .zip() and discards our idx field.
3098                        // rebucketed_unordered preserves first-completion
3099                        // order, so sort by idx to restore input order
3100                        // before returning.
3101                        results.sort_by_key(|(idx, _)| *idx);
3102                        Ok(results)
3103                    })
3104                };
3105                get_root_data_map_parallel(data_map.clone(), &fetch)
3106            })
3107            .map_err(|e| Error::Encryption(format!("DataMap resolution failed: {e}")))?;
3108
3109            info!(
3110                "Resolved hierarchical DataMap: {} data chunks",
3111                resolved.len()
3112            );
3113            resolved
3114        } else {
3115            data_map.clone()
3116        };
3117
3118        // Phase 2: Now we know the real chunk count.
3119        let total_chunks = root_map.len();
3120        if let Some(ref tx) = progress {
3121            let _ = tx.try_send(DownloadEvent::DataMapResolved { total_chunks });
3122        }
3123
3124        // Phase 3: Fetch and decrypt data chunks with accurate progress.
3125        let fetched_counter = Arc::new(std::sync::atomic::AtomicUsize::new(0));
3126        let fetched_for_closure = fetched_counter.clone();
3127        let progress_for_closure = progress.clone();
3128        let peer_reports_for_closure = peer_reports.clone();
3129
3130        let fetch_limiter_outer = self.controller().fetch.clone();
3131        let usable_memory = usable_memory_bytes();
3132        let configured_batch_floor = stream_decrypt_batch_size();
3133        let fetch_cap = fetch_limiter_outer.current();
3134        let decrypt_batch_size = adaptive_stream_decrypt_batch_size(
3135            total_chunks,
3136            fetch_cap,
3137            configured_batch_floor,
3138            usable_memory,
3139        );
3140        info!(
3141            total_chunks,
3142            fetch_cap,
3143            configured_batch_floor,
3144            ?usable_memory,
3145            decrypt_batch_size,
3146            "Selected adaptive stream decrypt batch size"
3147        );
3148
3149        let stream = streaming_decrypt_with_batch_size(
3150            &root_map,
3151            |batch: &[(usize, XorName)]| {
3152                let batch_owned: Vec<(usize, XorName)> = batch.to_vec();
3153                let fetch_context = FileDownloadFetchContext {
3154                    total_chunks,
3155                    peer_count,
3156                    fetched_ref: fetched_for_closure.clone(),
3157                    progress_ref: progress_for_closure.clone(),
3158                    peer_reports: peer_reports_for_closure.clone(),
3159                };
3160                let fetch_limiter = fetch_limiter_outer.clone();
3161
3162                tokio::task::block_in_place(|| {
3163                    handle.block_on(async {
3164                        // First pass: try every chunk in the batch. Normal mode
3165                        // uses chunk_get_observed (early-return after a found
3166                        // peer); diagnostic mode asks every selected closest
3167                        // peer and records that sweep before returning bytes.
3168                        // Any missing chunk or transient fetch error is encoded
3169                        // as Err(hash), so one noisy chunk does not abort the
3170                        // whole batch before the deferred retry rounds run.
3171                        let first_fetch_context = fetch_context.clone();
3172                        let raw: Vec<DownloadBatchEntry> = rebucketed_unordered(
3173                            &fetch_limiter,
3174                            batch_owned,
3175                            |(idx, hash): (usize, XorName)| {
3176                                let fetch_context = first_fetch_context.clone();
3177                                async move {
3178                                    self.download_fetch_file_chunk(
3179                                        idx,
3180                                        hash,
3181                                        fetch_context,
3182                                        false,
3183                                        FIRST_DIAGNOSTIC_FETCH_ATTEMPT,
3184                                    )
3185                                    .await
3186                                }
3187                            },
3188                        )
3189                        .await?;
3190
3191                        // Partition: things we already have vs the
3192                        // deferred set we need to retry.
3193                        let mut results: Vec<(usize, bytes::Bytes)> = Vec::new();
3194                        let mut deferred: Vec<(usize, XorName)> = Vec::new();
3195                        for (idx, inner) in raw {
3196                            match inner {
3197                                Ok(bytes) => results.push((idx, bytes)),
3198                                Err(hash) => deferred.push((idx, hash)),
3199                            }
3200                        }
3201
3202                        // Deferred retry pass: retry the deferred chunks
3203                        // in CONCURRENT rounds (reusing the fetch
3204                        // limiter's cap), not serially. The first round
3205                        // fires immediately — most deferrals on a
3206                        // healthy-but-lossy link are peer-side noise
3207                        // that clears in well under a second, and
3208                        // serializing them behind mandatory multi-second
3209                        // sleeps was the single biggest throughput sink
3210                        // on such links (a batch deferring ~20 chunks
3211                        // burned minutes of near-zero throughput even
3212                        // though every chunk succeeded on its first
3213                        // retry). Only chunks that survive a round get a
3214                        // longer back-off before the next, so genuine
3215                        // saturation still gets time to settle.
3216                        if !deferred.is_empty() {
3217                            // Round delays in seconds. Round 0 is
3218                            // immediate; later rounds back off to ride
3219                            // out sustained saturation.
3220                            const DEFERRED_ROUND_DELAYS_SECS: [u64; 3] = [0, 15, 45];
3221                            info!(
3222                                "Deferring {} chunk(s) for concurrent retry after batch settles",
3223                                deferred.len()
3224                            );
3225                            let mut remaining = deferred;
3226                            for (round, &delay_secs) in
3227                                DEFERRED_ROUND_DELAYS_SECS.iter().enumerate()
3228                            {
3229                                if remaining.is_empty() {
3230                                    break;
3231                                }
3232                                if delay_secs > 0 {
3233                                    tokio::time::sleep(std::time::Duration::from_secs(delay_secs))
3234                                        .await;
3235                                }
3236                                info!(
3237                                    "Deferred retry round {}/{}: {} chunk(s)",
3238                                    round + 1,
3239                                    DEFERRED_ROUND_DELAYS_SECS.len(),
3240                                    remaining.len(),
3241                                );
3242                                let round_input = std::mem::take(&mut remaining);
3243                                let retry_fetch_context = fetch_context.clone();
3244                                let round_results: Vec<DownloadBatchEntry> = rebucketed_unordered(
3245                                    &fetch_limiter,
3246                                    round_input,
3247                                    |(idx, hash): (usize, XorName)| {
3248                                        let fetch_context = retry_fetch_context.clone();
3249                                        async move {
3250                                            self.download_fetch_file_chunk(
3251                                                idx,
3252                                                hash,
3253                                                fetch_context,
3254                                                true,
3255                                                round + DEFERRED_RETRY_ATTEMPT_OFFSET,
3256                                            )
3257                                            .await
3258                                        }
3259                                    },
3260                                )
3261                                .await?;
3262                                for (idx, inner) in round_results {
3263                                    match inner {
3264                                        Ok(bytes) => results.push((idx, bytes)),
3265                                        Err(hash) => remaining.push((idx, hash)),
3266                                    }
3267                                }
3268                            }
3269                            if let Some((_, hash)) = remaining.first() {
3270                                return Err(self_encryption::Error::Generic(format!(
3271                                    "Chunk not found after {} deferred retry rounds: {}",
3272                                    DEFERRED_ROUND_DELAYS_SECS.len(),
3273                                    hex::encode(hash.0),
3274                                )));
3275                            }
3276                        }
3277
3278                        // streaming_decrypt itself sort_by_keys before
3279                        // zipping, but the same closure is also passed
3280                        // through get_root_data_map_parallel internally
3281                        // (see self_encryption::stream_decrypt.rs::new), and
3282                        // THAT path zips positionally without sorting. Sort
3283                        // here so both consumers see input order.
3284                        results.sort_by_key(|(idx, _)| *idx);
3285                        Ok(results)
3286                    })
3287                })
3288            },
3289            decrypt_batch_size,
3290        )
3291        .map_err(|e| Error::Encryption(format!("streaming decrypt failed: {e}")))?;
3292
3293        // Drive the iterator (each `next()` runs the batched fetch via
3294        // block_in_place) and hand each decrypted segment to the sink in
3295        // order. Awaiting the sink between items yields back to the runtime so
3296        // a bounded sink can apply backpressure.
3297        let mut bytes_total = 0u64;
3298        for chunk_result in stream {
3299            let chunk: Bytes =
3300                chunk_result.map_err(|e| Error::Encryption(format!("decryption failed: {e}")))?;
3301            bytes_total += chunk.len() as u64;
3302            on_chunk(chunk).await?;
3303        }
3304        Ok(bytes_total)
3305    }
3306
3307    /// Download and decrypt a file to disk, with optional progress events.
3308    ///
3309    /// Same as [`Client::file_download`] but sends [`DownloadEvent`]s for UI
3310    /// feedback. Streams to a temp file (one decrypt batch resident at a time)
3311    /// and renames atomically on success. A `TempDownload` guard removes the
3312    /// staging file on any error path, including a panic.
3313    pub async fn file_download_with_progress(
3314        &self,
3315        data_map: &DataMap,
3316        output: &Path,
3317        progress: Option<mpsc::Sender<DownloadEvent>>,
3318    ) -> Result<u64> {
3319        self.file_download_with_progress_using_peer_count(
3320            data_map,
3321            output,
3322            progress,
3323            self.config().close_group_size,
3324        )
3325        .await
3326    }
3327
3328    /// Download and decrypt a file to disk with progress events, trying
3329    /// `peer_count` closest peers for every chunk fetch.
3330    ///
3331    /// Streams to a temp file (one decrypt batch resident at a time) and
3332    /// renames atomically on success.
3333    async fn file_download_with_progress_using_peer_count(
3334        &self,
3335        data_map: &DataMap,
3336        output: &Path,
3337        progress: Option<mpsc::Sender<DownloadEvent>>,
3338        peer_count: usize,
3339    ) -> Result<u64> {
3340        self.file_download_with_progress_using_peer_count_and_reports(
3341            data_map, output, progress, peer_count, None,
3342        )
3343        .await
3344    }
3345
3346    async fn file_download_with_progress_using_peer_count_and_reports(
3347        &self,
3348        data_map: &DataMap,
3349        output: &Path,
3350        progress: Option<mpsc::Sender<DownloadEvent>>,
3351        peer_count: usize,
3352        peer_reports: Option<Arc<Mutex<Vec<RecordedFileChunkPeerSweep>>>>,
3353    ) -> Result<u64> {
3354        debug!("Downloading file to {}", output.display());
3355
3356        let parent = output.parent().unwrap_or_else(|| Path::new("."));
3357        let unique: u64 = rand::random();
3358        let tmp_path = parent.join(format!(".ant_download_{}_{unique}.tmp", std::process::id()));
3359
3360        // Guard removes the staging file on any early return OR a panic unwind
3361        // out of the `block_in_place` decrypt loop; defused only by a
3362        // successful commit(). Centralizes what used to be three duplicated
3363        // cleanup arms.
3364        let tmp = TempDownload::new(tmp_path);
3365        let mut file = std::fs::File::create(tmp.path())?;
3366
3367        let bytes_written = self
3368            .download_decrypted_chunks(data_map, progress, peer_count, peer_reports, |bytes| {
3369                let r = file.write_all(&bytes).map_err(Error::from);
3370                std::future::ready(r)
3371            })
3372            .await?;
3373        file.flush()?;
3374        drop(file); // close the handle before rename (Windows won't rename an open file)
3375
3376        tmp.commit(output)?;
3377        info!(
3378            "File downloaded: {bytes_written} bytes written to {}",
3379            output.display()
3380        );
3381        Ok(bytes_written)
3382    }
3383
3384    /// Download and decrypt a file, streaming the plaintext to `sink` instead
3385    /// of writing to disk.
3386    ///
3387    /// Constant memory (one decrypt batch resident at a time); the caller
3388    /// receives bytes progressively as each batch decrypts, suitable for
3389    /// forwarding to an HTTP chunked body or a gRPC response stream. The
3390    /// bounded `sink` applies backpressure. If the receiver is dropped (e.g.
3391    /// the client disconnected) the download stops early and returns
3392    /// [`Error::Cancelled`].
3393    ///
3394    /// The channel item type is `Result<Bytes, Error>`, so the caller sets up:
3395    ///
3396    /// ```ignore
3397    /// let (tx, rx) = tokio::sync::mpsc::channel::<Result<Bytes, Error>>(8);
3398    /// ```
3399    ///
3400    /// Typically the caller `tokio::spawn`s this and converts the matching
3401    /// `Receiver` into its response stream. Requires a multi-threaded Tokio
3402    /// runtime (the decrypt iterator uses `block_in_place`).
3403    pub async fn file_download_to_sender(
3404        &self,
3405        data_map: &DataMap,
3406        sink: mpsc::Sender<std::result::Result<Bytes, Error>>,
3407        progress: Option<mpsc::Sender<DownloadEvent>>,
3408    ) -> Result<u64> {
3409        let peer_count = self.config().close_group_size;
3410        self.download_decrypted_chunks(data_map, progress, peer_count, None, |bytes| {
3411            let sink = sink.clone();
3412            async move {
3413                sink.send(Ok(bytes))
3414                    .await
3415                    .map_err(|_| Error::Cancelled("download stream receiver dropped".into()))
3416            }
3417        })
3418        .await
3419    }
3420}
3421
3422#[cfg(test)]
3423#[allow(clippy::unwrap_used)]
3424mod tests {
3425    use super::*;
3426
3427    #[test]
3428    fn merkle_store_cap_clamps_to_memory_bound() {
3429        // Below the ceiling: pass the adaptive cap through unchanged.
3430        assert_eq!(merkle_store_cap(8), 8);
3431        assert_eq!(merkle_store_cap(64), 64);
3432        // A configured `adaptive.max.store` above the ceiling must be clamped so
3433        // the whole-file fan-out can't pin more than ~256 MB of bodies (PR #137).
3434        assert_eq!(merkle_store_cap(512), MERKLE_STORE_MAX_IN_FLIGHT);
3435        assert_eq!(merkle_store_cap(usize::MAX), MERKLE_STORE_MAX_IN_FLIGHT);
3436        // Never zero — always make progress.
3437        assert_eq!(merkle_store_cap(0), 1);
3438    }
3439
3440    #[test]
3441    fn distributed_sample_indices_spreads_across_large_file() {
3442        // cap 5 over 100 chunks: first and last included, evenly spread.
3443        assert_eq!(distributed_sample_indices(100, 5), vec![0, 24, 49, 74, 99]);
3444    }
3445
3446    #[test]
3447    fn distributed_sample_indices_covers_whole_small_file() {
3448        // total <= cap returns every index, preserving the exact
3449        // "whole file sampled" detection in estimate_upload_cost.
3450        assert_eq!(distributed_sample_indices(3, 5), vec![0, 1, 2]);
3451        assert_eq!(distributed_sample_indices(5, 5), vec![0, 1, 2, 3, 4]);
3452    }
3453
3454    #[test]
3455    fn distributed_sample_indices_is_in_range_and_increasing() {
3456        assert!(distributed_sample_indices(0, 5).is_empty());
3457        assert_eq!(distributed_sample_indices(1, 5), vec![0]);
3458        for total in 1..200usize {
3459            let idx = distributed_sample_indices(total, 5);
3460            assert_eq!(*idx.first().unwrap(), 0);
3461            assert_eq!(*idx.last().unwrap(), total - 1);
3462            assert!(idx.iter().all(|&i| i < total));
3463            assert!(idx.windows(2).all(|w| w[0] < w[1]));
3464        }
3465    }
3466
3467    #[test]
3468    fn disk_space_check_passes_for_small_file() {
3469        // A 1 KB file should always pass the disk space check
3470        check_disk_space_for_spill(1024).unwrap();
3471    }
3472
3473    #[test]
3474    fn disk_space_check_fails_for_absurd_size() {
3475        // Requesting space for a 1 exabyte file should fail on any real system
3476        let result = check_disk_space_for_spill(u64::MAX / 2);
3477        assert!(result.is_err());
3478        let err = result.unwrap_err();
3479        assert!(
3480            matches!(err, Error::InsufficientDiskSpace(_)),
3481            "expected InsufficientDiskSpace, got: {err}"
3482        );
3483    }
3484
3485    #[test]
3486    fn adaptive_stream_decrypt_batch_size_tracks_fetch_headroom() {
3487        let batch_size = adaptive_stream_decrypt_batch_size(1_000, 64, 10, Some(u64::MAX));
3488
3489        assert_eq!(batch_size, 64 * DOWNLOAD_STREAM_BATCH_FETCH_MULTIPLIER);
3490    }
3491
3492    #[test]
3493    fn adaptive_stream_decrypt_batch_size_caps_to_total_chunks() {
3494        let batch_size = adaptive_stream_decrypt_batch_size(12, 64, 10, Some(u64::MAX));
3495
3496        assert_eq!(batch_size, 12);
3497    }
3498
3499    #[test]
3500    fn adaptive_stream_decrypt_batch_size_honours_configured_floor() {
3501        let batch_size = adaptive_stream_decrypt_batch_size(1_000, 1, 32, None);
3502
3503        assert_eq!(batch_size, 32);
3504    }
3505
3506    #[test]
3507    fn adaptive_stream_decrypt_batch_size_does_not_expand_without_memory_reading() {
3508        let batch_size = adaptive_stream_decrypt_batch_size(1_000, 64, 10, None);
3509
3510        assert_eq!(batch_size, 10);
3511    }
3512
3513    #[test]
3514    fn adaptive_stream_decrypt_batch_size_caps_to_memory_budget() {
3515        let estimated_bytes_per_chunk = (self_encryption::MAX_CHUNK_SIZE as u64)
3516            .saturating_mul(DOWNLOAD_STREAM_BATCH_BYTES_PER_CHUNK_MULTIPLIER)
3517            .max(1);
3518        let usable_memory = estimated_bytes_per_chunk
3519            .saturating_mul(16)
3520            .saturating_mul(DOWNLOAD_STREAM_BATCH_MEMORY_BUDGET_DIVISOR);
3521        let batch_size = adaptive_stream_decrypt_batch_size(1_000, 256, 10, Some(usable_memory));
3522
3523        assert_eq!(batch_size, 16);
3524    }
3525
3526    #[test]
3527    fn adaptive_stream_decrypt_batch_size_keeps_one_chunk_when_memory_is_tight() {
3528        let batch_size = adaptive_stream_decrypt_batch_size(1_000, 64, 10, Some(1));
3529
3530        assert_eq!(batch_size, 1);
3531    }
3532
3533    #[test]
3534    fn cached_merkle_covers_only_when_all_addresses_have_proofs() {
3535        let covered = compute_address(&Bytes::from_static(b"covered"));
3536        let extra = compute_address(&Bytes::from_static(b"extra"));
3537        let missing = compute_address(&Bytes::from_static(b"missing"));
3538        let cached = MerkleBatchPaymentResult {
3539            proofs: HashMap::from([(covered, vec![1]), (extra, vec![2])]),
3540            chunk_count: 2,
3541            storage_cost_atto: "0".to_string(),
3542            gas_cost_wei: 0,
3543            merkle_payment_timestamp: 0,
3544        };
3545
3546        assert!(cached_merkle_covers_addresses(&cached, &[covered]));
3547        assert!(cached_merkle_covers_addresses(&cached, &[covered, extra]));
3548        assert!(!cached_merkle_covers_addresses(
3549            &cached,
3550            &[covered, missing]
3551        ));
3552    }
3553
3554    /// A partial merkle payment leaves some addresses without a proof. Those
3555    /// must be split out so `upload_merkle_from_spill` reports them as failed
3556    /// (`PartialUpload`) instead of aborting the whole file — preserving the
3557    /// addresses' original order in each group.
3558    #[test]
3559    fn partition_addresses_by_proof_splits_paid_and_unpaid() {
3560        let paid_a = [1u8; 32];
3561        let unpaid_b = [2u8; 32];
3562        let paid_c = [3u8; 32];
3563        let unpaid_d = [4u8; 32];
3564        let proofs: HashMap<[u8; 32], Vec<u8>> =
3565            HashMap::from([(paid_a, vec![0xaa]), (paid_c, vec![0xcc])]);
3566
3567        let (to_store, missing) =
3568            partition_addresses_by_proof(&[paid_a, unpaid_b, paid_c, unpaid_d], &proofs);
3569
3570        assert_eq!(to_store, vec![paid_a, paid_c]);
3571        assert_eq!(missing, vec![unpaid_b, unpaid_d]);
3572    }
3573
3574    /// A wave that returns `Ok` contributes its stored chunks, parsed cost, and
3575    /// stats; nothing is recorded as failed.
3576    #[test]
3577    fn fold_single_wave_keeps_ok_wave() {
3578        let stored = vec![[1u8; 32], [2u8; 32]];
3579        let stats = WaveAggregateStats {
3580            chunk_attempts_total: 7,
3581            ..Default::default()
3582        };
3583
3584        let outcome = fold_single_wave(Ok((stored.clone(), "100".to_string(), 9, stats))).unwrap();
3585
3586        assert_eq!(outcome.stored, stored);
3587        assert!(outcome.failed.is_empty());
3588        assert_eq!(outcome.storage_atto.to_string(), "100");
3589        assert_eq!(outcome.gas_wei, 9);
3590        assert_eq!(outcome.stats.chunk_attempts_total, 7);
3591    }
3592
3593    /// The core V2-461 semantic: a wave short of quorum (`PartialUpload`) is
3594    /// recoverable — its stored chunks, failed chunks, and on-chain spend are
3595    /// folded so the caller can continue to the next wave rather than aborting
3596    /// the whole file.
3597    #[test]
3598    fn fold_single_wave_folds_partial_upload() {
3599        let stored = vec![[3u8; 32]];
3600        let failed = vec![([4u8; 32], "short of quorum".to_string())];
3601        let err = Error::PartialUpload {
3602            stored: stored.clone(),
3603            stored_count: 1,
3604            failed: failed.clone(),
3605            failed_count: 1,
3606            total_chunks: 2,
3607            spend: Box::new(PartialUploadSpend {
3608                storage_cost_atto: "250".to_string(),
3609                gas_cost_wei: 11,
3610            }),
3611            reason: "wave store failed after retries".to_string(),
3612        };
3613
3614        let outcome = fold_single_wave(Err(err)).unwrap();
3615
3616        assert_eq!(outcome.stored, stored);
3617        assert_eq!(outcome.failed, failed);
3618        assert_eq!(outcome.storage_atto.to_string(), "250");
3619        assert_eq!(outcome.gas_wei, 11);
3620        // `PartialUpload` carries no stats, so the failed wave contributes none.
3621        assert_eq!(outcome.stats.chunk_attempts_total, 0);
3622    }
3623
3624    /// A non-`PartialUpload` error (wallet/payment-infrastructure failure) is
3625    /// fatal and must abort the file, not be folded into the failed set.
3626    #[test]
3627    fn fold_single_wave_propagates_fatal_error() {
3628        let result = fold_single_wave(Err(Error::Payment("wallet unavailable".to_string())));
3629
3630        assert!(
3631            matches!(result, Err(Error::Payment(_))),
3632            "fatal payment error must propagate, got: {result:?}"
3633        );
3634    }
3635
3636    #[test]
3637    fn partition_addresses_by_proof_handles_all_or_nothing() {
3638        let a = [5u8; 32];
3639        let b = [6u8; 32];
3640
3641        // No proofs at all → every address is missing.
3642        let empty: HashMap<[u8; 32], Vec<u8>> = HashMap::new();
3643        let (to_store, missing) = partition_addresses_by_proof(&[a, b], &empty);
3644        assert!(to_store.is_empty());
3645        assert_eq!(missing, vec![a, b]);
3646
3647        // All proofs present → nothing missing.
3648        let full: HashMap<[u8; 32], Vec<u8>> = HashMap::from([(a, vec![1]), (b, vec![2])]);
3649        let (to_store, missing) = partition_addresses_by_proof(&[a, b], &full);
3650        assert_eq!(to_store, vec![a, b]);
3651        assert!(missing.is_empty());
3652    }
3653
3654    #[test]
3655    fn chunk_spill_round_trip() {
3656        let mut spill = ChunkSpill::new().unwrap();
3657        let data1 = vec![0xAA; 1024];
3658        let data2 = vec![0xBB; 2048];
3659
3660        spill.push(&data1).unwrap();
3661        spill.push(&data2).unwrap();
3662
3663        assert_eq!(spill.len(), 2);
3664        assert_eq!(spill.total_bytes(), 1024 + 2048);
3665        let chunk_entries = spill.chunk_entries().unwrap();
3666        let entry_total: u64 = chunk_entries.iter().map(|(_, size)| *size).sum();
3667        assert_eq!(entry_total, 1024 + 2048);
3668
3669        // Read back and verify
3670        let chunk1 = spill.read_chunk(spill.addresses.first().unwrap()).unwrap();
3671        assert_eq!(&chunk1[..], &data1[..]);
3672
3673        let chunk2 = spill.read_chunk(spill.addresses.get(1).unwrap()).unwrap();
3674        assert_eq!(&chunk2[..], &data2[..]);
3675
3676        // Verify waves with 1-chunk wave size
3677        let waves: Vec<_> = spill.addresses.chunks(1).collect();
3678        assert_eq!(waves.len(), 2);
3679    }
3680
3681    #[test]
3682    fn chunk_spill_cleanup_on_drop() {
3683        let dir;
3684        {
3685            let spill = ChunkSpill::new().unwrap();
3686            dir = spill.dir.clone();
3687            assert!(dir.exists());
3688        }
3689        // After drop, the directory should be cleaned up
3690        assert!(!dir.exists(), "spill dir should be removed on drop");
3691    }
3692
3693    #[test]
3694    fn chunk_spill_deduplicates_identical_content() {
3695        let mut spill = ChunkSpill::new().unwrap();
3696        let data = vec![0xCC; 512];
3697
3698        spill.push(&data).unwrap();
3699        spill.push(&data).unwrap(); // same content, should be skipped
3700        spill.push(&data).unwrap(); // again
3701
3702        assert_eq!(spill.len(), 1, "duplicate chunks should be deduplicated");
3703        assert_eq!(
3704            spill.total_bytes(),
3705            512,
3706            "total_bytes should count unique only"
3707        );
3708
3709        // Different content should still be added
3710        let data2 = vec![0xDD; 256];
3711        spill.push(&data2).unwrap();
3712        assert_eq!(spill.len(), 2);
3713        assert_eq!(spill.total_bytes(), 512 + 256);
3714    }
3715}
3716
3717/// Compile-time assertions that Client file method futures are Send.
3718#[cfg(test)]
3719mod send_assertions {
3720    use super::*;
3721
3722    fn _assert_send<T: Send>(_: &T) {}
3723
3724    #[allow(dead_code, unreachable_code, clippy::diverging_sub_expression)]
3725    async fn _file_upload_is_send(client: &Client) {
3726        let fut = client.file_upload(Path::new("/dev/null"));
3727        _assert_send(&fut);
3728    }
3729
3730    #[allow(dead_code, unreachable_code, clippy::diverging_sub_expression)]
3731    async fn _file_upload_with_mode_is_send(client: &Client) {
3732        let fut = client.file_upload_with_mode(Path::new("/dev/null"), PaymentMode::Auto);
3733        _assert_send(&fut);
3734    }
3735
3736    #[allow(
3737        dead_code,
3738        unreachable_code,
3739        unused_variables,
3740        clippy::diverging_sub_expression
3741    )]
3742    async fn _file_download_is_send(client: &Client) {
3743        let dm: DataMap = todo!();
3744        let fut = client.file_download(&dm, Path::new("/dev/null"));
3745        _assert_send(&fut);
3746    }
3747}