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