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, PaidChunk, PaymentIntent, PreparedChunk, WaveAggregateStats, WaveResult,
16};
17use crate::data::client::chunk::{ChunkFetchDiagnostics, ChunkPeerGetResult};
18use crate::data::client::classify_error;
19use crate::data::client::diagnostics::DownloadDiagnosticsSender;
20use crate::data::client::merkle::{
21    finalize_merkle_batch, merge_merkle_batch_results, merkle_batch_sizes, merkle_billable_leaves,
22    merkle_deferred_retry, merkle_store_with_retry, should_use_merkle, MerkleBatchPaymentResult,
23    PaymentMode, PreparedMerkleBatch, DEFERRED_ROUND_DELAYS_SECS,
24};
25use crate::data::client::payment::SINGLE_NODE_PAYMENT_MULTIPLIER;
26use crate::data::client::Client;
27use crate::data::error::{Error, PartialUploadSpend, Result};
28use ant_protocol::evm::{Amount, PaymentQuote, QuoteHash, TxHash, MAX_LEAVES};
29use ant_protocol::transport::{MultiAddr, PeerId};
30use ant_protocol::{compute_address, XorName as ChunkAddress, DATA_TYPE_CHUNK};
31use bytes::Bytes;
32use fs2::FileExt;
33use futures::stream::{self, StreamExt};
34use self_encryption::{
35    get_root_data_map_parallel, stream_decrypt_batch_size, stream_encrypt,
36    streaming_decrypt_with_batch_size, DataMap,
37};
38use std::collections::{HashMap, HashSet};
39use std::io::Write;
40use std::num::NonZeroUsize;
41use std::path::{Path, PathBuf};
42use std::sync::{Arc, Mutex};
43use tokio::runtime::Handle;
44use tokio::sync::mpsc;
45use tracing::{debug, info, warn};
46use xor_name::XorName;
47
48/// Progress events emitted during file upload for UI feedback.
49#[derive(Debug, Clone)]
50pub enum UploadEvent {
51    /// A chunk has been encrypted and spilled to disk.
52    Encrypting { chunks_done: usize },
53    /// File encryption complete.
54    Encrypted { total_chunks: usize },
55    /// Starting quote collection for a wave.
56    QuotingChunks {
57        wave: usize,
58        total_waves: usize,
59        chunks_in_wave: usize,
60    },
61    /// A chunk has been quoted (peer discovery + price received).
62    /// This is the slow phase — each quote involves network round-trips.
63    ChunkQuoted { quoted: usize, total: usize },
64    /// A chunk has been stored on the network.
65    ChunkStored { stored: usize, total: usize },
66}
67
68/// Progress events emitted during file download for UI feedback.
69#[derive(Debug, Clone)]
70pub enum DownloadEvent {
71    /// Resolving hierarchical DataMap to discover real chunk count.
72    ResolvingDataMap { total_map_chunks: usize },
73    /// A DataMap chunk has been fetched during resolution.
74    MapChunkFetched { fetched: usize },
75    /// DataMap resolved — total data chunk count now known.
76    DataMapResolved { total_chunks: usize },
77    /// Data chunks are being fetched from the network.
78    ChunksFetched { fetched: usize, total: usize },
79}
80
81/// File download result when peer-health diagnostics are enabled.
82#[derive(Debug, Clone)]
83pub struct FileDownloadWithPeerReport {
84    /// Number of plaintext bytes written to the destination.
85    pub bytes_written: u64,
86    /// Per-file-chunk closest-peer GET results collected during the actual download.
87    pub chunk_reports: Vec<FileChunkPeerReport>,
88}
89
90/// Closest-peer GET results for one file chunk.
91#[derive(Debug, Clone)]
92pub struct FileChunkPeerReport {
93    /// 1-based chunk index in the resolved file DataMap.
94    pub index: usize,
95    /// Chunk address.
96    pub address: ChunkAddress,
97    /// All diagnostic GET sweeps attempted for this chunk.
98    pub sweeps: Vec<FileChunkPeerSweepReport>,
99}
100
101/// One all-peer diagnostic GET sweep for a file chunk.
102#[derive(Debug, Clone)]
103pub struct FileChunkPeerSweepReport {
104    /// 1-based attempt number for this chunk.
105    pub attempt: usize,
106    /// Whether this sweep happened during a deferred retry round.
107    pub deferred_retry: bool,
108    /// DHT lookup / sweep-level error, if the closest-peer group could not be queried.
109    pub error: Option<String>,
110    /// Per-peer results, sorted closest first.
111    pub peers: Vec<FileChunkPeerReportPeer>,
112}
113
114/// One peer result in a [`FileChunkPeerReport`].
115#[derive(Debug, Clone)]
116pub struct FileChunkPeerReportPeer {
117    /// Peer queried for the chunk.
118    pub peer_id: PeerId,
119    /// Known network addresses used for the peer.
120    pub peer_addrs: Vec<MultiAddr>,
121    /// XOR distance from `peer_id` to the chunk address.
122    pub xor_distance: ChunkAddress,
123    /// Whether this peer returned the chunk or why it did not.
124    pub status: FileChunkPeerStatus,
125}
126
127/// Peer-level file chunk GET diagnostic status.
128#[derive(Debug, Clone)]
129pub enum FileChunkPeerStatus {
130    /// The peer returned the chunk.
131    Found { bytes: usize },
132    /// The peer responded authoritatively that it does not store the chunk.
133    NotFound,
134    /// The peer did not respond before the timeout.
135    Timeout { message: String },
136    /// The transport/network path to the peer failed.
137    NetworkError { message: String },
138    /// Any other per-peer error.
139    Error { message: String },
140}
141
142/// One entry in the per-chunk quote list returned by
143/// [`Client::get_store_quotes`]: the responding peer, its addresses, the
144/// signed quote it returned, the payment amount it is demanding, and (ADR-0004)
145/// the opaque signed-commitment blob the node shipped with the quote.
146type QuoteEntry = (
147    PeerId,
148    Vec<MultiAddr>,
149    PaymentQuote,
150    Amount,
151    Option<Vec<u8>>,
152);
153
154type DownloadBatchEntry = (usize, std::result::Result<Bytes, XorName>);
155
156#[derive(Debug, Clone)]
157struct RecordedFileChunkPeerSweep {
158    index: usize,
159    address: ChunkAddress,
160    sweep: FileChunkPeerSweepReport,
161}
162
163#[derive(Clone)]
164struct FileDownloadFetchContext {
165    total_chunks: usize,
166    peer_count: usize,
167    fetched_ref: Arc<std::sync::atomic::AtomicUsize>,
168    progress_ref: Option<mpsc::Sender<DownloadEvent>>,
169    peer_reports: Option<Arc<Mutex<Vec<RecordedFileChunkPeerSweep>>>>,
170    /// Optional runtime-gated download diagnostics sender. `None` when
171    /// `--download-diagnostics` was not passed, so the chunk-fetch path
172    /// skips all record construction and allocation.
173    diagnostics: Option<DownloadDiagnosticsSender>,
174}
175
176/// Number of chunks per upload wave (matches batch.rs PAYMENT_WAVE_SIZE).
177const UPLOAD_WAVE_SIZE: usize = 64;
178
179/// Hard ceiling on chunk bodies held in memory at once by the merkle whole-file
180/// store fan-out (`upload_merkle_from_spill`). Each in-flight store holds one
181/// spilled body (≤ `MAX_CHUNK_SIZE` = 4 MiB), so this bounds peak resident store
182/// memory at ~256 MiB — the same bound the old fixed 64-chunk waves gave. The
183/// adaptive store cap can legitimately exceed this (`AdaptiveConfig::sanitize`
184/// permits `adaptive.max.store` above 64), so the fan-out clamps its cap here to
185/// keep a high configured max from pinning gigabytes of chunk bodies (PR #137
186/// review). Throughput is unaffected at the default cap, which is already 64.
187const MERKLE_STORE_MAX_IN_FLIGHT: usize = 64;
188
189/// The merkle whole-file store fan-out concurrency: the adaptive store cap,
190/// clamped to [`MERKLE_STORE_MAX_IN_FLIGHT`] (memory bound) and floored at 1.
191fn merkle_store_cap(limiter_current: usize) -> usize {
192    limiter_current.clamp(1, MERKLE_STORE_MAX_IN_FLIGHT)
193}
194
195/// Stream decrypt batches should be larger than fetch fan-out so
196/// the rolling fetch scheduler can keep launching new chunk GETs as earlier
197/// ones complete, instead of stopping at each self-encryption batch boundary.
198const DOWNLOAD_STREAM_BATCH_FETCH_MULTIPLIER: usize = 4;
199
200/// Use at most this fraction of currently usable RAM for one decrypt batch.
201const DOWNLOAD_STREAM_BATCH_MEMORY_BUDGET_DIVISOR: u64 = 4;
202
203/// A decrypt batch briefly holds encrypted chunk bytes, decrypted chunk bytes,
204/// and Vec/Bytes overhead. Use a conservative multiplier rather than assuming
205/// payload bytes alone.
206const DOWNLOAD_STREAM_BATCH_BYTES_PER_CHUNK_MULTIPLIER: u64 = 3;
207
208/// Maximum number of distinct chunk addresses to sample when probing for a
209/// representative quote in [`Client::estimate_upload_cost`].
210///
211/// Bounded small so we never spend more than a couple of round-trips on the
212/// `AlreadyStored` retry path, which only matters when many leading chunks
213/// of a file already live on the network.
214const ESTIMATE_SAMPLE_CAP: usize = 5;
215
216/// First normal-path diagnostic fetch attempt.
217const FIRST_DIAGNOSTIC_FETCH_ATTEMPT: usize = 1;
218
219/// Deferred retry attempt number for retry round 0.
220const DEFERRED_RETRY_ATTEMPT_OFFSET: usize = 2;
221
222/// Pick up to `cap` chunk indices spread evenly across `[0, total)`, always
223/// including the first and last chunk.
224///
225/// Sampling the *first* N chunks biases the probe: a file sharing a leading
226/// prefix with a prior upload (compressed archives, similar headers) reports
227/// those chunks as `AlreadyStored` even when the tail is new, so a positional
228/// sample looks in the worst possible place. Spreading the sample means a
229/// single new chunk anywhere in the file yields a real price.
230///
231/// Returns `[0]` for a single chunk and every index when `total <= cap`, so
232/// [`Client::estimate_upload_cost`] can still detect the "whole file sampled"
233/// case. Indices are strictly increasing.
234fn distributed_sample_indices(total: usize, cap: usize) -> Vec<usize> {
235    if total == 0 {
236        return Vec::new();
237    }
238    let sample_limit = total.min(cap);
239    if sample_limit <= 1 {
240        return vec![0];
241    }
242    let mut indices: Vec<usize> = (0..sample_limit)
243        .map(|i| i * (total - 1) / (sample_limit - 1))
244        .collect();
245    indices.dedup(); // defensive: already strictly increasing for cap >= 2
246    indices
247}
248
249fn file_chunk_sweep_report_from_peer_results(
250    attempt: usize,
251    deferred_retry: bool,
252    results: &[ChunkPeerGetResult],
253) -> (Option<Bytes>, FileChunkPeerSweepReport) {
254    let mut content = None;
255    let peers = results
256        .iter()
257        .map(|result| {
258            if content.is_none() {
259                if let Ok(Some(chunk)) = &result.chunk_result {
260                    content = Some(chunk.content.clone());
261                }
262            }
263
264            FileChunkPeerReportPeer {
265                peer_id: result.peer_id,
266                peer_addrs: result.peer_addrs.clone(),
267                xor_distance: result.xor_distance,
268                status: file_chunk_peer_status(&result.chunk_result),
269            }
270        })
271        .collect();
272
273    (
274        content,
275        FileChunkPeerSweepReport {
276            attempt,
277            deferred_retry,
278            error: None,
279            peers,
280        },
281    )
282}
283
284fn file_chunk_sweep_report_from_error(
285    attempt: usize,
286    deferred_retry: bool,
287    error: &Error,
288) -> FileChunkPeerSweepReport {
289    FileChunkPeerSweepReport {
290        attempt,
291        deferred_retry,
292        error: Some(error.to_string()),
293        peers: Vec::new(),
294    }
295}
296
297fn file_chunk_reports_from_recorded_sweeps(
298    mut sweeps: Vec<RecordedFileChunkPeerSweep>,
299) -> Vec<FileChunkPeerReport> {
300    sweeps.sort_by_key(|record| (record.index, record.sweep.attempt));
301
302    let mut reports: Vec<FileChunkPeerReport> = Vec::new();
303    for record in sweeps {
304        if let Some(report) = reports
305            .last_mut()
306            .filter(|report| report.index == record.index)
307        {
308            report.sweeps.push(record.sweep);
309            continue;
310        }
311
312        reports.push(FileChunkPeerReport {
313            index: record.index,
314            address: record.address,
315            sweeps: vec![record.sweep],
316        });
317    }
318
319    reports
320}
321
322fn file_chunk_peer_status(
323    chunk_result: &std::result::Result<Option<ant_protocol::DataChunk>, Error>,
324) -> FileChunkPeerStatus {
325    match chunk_result {
326        Ok(Some(chunk)) => FileChunkPeerStatus::Found {
327            bytes: chunk.content.len(),
328        },
329        Ok(None) => FileChunkPeerStatus::NotFound,
330        Err(Error::Timeout(e)) => FileChunkPeerStatus::Timeout { message: e.clone() },
331        Err(Error::Network(e)) => FileChunkPeerStatus::NetworkError { message: e.clone() },
332        Err(e) => FileChunkPeerStatus::Error {
333            message: e.to_string(),
334        },
335    }
336}
337
338/// Gas used by one `pay_for_quotes` transaction that packs up to
339/// `UPLOAD_WAVE_SIZE` (quote_hash, rewards_address, amount) entries.
340///
341/// `batch_pay` in `batch.rs` flattens every chunk's close-group quotes into a
342/// single EVM call, so the dominant cost is the SSTOREs for each entry plus
343/// the base tx overhead. On Arbitrum that is roughly
344/// `21_000 + 64 × (20_000 + small)` ≈ 1.3M; we round up to 1.5M as a
345/// conservative per-wave upper bound.
346const GAS_PER_WAVE_TX: u128 = 1_500_000;
347
348/// Gas used by one merkle batch payment transaction.
349///
350/// One on-chain tx per merkle sub-batch, but each tx verifies a merkle tree
351/// and posts a pool commitment, so budget higher than a plain transfer.
352const GAS_PER_MERKLE_TX: u128 = 500_000;
353
354/// Advisory gas price (wei/gas) used to turn the gas estimate into an ETH
355/// figure when no live gas oracle is consulted.
356///
357/// Arbitrum One typically settles around 0.1 gwei on quiet blocks; we use
358/// that as the default so the CLI prints a sensible order-of-magnitude
359/// number. Users should treat the reported gas cost as an estimate, not a
360/// commitment — real gas is bid at submission time.
361const ARBITRUM_GAS_PRICE_WEI: u128 = 100_000_000;
362
363/// Extra headroom percentage for disk space check.
364///
365/// Encrypted chunks are slightly larger than the source data due to padding
366/// and self-encryption overhead. We require file_size + 10% free space in
367/// the temp directory to account for this.
368const DISK_SPACE_HEADROOM_PERCENT: u64 = 10;
369
370/// Temporary on-disk buffer for encrypted chunks.
371///
372/// During file encryption, chunks are written to a temp directory so that
373/// only their 32-byte addresses stay in memory. At upload time chunks are
374/// read back one wave at a time, keeping peak RAM at ~`UPLOAD_WAVE_SIZE × 4 MB`.
375/// Grace period (in seconds) before a spill dir is eligible for stale cleanup.
376///
377/// This is a small TOCTOU guard covering the sub-millisecond window inside
378/// [`ChunkSpill::new`] between `create_dir` and `try_lock_exclusive`. Once a
379/// dir is older than this and its lockfile is releasable, the owning process
380/// is gone and the dir is safe to reap — regardless of how old it is.
381///
382/// The previous policy waited 24 h before reaping any orphan, which meant
383/// that any non-graceful exit (SIGKILL, kernel OOM, panic abort) leaked its
384/// spill dir until the next day's upload — and on a host being restart-looped
385/// by systemd, orphans could fill the disk well within that window.
386const SPILL_STALE_GRACE_SECS: u64 = 30;
387
388/// Prefix for spill directory names to distinguish from user files.
389const SPILL_DIR_PREFIX: &str = "spill_";
390
391/// Lockfile name inside each spill dir to signal active use.
392const SPILL_LOCK_NAME: &str = ".lock";
393
394struct ChunkSpill {
395    /// Directory holding spilled chunk files (named by hex address).
396    dir: PathBuf,
397    /// Lockfile held for the lifetime of this spill (prevents stale cleanup).
398    _lock: std::fs::File,
399    /// Deduplicated list of chunk addresses.
400    addresses: Vec<[u8; 32]>,
401    /// Tracks seen addresses for deduplication.
402    seen: HashSet<[u8; 32]>,
403    /// Byte size per spilled chunk address.
404    sizes: HashMap<[u8; 32], u64>,
405    /// Running total of unique chunk byte sizes (for average-size calculation).
406    total_bytes: u64,
407}
408
409impl ChunkSpill {
410    /// Return the parent directory for all spill dirs: `<data_dir>/spill/`.
411    fn spill_root() -> Result<PathBuf> {
412        use crate::config;
413        let root = config::data_dir()
414            .map_err(|e| Error::Config(format!("cannot determine data dir for spill: {e}")))?
415            .join("spill");
416        Ok(root)
417    }
418
419    /// Create a new spill directory under `<data_dir>/spill/`.
420    ///
421    /// Directory name is `spill_<timestamp>_<random>` so orphans can be
422    /// identified by prefix and cleaned up by age. A lockfile inside the
423    /// dir prevents concurrent cleanup from deleting an active spill.
424    fn new() -> Result<Self> {
425        let root = Self::spill_root()?;
426        std::fs::create_dir_all(&root)?;
427
428        // Clean up stale spill dirs from previous crashed runs.
429        Self::cleanup_stale(&root);
430
431        let now = std::time::SystemTime::now()
432            .duration_since(std::time::UNIX_EPOCH)
433            .unwrap_or_default()
434            .as_secs();
435        let unique: u64 = rand::random();
436        let dir = root.join(format!("{SPILL_DIR_PREFIX}{now}_{unique}"));
437        std::fs::create_dir(&dir)?;
438
439        // Create and hold a lockfile for the lifetime of this spill.
440        // cleanup_stale() will skip dirs with locked files.
441        let lock_path = dir.join(SPILL_LOCK_NAME);
442        let lock_file = std::fs::File::create(&lock_path).map_err(|e| {
443            Error::Io(std::io::Error::new(
444                e.kind(),
445                format!("failed to create spill lockfile: {e}"),
446            ))
447        })?;
448        lock_file.try_lock_exclusive().map_err(|e| {
449            Error::Io(std::io::Error::new(
450                e.kind(),
451                format!("failed to lock spill lockfile: {e}"),
452            ))
453        })?;
454
455        Ok(Self {
456            dir,
457            _lock: lock_file,
458            addresses: Vec::new(),
459            seen: HashSet::new(),
460            sizes: HashMap::new(),
461            total_bytes: 0,
462        })
463    }
464
465    /// Clean up stale spill directories. Best-effort, errors are logged.
466    ///
467    /// A spill dir is reaped when:
468    /// 1. Its name starts with `SPILL_DIR_PREFIX` (ignores unrelated files)
469    /// 2. It is an actual directory, not a symlink (prevents symlink attacks)
470    /// 3. Its timestamp is older than `SPILL_STALE_GRACE_SECS` (TOCTOU guard)
471    /// 4. Its lockfile is releasable — i.e. no live process holds it
472    ///
473    /// The lockfile is the primary correctness gate: a releasable lock means
474    /// the owning `ChunkSpill` has been dropped or the process is gone, so
475    /// the dir is fair game. The grace period covers only the brief window
476    /// inside [`Self::new`] between `create_dir` and `try_lock_exclusive`.
477    ///
478    /// Safe to call concurrently from multiple processes.
479    fn cleanup_stale(root: &Path) {
480        let now = std::time::SystemTime::now()
481            .duration_since(std::time::UNIX_EPOCH)
482            .unwrap_or_default()
483            .as_secs();
484
485        if now == 0 {
486            // Clock is broken (before Unix epoch). Skip cleanup to avoid
487            // misidentifying dirs as stale.
488            warn!("System clock before Unix epoch, skipping spill cleanup");
489            return;
490        }
491
492        let entries = match std::fs::read_dir(root) {
493            Ok(entries) => entries,
494            Err(_) => return,
495        };
496
497        for entry in entries.flatten() {
498            let name = entry.file_name();
499            let name_str = name.to_string_lossy();
500
501            // Only process dirs with our prefix.
502            let suffix = match name_str.strip_prefix(SPILL_DIR_PREFIX) {
503                Some(s) => s,
504                None => continue,
505            };
506
507            // Parse timestamp: "spill_<timestamp>_<random>"
508            let timestamp: u64 = match suffix.split('_').next().and_then(|s| s.parse().ok()) {
509                Some(ts) => ts,
510                None => continue,
511            };
512
513            if now.saturating_sub(timestamp) < SPILL_STALE_GRACE_SECS {
514                continue;
515            }
516
517            // Safety: only delete actual directories, not symlinks.
518            let file_type = match entry.file_type() {
519                Ok(ft) => ft,
520                Err(_) => continue,
521            };
522            if !file_type.is_dir() {
523                continue;
524            }
525
526            let path = entry.path();
527
528            // Check lockfile: if locked, the dir is in active use -- skip it.
529            let lock_path = path.join(SPILL_LOCK_NAME);
530            if let Ok(lock_file) = std::fs::File::open(&lock_path) {
531                use fs2::FileExt;
532                if lock_file.try_lock_exclusive().is_err() {
533                    // Lock held by another process -- dir is active.
534                    debug!("Skipping active spill dir: {}", path.display());
535                    continue;
536                }
537                // We acquired the lock, so no one else holds it.
538                // Drop it before deleting.
539                drop(lock_file);
540            }
541
542            info!("Cleaning up stale spill dir: {}", path.display());
543            if let Err(e) = std::fs::remove_dir_all(&path) {
544                warn!("Failed to clean up stale spill dir {}: {e}", path.display());
545            }
546        }
547    }
548
549    /// Run stale spill cleanup. Call at client startup or periodically.
550    #[allow(dead_code)]
551    pub(crate) fn run_cleanup() {
552        if let Ok(root) = Self::spill_root() {
553            Self::cleanup_stale(&root);
554        }
555    }
556
557    /// Write one encrypted chunk to disk and record its address.
558    ///
559    /// Deduplicates by content address: if the same chunk was already
560    /// spilled, the write and accounting are skipped. This prevents
561    /// double-uploads and inflated quoting metrics.
562    fn push(&mut self, content: &[u8]) -> Result<()> {
563        let address = compute_address(content);
564        if !self.seen.insert(address) {
565            return Ok(());
566        }
567        let path = self.dir.join(hex::encode(address));
568        std::fs::write(&path, content)?;
569        let content_len = content.len() as u64;
570        self.sizes.insert(address, content_len);
571        self.total_bytes += content_len;
572        self.addresses.push(address);
573        Ok(())
574    }
575
576    /// Number of chunks stored.
577    fn len(&self) -> usize {
578        self.addresses.len()
579    }
580
581    /// Total bytes of all spilled chunks.
582    fn total_bytes(&self) -> u64 {
583        self.total_bytes
584    }
585
586    /// Address and byte-size pairs for all spilled chunks.
587    fn chunk_entries(&self) -> Result<Vec<([u8; 32], u64)>> {
588        self.addresses
589            .iter()
590            .map(|address| {
591                self.sizes
592                    .get(address)
593                    .copied()
594                    .map(|size| (*address, size))
595                    .ok_or_else(|| {
596                        Error::Storage(format!(
597                            "missing size for spilled chunk {}",
598                            hex::encode(address)
599                        ))
600                    })
601            })
602            .collect()
603    }
604
605    /// Read a single chunk back from disk by address.
606    fn read_chunk(&self, address: &[u8; 32]) -> Result<Bytes> {
607        let path = self.dir.join(hex::encode(address));
608        let data = std::fs::read(&path).map_err(|e| {
609            Error::Io(std::io::Error::new(
610                e.kind(),
611                format!("reading spilled chunk {}: {e}", hex::encode(address)),
612            ))
613        })?;
614        Ok(Bytes::from(data))
615    }
616
617    /// Read the bodies for `addresses` back from disk, in the given order.
618    fn read_chunks(&self, addresses: &[[u8; 32]]) -> Result<Vec<Bytes>> {
619        addresses.iter().map(|addr| self.read_chunk(addr)).collect()
620    }
621
622    /// Read every spilled body back, in insertion order.
623    fn read_all_chunks(&self) -> Result<Vec<Bytes>> {
624        self.read_chunks(&self.addresses)
625    }
626
627    /// Clean up the spill directory.
628    fn cleanup(&self) {
629        if let Err(e) = std::fs::remove_dir_all(&self.dir) {
630            warn!(
631                "Failed to clean up chunk spill dir {}: {e}",
632                self.dir.display()
633            );
634        }
635    }
636}
637
638impl Drop for ChunkSpill {
639    fn drop(&mut self) {
640        self.cleanup();
641    }
642}
643
644fn cached_merkle_covers_addresses(
645    cached: &MerkleBatchPaymentResult,
646    addresses: &[[u8; 32]],
647) -> bool {
648    addresses
649        .iter()
650        .all(|addr| cached.proofs.contains_key(addr))
651}
652
653/// Split `addresses` into `(to_store, missing_proof)`: those that have a merkle
654/// proof in `proofs`, and those that don't.
655///
656/// A partial [`MerkleBatchPaymentResult`] (from a `pay_for_merkle_multi_batch`
657/// where a later sub-batch failed) carries proofs only for the sub-batches that
658/// both settled AND produced proofs, so chunks reach the upload path with no
659/// proof. Usually that means they were never paid for, but not always: a
660/// sub-batch settles on-chain before its proofs are generated. `upload_merkle_from_spill` reports those as failed via
661/// [`Error::PartialUpload`] rather than aborting the whole file. Order within
662/// each group follows `addresses`.
663fn partition_addresses_by_proof(
664    addresses: &[[u8; 32]],
665    proofs: &HashMap<[u8; 32], Vec<u8>>,
666) -> (Vec<[u8; 32]>, Vec<[u8; 32]>) {
667    addresses
668        .iter()
669        .copied()
670        .partition(|addr| proofs.contains_key(addr))
671}
672
673/// The clause naming chunks that reached the store path with no merkle proof,
674/// or `None` when every chunk had one.
675///
676/// `payment_refusal` is the storers' verdict when one stopped this upload's
677/// own payment. It goes last, and is scoped before it is quoted: the refusal's
678/// own wording says nothing was charged, which is true of the sub-batch it
679/// refused and false of an upload whose earlier sub-batches already settled —
680/// the CLI prints that spend on the same line. Mirrors
681/// [`settlement_refusal_after_paid_waves`], which does the same job for the
682/// single-node wave path.
683///
684/// Says only that the proof is absent, never that the chunk went unpaid: a
685/// sub-batch settles on-chain before its proofs are generated, so a
686/// proof-generation failure leaves chunks that were charged for and still have
687/// no proof.
688fn proofless_clause(proofless_count: usize, payment_refusal: Option<&str>) -> Option<String> {
689    if proofless_count == 0 {
690        return None;
691    }
692    Some(match payment_refusal {
693        Some(refusal) => format!(
694            "{proofless_count} chunk(s) have no merkle proof because storers refused this \
695             client's settlement version during payment. That refusal covers those chunks; \
696             any spend reported here settled for earlier sub-batches. {refusal}"
697        ),
698        None => format!("{proofless_count} chunk(s) have no merkle proof"),
699    })
700}
701
702/// The `PartialUpload` reason for a merkle upload that ends with failed chunks.
703///
704/// Chunks with no proof were never attempted, so folding them into "short of
705/// quorum after N attempts" reports a failure they did not have and, when a
706/// settlement refusal stopped the payment, replaces the one instruction that
707/// makes the next attempt work. The two groups are reported separately so the
708/// counts still add up to `failed_count`.
709fn merkle_partial_reason(
710    failed_count: usize,
711    proofless_count: usize,
712    total_attempts: usize,
713    payment_refusal: Option<&str>,
714) -> String {
715    let quorum = |n: usize| format!("{n} chunk(s) short of quorum after {total_attempts} attempts");
716    match proofless_clause(proofless_count, payment_refusal) {
717        None => quorum(failed_count),
718        // Saturating because the proof-less chunks are a subset of the failed
719        // ones by construction; if that ever stops holding, under-reporting the
720        // shortfall beats an underflow panic on the error path.
721        Some(proofless) => match failed_count.saturating_sub(proofless_count) {
722            0 => proofless,
723            short => format!("{}; {proofless}", quorum(short)),
724        },
725    }
726}
727
728/// The `PartialUpload` reason for a merkle upload that a store failure aborted.
729///
730/// The abort is the immediate cause and leads, but chunks that arrived with no
731/// proof are a second, independent failure with its own remedy. Reporting only
732/// the abort leaves that remedy in the per-chunk messages, which the CLI does
733/// not print.
734fn merkle_fatal_reason(
735    abort: &str,
736    proofless_count: usize,
737    payment_refusal: Option<&str>,
738) -> String {
739    match proofless_clause(proofless_count, payment_refusal) {
740        Some(proofless) => format!("{abort}; {proofless}"),
741        None => abort.to_string(),
742    }
743}
744
745/// Build a `PartialUpload` after a fatal merkle store error, with accurate
746/// counts.
747///
748/// A fatal abort can leave chunks in three states: confirmed stored (in
749/// `stored_addresses`), known-failed (in `known_failed` — missing proofs, the
750/// quorum shortfalls and the fatal chunk seen so far), and "in flight when the
751/// abort hit" (neither). Rather than trust the helpers to enumerate the last
752/// group, this derives the failed set authoritatively as *every* `addresses`
753/// entry not in `stored_addresses`, preferring a known per-chunk message and
754/// falling back to the fatal `reason`. That guarantees
755/// `stored_count + failed_count` accounts for the whole file — fixing the
756/// under-reporting where a fatal wave could surface `failed_count = 0` and omit
757/// same-pass successes.
758fn partial_upload_after_fatal(
759    addresses: &[[u8; 32]],
760    stored_addresses: Vec<[u8; 32]>,
761    stored_count: usize,
762    total_chunks: usize,
763    known_failed: Vec<([u8; 32], String)>,
764    spend: PartialUploadSpend,
765    reason: String,
766) -> Error {
767    let stored_set: HashSet<[u8; 32]> = stored_addresses.iter().copied().collect();
768    let mut failed_map: HashMap<[u8; 32], String> = HashMap::new();
769    for (addr, msg) in known_failed {
770        if !stored_set.contains(&addr) {
771            failed_map.entry(addr).or_insert(msg);
772        }
773    }
774    for addr in addresses {
775        if !stored_set.contains(addr) {
776            failed_map.entry(*addr).or_insert_with(|| reason.clone());
777        }
778    }
779    let failed: Vec<([u8; 32], String)> = failed_map.into_iter().collect();
780    let failed_count = failed.len();
781    Error::PartialUpload {
782        stored: stored_addresses,
783        stored_count,
784        failed,
785        failed_count,
786        total_chunks,
787        spend: Box::new(spend),
788        reason,
789    }
790}
791
792/// Require every sub-batch of a *resumable* merkle finalize to be paid.
793///
794/// A [`MerkleFinalizeResume`] re-drives storage against the proofs folded at
795/// finalize time and accepts no new payment material, so a chunk whose
796/// sub-batch was never paid could never acquire a proof on resume: every
797/// [`Client::finalize_resume`] call would report it as missing-proof again and
798/// the handle would never drain to [`FinalizeOutcome::Complete`]. Rejecting
799/// partial payment up front keeps resume handles always drainable. A caller
800/// that intends to pay only some sub-batches must use the non-resumable
801/// [`Client::finalize_upload_merkle_multi`], which surfaces the unpaid chunks
802/// through [`Error::PartialUpload`] (ADR-0003).
803fn require_fully_paid_for_resumable(winner_pool_hashes: &[Option<[u8; 32]>]) -> Result<()> {
804    let unpaid = winner_pool_hashes.iter().filter(|h| h.is_none()).count();
805    if unpaid > 0 {
806        return Err(Error::Payment(format!(
807            "{unpaid}/{} sub-batch(es) unpaid: the resumable finalize requires every \
808             sub-batch to be paid, because a resume handle cannot acquire proofs for \
809             unpaid chunks and would never drain to Complete. Pay every sub-batch, or \
810             use finalize_upload_merkle_multi() to finalize a partial payment (its \
811             unpaid chunks are reported through PartialUpload).",
812            winner_pool_hashes.len()
813        )));
814    }
815    Ok(())
816}
817
818/// Fold the per-batch winner hashes of an external merkle upload into one
819/// combined payment receipt.
820///
821/// Validates that `winner_pool_hashes` aligns with `prepared_batches` (one
822/// entry per batch, in order), requires at least one paid batch, finalizes
823/// each paid batch, and merges the receipts the way the wallet path folds
824/// its sub-batch payments. Unpaid (`None`) batches contribute no proofs, so
825/// the store phase reports their chunks through [`Error::PartialUpload`]
826/// (ADR-0003) — the resumable path rejects them up front instead
827/// ([`require_fully_paid_for_resumable`]).
828fn fold_external_merkle_payments(
829    prepared_batches: Vec<PreparedMerkleBatch>,
830    winner_pool_hashes: Vec<Option<[u8; 32]>>,
831) -> Result<MerkleBatchPaymentResult> {
832    let batch_count = prepared_batches.len();
833    if winner_pool_hashes.len() != batch_count {
834        return Err(Error::Payment(format!(
835            "Expected {batch_count} winner pool hash entries (one per \
836             prepared sub-batch), got {}.",
837            winner_pool_hashes.len()
838        )));
839    }
840
841    let mut paid = Vec::with_capacity(batch_count);
842    let mut unpaid_batches = 0usize;
843    for (batch, hash) in prepared_batches.into_iter().zip(winner_pool_hashes) {
844        match hash {
845            Some(h) => paid.push(finalize_merkle_batch(batch, h)?),
846            None => unpaid_batches += 1,
847        }
848    }
849    if paid.is_empty() {
850        return Err(Error::Payment(
851            "No merkle sub-batch was paid — nothing to finalize. \
852             Pay at least one batch or drop the prepared upload."
853                .to_string(),
854        ));
855    }
856    if unpaid_batches > 0 {
857        warn!(
858            "External merkle finalize: {unpaid_batches}/{batch_count} sub-batch(es) \
859             unpaid; their chunks will be reported as failed"
860        );
861    }
862    Ok(merge_merkle_batch_results(paid))
863}
864
865/// Assemble the outcome of one external-signer merkle store pass into
866/// [`FinalizeOutcome`]. Pure (no `self`/network) so the resume-handoff contract
867/// is unit-testable.
868///
869/// `Ok` from the store becomes [`FinalizeOutcome::Complete`]. A recoverable
870/// [`Error::PartialUpload`] becomes [`FinalizeOutcome::Partial`], moving the
871/// retained spill and proofs into a [`MerkleFinalizeResume`] whose
872/// `unstored_addresses` are the failed chunks (to store next) and whose
873/// `stored_addresses` is the cumulative stored set (carried forward as the next
874/// attempt's already-stored input). Any other error is fatal and propagates
875/// unchanged.
876fn assemble_merkle_finalize_outcome(
877    store_result: Result<(usize, String, u128, WaveAggregateStats)>,
878    data_map: DataMap,
879    data_map_address: Option<[u8; 32]>,
880    total_chunks: usize,
881    chunk_store: ExternalChunkStore,
882    batch_result: MerkleBatchPaymentResult,
883) -> Result<FinalizeOutcome> {
884    match store_result {
885        Ok((chunks_stored, _storage_cost, _gas_cost, stats)) => {
886            info!("External-signer merkle upload finalized: {chunks_stored} chunks stored");
887            Ok(FinalizeOutcome::Complete(FileUploadResult {
888                data_map,
889                chunks_stored,
890                chunks_failed: 0,
891                total_chunks,
892                payment_mode_used: PaymentMode::Merkle,
893                // The external signer pays on-chain out-of-band, so the spend
894                // is unknown to the library here.
895                storage_cost_atto: "0".into(),
896                gas_cost_wei: 0,
897                data_map_address,
898                chunk_attempts_total: stats.chunk_attempts_total,
899                store_durations_ms: stats.store_durations_ms,
900                retries_histogram: stats.retries_histogram,
901            }))
902        }
903        Err(Error::PartialUpload {
904            stored,
905            stored_count,
906            failed,
907            failed_count,
908            spend,
909            ..
910        }) => {
911            // Recoverable: retain the spill and the already-signed proofs so the
912            // caller can drain the remainder against the same payment.
913            let unstored_addresses: Vec<[u8; 32]> = failed.iter().map(|(addr, _)| *addr).collect();
914            let result = FileUploadResult {
915                data_map: data_map.clone(),
916                chunks_stored: stored_count,
917                chunks_failed: failed_count,
918                total_chunks,
919                payment_mode_used: PaymentMode::Merkle,
920                storage_cost_atto: spend.storage_cost_atto.clone(),
921                gas_cost_wei: spend.gas_cost_wei,
922                data_map_address,
923                // Per-attempt store telemetry is not carried on a partial.
924                chunk_attempts_total: 0,
925                store_durations_ms: Vec::new(),
926                retries_histogram: [0; 4],
927            };
928            let resume = MerkleFinalizeResume {
929                data_map,
930                data_map_address,
931                total_chunks,
932                chunk_store,
933                unstored_addresses,
934                batch_result,
935                // Cumulative stored set (already-stored + stored this pass),
936                // carried forward as the next attempt's already-stored input.
937                stored_addresses: stored,
938            };
939            Ok(FinalizeOutcome::Partial {
940                result,
941                resume: FinalizeResume::Merkle(Box::new(resume)),
942            })
943        }
944        Err(e) => Err(e),
945    }
946}
947
948/// Assemble the outcome of one wave-batch external store pass into
949/// [`FinalizeOutcome`]. Pure (no `self`/network) so the resume-handoff contract
950/// is unit-testable.
951///
952/// `retained` maps every paid chunk's address to its [`PaidChunk`] (body +
953/// proof + PUT targets). If [`WaveResult`] reports no failures the result is
954/// [`FinalizeOutcome::Complete`]; otherwise the failed chunks' [`PaidChunk`]s
955/// are pulled out of `retained` into a [`WaveFinalizeResume`] so the caller can
956/// re-store just those against the same payment — the store never returns an
957/// `Err` for a partial, so this function is infallible.
958fn assemble_wave_finalize_outcome(
959    wave_result: WaveResult,
960    mut retained: HashMap<[u8; 32], PaidChunk>,
961    data_map: DataMap,
962    data_map_address: Option<[u8; 32]>,
963    total_chunks: usize,
964    already_stored_count: usize,
965    storage_cost_atto: String,
966) -> FinalizeOutcome {
967    let stored_count = already_stored_count + wave_result.stored.len();
968    if wave_result.failed.is_empty() {
969        info!("External-signer upload finalized: {stored_count} chunks stored");
970        let mut stats = WaveAggregateStats::default();
971        stats.absorb(&wave_result);
972        return FinalizeOutcome::Complete(FileUploadResult {
973            data_map,
974            chunks_stored: stored_count,
975            chunks_failed: 0,
976            total_chunks,
977            payment_mode_used: PaymentMode::Single,
978            // Storage spend is known from the payment intent; gas is paid by the
979            // external signer out-of-band (unknown here).
980            storage_cost_atto,
981            gas_cost_wei: 0,
982            data_map_address,
983            chunk_attempts_total: stats.chunk_attempts_total,
984            store_durations_ms: stats.store_durations_ms,
985            retries_histogram: stats.retries_histogram,
986        });
987    }
988
989    // Recoverable: pull the already-paid chunks that still need storing back out
990    // so the caller can re-store them against the same payment.
991    let failed_count = wave_result.failed.len();
992    let failed_paid_chunks: Vec<PaidChunk> = wave_result
993        .failed
994        .iter()
995        .filter_map(|(addr, _)| retained.remove(addr))
996        .collect();
997    let result = FileUploadResult {
998        data_map: data_map.clone(),
999        chunks_stored: stored_count,
1000        chunks_failed: failed_count,
1001        total_chunks,
1002        payment_mode_used: PaymentMode::Single,
1003        storage_cost_atto: storage_cost_atto.clone(),
1004        gas_cost_wei: 0,
1005        data_map_address,
1006        // Per-attempt store telemetry is not carried on a partial.
1007        chunk_attempts_total: 0,
1008        store_durations_ms: Vec::new(),
1009        retries_histogram: [0; 4],
1010    };
1011    let resume = WaveFinalizeResume {
1012        data_map,
1013        data_map_address,
1014        total_chunks,
1015        stored_count,
1016        failed_paid_chunks,
1017        storage_cost_atto,
1018    };
1019    FinalizeOutcome::Partial {
1020        result,
1021        resume: FinalizeResume::Wave(Box::new(resume)),
1022    }
1023}
1024
1025/// One wave's contribution to a single-node upload, distilled from its
1026/// `batch_upload_chunks_with_events` result.
1027#[derive(Debug)]
1028struct SingleWaveOutcome {
1029    /// Addresses confirmed stored in this wave.
1030    stored: Vec<[u8; 32]>,
1031    /// Chunks that failed after retries in this wave.
1032    failed: Vec<([u8; 32], String)>,
1033    /// Storage cost paid on-chain for this wave, in atto-tokens.
1034    storage_atto: Amount,
1035    /// Gas paid on-chain for this wave, in wei.
1036    gas_wei: u128,
1037    /// Per-wave store/retry statistics. Empty for a quorum-short wave, whose
1038    /// `PartialUpload` carries no stats.
1039    stats: WaveAggregateStats,
1040}
1041
1042/// Fold one wave's batch-upload result for the single-node path.
1043///
1044/// A `PartialUpload` (chunks short of quorum after retries) is **recoverable**:
1045/// its stored/failed chunks and on-chain spend are returned so the caller
1046/// records them and continues to the next wave, making the file make maximum
1047/// progress exactly like `upload_merkle_from_spill`. Every other error is **fatal**
1048/// (wallet/payment-infrastructure failures, missing proofs, spill reads) and is
1049/// returned via `Err` to abort the file. Because `UPLOAD_WAVE_SIZE ==
1050/// PAYMENT_WAVE_SIZE`, each batch call is exactly one payment wave, so folding a
1051/// `PartialUpload` leaves nothing un-attempted within the wave.
1052fn fold_single_wave(
1053    result: Result<(Vec<[u8; 32]>, String, u128, WaveAggregateStats)>,
1054) -> Result<SingleWaveOutcome> {
1055    match result {
1056        Ok((stored, storage, gas, stats)) => Ok(SingleWaveOutcome {
1057            stored,
1058            failed: Vec::new(),
1059            storage_atto: storage.parse().unwrap_or(Amount::ZERO),
1060            gas_wei: gas,
1061            stats,
1062        }),
1063        Err(Error::PartialUpload {
1064            stored,
1065            failed,
1066            spend,
1067            ..
1068        }) => Ok(SingleWaveOutcome {
1069            stored,
1070            failed,
1071            storage_atto: spend.storage_cost_atto.parse().unwrap_or(Amount::ZERO),
1072            gas_wei: spend.gas_cost_wei,
1073            stats: WaveAggregateStats::default(),
1074        }),
1075        Err(e) => Err(e),
1076    }
1077}
1078
1079/// Shape a corroborated settlement refusal that landed on a wave **after** an
1080/// earlier wave had already paid and stored.
1081///
1082/// The refusal is terminal for this build, but by wave two or later it is no
1083/// longer true that nothing was charged: the single-node path pays each wave
1084/// before storing it, so the earlier waves' spend has settled on-chain. Bare
1085/// `ClientUpdateRequired` would report the upload as costing nothing and drop
1086/// the stored set a resume needs, so the refusal is surfaced as a
1087/// `PartialUpload` carrying the real spend and the stored chunks, with the
1088/// storer's upgrade instruction kept in the reason. Every remaining chunk is
1089/// listed as failed: none of it was quoted, let alone paid for.
1090#[allow(clippy::too_many_arguments)]
1091fn settlement_refusal_after_paid_waves(
1092    refusal: &str,
1093    wave_num: usize,
1094    wave_count: usize,
1095    stored_addresses: Vec<[u8; 32]>,
1096    total_stored: usize,
1097    remaining: &[[u8; 32]],
1098    total_chunks: usize,
1099    total_storage: Amount,
1100    total_gas: u128,
1101) -> Error {
1102    let remaining_count = remaining.len();
1103    let refused_note = format!(
1104        "not quoted: storers refused this client's settlement version at wave \
1105         {wave_num}/{wave_count}"
1106    );
1107    let failed: Vec<([u8; 32], String)> = remaining
1108        .iter()
1109        .map(|addr| (*addr, refused_note.clone()))
1110        .collect();
1111    Error::PartialUpload {
1112        stored: stored_addresses,
1113        stored_count: total_stored,
1114        failed,
1115        failed_count: remaining_count,
1116        total_chunks,
1117        spend: Box::new(PartialUploadSpend {
1118            storage_cost_atto: total_storage.to_string(),
1119            gas_cost_wei: total_gas,
1120        }),
1121        reason: format!(
1122            "storers refused this client's settlement version at wave {wave_num}/{wave_count}: \
1123             the {total_stored} chunk(s) in earlier wave(s) were already paid for and stored, \
1124             and the remaining {remaining_count} chunk(s) were neither quoted nor paid. {refusal}"
1125        ),
1126    }
1127}
1128
1129/// Check that the spill directory has enough free space for the spilled chunks.
1130///
1131/// `file_size` is the source file's byte count. We require
1132/// `file_size + 10%` free space to account for self-encryption overhead.
1133fn check_disk_space_for_spill(file_size: u64) -> Result<()> {
1134    let spill_root = ChunkSpill::spill_root()?;
1135
1136    // Ensure the root exists so fs2 can query it.
1137    std::fs::create_dir_all(&spill_root)?;
1138
1139    let available = fs2::available_space(&spill_root).map_err(|e| {
1140        Error::Io(std::io::Error::new(
1141            e.kind(),
1142            format!(
1143                "failed to query disk space on {}: {e}",
1144                spill_root.display()
1145            ),
1146        ))
1147    })?;
1148
1149    // Use integer arithmetic to avoid f64 precision loss on large file sizes.
1150    let headroom = file_size / DISK_SPACE_HEADROOM_PERCENT;
1151    let required = file_size.saturating_add(headroom);
1152
1153    if available < required {
1154        let avail_mb = available / (1024 * 1024);
1155        let req_mb = required / (1024 * 1024);
1156        return Err(Error::InsufficientDiskSpace(format!(
1157            "need ~{req_mb} MB in spill dir ({}) but only {avail_mb} MB available",
1158            spill_root.display()
1159        )));
1160    }
1161
1162    debug!(
1163        "Disk space check passed: {available} bytes available, {required} bytes required (spill: {})",
1164        spill_root.display()
1165    );
1166    Ok(())
1167}
1168
1169fn usable_memory_bytes() -> Option<u64> {
1170    let mut system = sysinfo::System::new();
1171    system.refresh_memory();
1172
1173    let available_memory = system.available_memory();
1174    let free_memory = system.free_memory();
1175    let used_memory = system.used_memory();
1176    let total_memory = system.total_memory();
1177    let unused_memory = total_memory.saturating_sub(used_memory);
1178
1179    let mut usable = [available_memory, free_memory, unused_memory]
1180        .into_iter()
1181        .filter(|bytes| *bytes > 0)
1182        .max();
1183
1184    let cgroup_free_memory = system
1185        .cgroup_limits()
1186        .filter(|limits| limits.total_memory > 0)
1187        .map(|limits| limits.free_memory);
1188    if let Some(cgroup_free_memory) = cgroup_free_memory {
1189        usable = Some(usable.unwrap_or(u64::MAX).min(cgroup_free_memory));
1190    }
1191
1192    debug!(
1193        available_memory,
1194        free_memory,
1195        used_memory,
1196        total_memory,
1197        cgroup_free_memory,
1198        usable_memory = ?usable,
1199        "Detected usable memory for stream decrypt batch sizing"
1200    );
1201
1202    usable
1203}
1204
1205fn stream_decrypt_batch_memory_cap(usable_memory_bytes: u64) -> usize {
1206    let budget = usable_memory_bytes / DOWNLOAD_STREAM_BATCH_MEMORY_BUDGET_DIVISOR;
1207    let estimated_bytes_per_chunk = (self_encryption::MAX_CHUNK_SIZE as u64)
1208        .saturating_mul(DOWNLOAD_STREAM_BATCH_BYTES_PER_CHUNK_MULTIPLIER)
1209        .max(1);
1210    let cap = (budget / estimated_bytes_per_chunk).max(1);
1211
1212    usize::try_from(cap).unwrap_or(usize::MAX)
1213}
1214
1215fn adaptive_stream_decrypt_batch_size(
1216    total_chunks: usize,
1217    fetch_cap: usize,
1218    configured_batch_floor: usize,
1219    usable_memory_bytes: Option<u64>,
1220) -> usize {
1221    let fetch_target = fetch_cap
1222        .max(1)
1223        .saturating_mul(DOWNLOAD_STREAM_BATCH_FETCH_MULTIPLIER);
1224    let requested = match usable_memory_bytes {
1225        Some(bytes) => {
1226            let memory_cap = stream_decrypt_batch_memory_cap(bytes);
1227            configured_batch_floor
1228                .max(fetch_target)
1229                .max(1)
1230                .min(memory_cap)
1231        }
1232        None => configured_batch_floor.max(1),
1233    };
1234
1235    requested.min(total_chunks.max(1)).max(1)
1236}
1237
1238/// Whether the data map is published to the network for address-based retrieval.
1239///
1240/// A private upload stores only the data chunks and returns the `DataMap` to
1241/// the caller — only someone holding that `DataMap` can reconstruct the file.
1242/// A public upload additionally stores the serialized `DataMap` as a chunk on
1243/// the network, yielding a single chunk address that anyone can use to
1244/// retrieve the `DataMap` (via [`Client::data_map_fetch`]) and then the file.
1245#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1246pub enum Visibility {
1247    /// Keep the data map local; only the holder can retrieve the file.
1248    #[default]
1249    Private,
1250    /// Publish the data map as a network chunk so anyone with the returned
1251    /// address can retrieve and decrypt the file.
1252    Public,
1253}
1254
1255/// Confidence attached to an [`UploadCostEstimate`]'s `storage_cost_atto`.
1256///
1257/// `estimate_upload_cost` prices a file by sampling a few of its chunk
1258/// addresses and extrapolating. When every sampled chunk is already stored
1259/// there is no live price to extrapolate from, so a `"0"` cost can mean either
1260/// "provably free" (the whole file was sampled) or only "probably free" (the
1261/// tail was unsampled). This lets callers tell those apart instead of treating
1262/// every `"0"` as unconditionally free.
1263#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
1264#[serde(rename_all = "snake_case")]
1265pub enum CostEstimateConfidence {
1266    /// At least one sampled chunk returned a live quote; `storage_cost_atto`
1267    /// is extrapolated from a real per-chunk price. The normal case.
1268    #[default]
1269    PricedSample,
1270    /// Every chunk in the file was sampled and every one was already stored.
1271    /// `storage_cost_atto` is exactly `"0"` — the upload is genuinely free.
1272    VerifiedAllAlreadyStored,
1273    /// Every *sampled* chunk was already stored, but not all chunks were
1274    /// sampled. `storage_cost_atto` is `"0"` as a best-effort guess; the real
1275    /// upload reconciles the true cost at payment time. Render this as "likely
1276    /// already stored", not a guaranteed-free price.
1277    AllSamplesAlreadyStoredIncomplete,
1278}
1279
1280/// Estimated cost of uploading a file, returned by
1281/// [`Client::estimate_upload_cost`].
1282///
1283/// Marked `#[non_exhaustive]` so adding a field later is not a breaking change
1284/// for downstream consumers that construct or pattern-match on this struct.
1285#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1286#[non_exhaustive]
1287pub struct UploadCostEstimate {
1288    /// Original file size in bytes.
1289    pub file_size: u64,
1290    /// Number of chunks the file would be split into (data chunks only,
1291    /// does not include the DataMap chunk added during public uploads).
1292    pub chunk_count: usize,
1293    /// Estimated total storage cost in atto (token smallest unit).
1294    pub storage_cost_atto: String,
1295    /// Estimated gas cost in wei as a string. This is a rough heuristic
1296    /// based on chunk count and payment mode, NOT a live gas price query.
1297    pub estimated_gas_cost_wei: String,
1298    /// Payment mode that would be used.
1299    pub payment_mode: PaymentMode,
1300    /// How much to trust `storage_cost_atto`. See [`CostEstimateConfidence`].
1301    #[serde(default)]
1302    pub confidence: CostEstimateConfidence,
1303}
1304
1305/// Result of a file upload: the `DataMap` needed to retrieve the file.
1306///
1307/// Marked `#[non_exhaustive]` so adding a new field in future is not a
1308/// breaking change for downstream consumers that construct or pattern-match
1309/// on this struct.
1310#[derive(Debug, Clone)]
1311#[non_exhaustive]
1312pub struct FileUploadResult {
1313    /// The data map containing chunk metadata for reconstruction.
1314    pub data_map: DataMap,
1315    /// Number of chunks stored on the network.
1316    pub chunks_stored: usize,
1317    /// Number of chunks that failed to store. Always 0 for a successful
1318    /// upload — partial-failure information is conveyed via
1319    /// [`crate::data::Error::PartialUpload`] instead.
1320    pub chunks_failed: usize,
1321    /// Total number of chunks in the upload, including chunks that were
1322    /// already stored and skipped. On full success this equals `chunks_stored`.
1323    pub total_chunks: usize,
1324    /// Which payment mode was actually used (not just requested).
1325    pub payment_mode_used: PaymentMode,
1326    /// Total storage cost paid in token units (atto). "0" if all chunks already existed.
1327    pub storage_cost_atto: String,
1328    /// Total gas cost in wei. 0 if no on-chain transactions were made.
1329    pub gas_cost_wei: u128,
1330    /// Chunk address of the serialized `DataMap`, set only for
1331    /// [`Visibility::Public`] uploads. **`Some` means this address is
1332    /// retrievable from the network (via [`Client::data_map_fetch`])**, not
1333    /// necessarily that *this* upload paid to store it — if the serialized
1334    /// `DataMap` hashed to a chunk that was already on the network (same
1335    /// file uploaded before; deterministic via self-encryption), the address
1336    /// is still returned but no storage payment was made for it.
1337    pub data_map_address: Option<[u8; 32]>,
1338    /// Sum of chunk-store RPC attempts across the upload
1339    /// (`>= chunks_stored` on full success; more if any chunk retried).
1340    /// `0` for paths that don't run the wave store loop.
1341    pub chunk_attempts_total: usize,
1342    /// Per-chunk store wall-clock in ms (length == `chunks_stored` on full
1343    /// success, empty for paths that don't run the wave store loop).
1344    pub store_durations_ms: Vec<u64>,
1345    /// Count of stored chunks that succeeded on each retry round
1346    /// (index 0 = first attempt, 1 = first retry, etc.). All zeros for
1347    /// paths that don't run the wave store loop.
1348    pub retries_histogram: [usize; 4],
1349}
1350
1351/// Payment information for external signing — either wave-batch or merkle.
1352// ADR-0004 added the signed commitment fields (`committed_key_count`,
1353// `commitment_pin`) to the merkle candidate quotes carried inside
1354// `PreparedMerkleBatch`, which grew the `Merkle` variant past the
1355// `large_enum_variant` threshold. This enum is constructed one-off per payment
1356// (never held in bulk collections), so the size delta is harmless; allow it
1357// rather than box a field on the security-sensitive merkle-finalize path.
1358#[allow(clippy::large_enum_variant)]
1359#[derive(Debug)]
1360pub enum ExternalPaymentInfo {
1361    /// Wave-batch: individual (quote_hash, rewards_address, amount) tuples.
1362    WaveBatch {
1363        /// Chunks ready for payment (needed for finalize).
1364        prepared_chunks: Vec<PreparedChunk>,
1365        /// Payment intent for external signing.
1366        payment_intent: PaymentIntent,
1367    },
1368    /// Merkle: one on-chain payment call per prepared sub-batch.
1369    Merkle {
1370        /// The prepared merkle sub-batches, in address order (public fields
1371        /// sent to the frontend, private fields stay in Rust). The external
1372        /// signer submits one `payForMerkleTree` transaction per batch;
1373        /// finalize takes one winner hash per batch in the same order
1374        /// (ADR-0003). A fresh upload below `MAX_LEAVES` chunks prepares as
1375        /// exactly one batch, so single-payment consumers keep working
1376        /// until they exceed it.
1377        prepared_batches: Vec<PreparedMerkleBatch>,
1378        /// Bodies of the chunks that still need upload, held in the
1379        /// encryption spill on disk — NOT resident in memory (ADR-0003).
1380        chunk_store: ExternalChunkStore,
1381        /// Chunk addresses that still need upload after the preflight check.
1382        chunk_addresses: Vec<[u8; 32]>,
1383    },
1384}
1385
1386/// Opaque on-disk store of the chunk bodies carried by a prepared external
1387/// merkle upload.
1388///
1389/// Wraps the encryption spill: bodies stay on disk from prepare until
1390/// finalize reads them back ≤ store-cap at a time, so peak RAM for the
1391/// external path matches the wallet path's ~256 MB bound instead of the file
1392/// size (ADR-0003). The spill directory lives exactly as long as this value:
1393/// dropping the `PreparedUpload` (e.g. a consumer's session TTL expiring or
1394/// an explicit cancel) removes it from disk.
1395pub struct ExternalChunkStore(ChunkSpill);
1396
1397impl ExternalChunkStore {
1398    fn from_spill(spill: ChunkSpill) -> Self {
1399        Self(spill)
1400    }
1401
1402    fn spill(&self) -> &ChunkSpill {
1403        &self.0
1404    }
1405}
1406
1407impl std::fmt::Debug for ExternalChunkStore {
1408    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1409        f.debug_struct("ExternalChunkStore")
1410            .field("chunks", &self.0.len())
1411            .field("bytes", &self.0.total_bytes())
1412            .finish()
1413    }
1414}
1415
1416/// Prepared upload ready for external payment.
1417///
1418/// Contains everything needed to construct the on-chain payment transaction
1419/// externally (e.g. via WalletConnect in a desktop app) and then finalize
1420/// the upload without a Rust-side wallet.
1421///
1422/// Note: This struct stays in Rust memory — only the public fields of
1423/// `payment_info` are sent to the frontend. `PreparedChunk` contains
1424/// non-serializable network types, so the full struct cannot derive `Serialize`.
1425///
1426/// Marked `#[non_exhaustive]` so adding a new field in future is not a
1427/// breaking change for downstream consumers.
1428#[derive(Debug)]
1429#[non_exhaustive]
1430pub struct PreparedUpload {
1431    /// The data map for later retrieval.
1432    pub data_map: DataMap,
1433    /// Payment information for chunks that still need payment after the
1434    /// already-stored preflight. This may be wave-batch even when the original
1435    /// chunk count was merkle-eligible if the remaining count is below the
1436    /// merkle threshold.
1437    pub payment_info: ExternalPaymentInfo,
1438    /// Chunk address of the serialized `DataMap` when this upload was
1439    /// prepared with [`Visibility::Public`]. `Some` means the address is
1440    /// retrievable on the network after finalization — either because this
1441    /// upload paid to store the chunk in `payment_info`, or because the
1442    /// chunk was already on the network (deterministic self-encryption).
1443    /// Carried through to [`FileUploadResult::data_map_address`].
1444    pub data_map_address: Option<[u8; 32]>,
1445    /// Chunk addresses already present on the network when this upload was
1446    /// prepared. These do not require payment or PUT during finalization.
1447    pub already_stored_addresses: Vec<[u8; 32]>,
1448    /// Total chunk count for the upload, including already-stored chunks.
1449    pub total_chunks: usize,
1450}
1451
1452/// Outcome of a resumable external-signer finalize
1453/// ([`Client::finalize_upload_resumable`] /
1454/// [`Client::finalize_upload_merkle_multi_resumable`] /
1455/// [`Client::finalize_resume`]).
1456///
1457/// `Complete` means every chunk is stored. `Partial` means some chunks are
1458/// still unstored after retries — short of quorum, or cut off by a store
1459/// abort; its [`FinalizeResume`] handle owns the retained payment material, so
1460/// the caller can store the remainder against the **same** on-chain payment
1461/// without re-quoting or re-signing (issue #140). Persistent store failures
1462/// also surface as `Partial`, so loops that retry a handle must bound their
1463/// attempts (see [`Client::finalize_resume`]).
1464#[derive(Debug)]
1465pub enum FinalizeOutcome {
1466    /// All chunks stored; the file is fully retrievable.
1467    Complete(FileUploadResult),
1468    /// Some chunks remain unstored after retries.
1469    Partial {
1470        /// Progress snapshot for this attempt (stored/failed counts, on-chain
1471        /// spend, `data_map_address`). Per-attempt store telemetry
1472        /// (`chunk_attempts_total`, `store_durations_ms`, `retries_histogram`)
1473        /// is not carried on a partial and reads as empty/zero.
1474        result: FileUploadResult,
1475        /// Hand back to [`Client::finalize_resume`] to store the still-unstored
1476        /// chunks against the same payment.
1477        resume: FinalizeResume,
1478    },
1479}
1480
1481/// Opaque handle to resume an external-signer finalize that stored some but not
1482/// all chunks after retries, carrying the material needed to store the
1483/// remainder against the original, already-signed payment — no new quote, no
1484/// second signature, no double payment (issue #140).
1485///
1486/// One variant per external payment path; a caller obtains it from
1487/// [`FinalizeOutcome::Partial`] and passes it back to [`Client::finalize_resume`]
1488/// without needing to know which path produced it. Boxed variants keep the enum
1489/// small. Dropping it abandons the upload (the wave path frees its retained
1490/// chunk bodies; the merkle path removes its spill directory from disk).
1491#[derive(Debug)]
1492#[non_exhaustive]
1493pub enum FinalizeResume {
1494    /// Resume a wave-batch (single-payment) external finalize.
1495    Wave(Box<WaveFinalizeResume>),
1496    /// Resume a merkle (multi-batch) external finalize.
1497    Merkle(Box<MerkleFinalizeResume>),
1498}
1499
1500/// Opaque handle to resume a wave-batch external finalize that stored some but
1501/// not all chunks after retries.
1502///
1503/// Owns the already-paid [`PaidChunk`]s (body + payment proof + PUT targets)
1504/// that still need storing; re-storing reuses those proofs, so the same
1505/// on-chain payment is honoured without re-signing. Dropping it frees the
1506/// retained chunk bodies (the upload is abandoned).
1507///
1508/// `#[non_exhaustive]` so future fields are not a breaking change. `Debug` is
1509/// redacted to counts only — it never prints chunk bodies, proofs, or the data
1510/// map.
1511#[non_exhaustive]
1512pub struct WaveFinalizeResume {
1513    data_map: DataMap,
1514    data_map_address: Option<[u8; 32]>,
1515    total_chunks: usize,
1516    stored_count: usize,
1517    failed_paid_chunks: Vec<PaidChunk>,
1518    storage_cost_atto: String,
1519}
1520
1521impl std::fmt::Debug for WaveFinalizeResume {
1522    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1523        f.debug_struct("WaveFinalizeResume")
1524            .field("total_chunks", &self.total_chunks)
1525            .field("stored", &self.stored_count)
1526            .field("unstored", &self.failed_paid_chunks.len())
1527            .field("public", &self.data_map_address.is_some())
1528            .finish_non_exhaustive()
1529    }
1530}
1531
1532/// Opaque handle to resume an external-signer merkle finalize that stored some
1533/// but not all chunks after retries.
1534///
1535/// Owns the on-disk chunk spill and the merkle proofs from the original,
1536/// already-signed payment, plus the addresses still to store. Passing it to
1537/// [`Client::finalize_resume`] re-drives storage for only those chunks — no new
1538/// quote, no second signature, no double payment (issue #140). Dropping it
1539/// removes the spill directory from disk (the upload is abandoned).
1540///
1541/// `#[non_exhaustive]` so future fields are not a breaking change. `Debug` is
1542/// redacted to counts only — it never prints chunk bodies, the data map, or
1543/// merkle proof material.
1544#[non_exhaustive]
1545pub struct MerkleFinalizeResume {
1546    data_map: DataMap,
1547    data_map_address: Option<[u8; 32]>,
1548    total_chunks: usize,
1549    chunk_store: ExternalChunkStore,
1550    unstored_addresses: Vec<[u8; 32]>,
1551    batch_result: MerkleBatchPaymentResult,
1552    stored_addresses: Vec<[u8; 32]>,
1553}
1554
1555impl std::fmt::Debug for MerkleFinalizeResume {
1556    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1557        f.debug_struct("MerkleFinalizeResume")
1558            .field("total_chunks", &self.total_chunks)
1559            .field("stored", &self.stored_addresses.len())
1560            .field("unstored", &self.unstored_addresses.len())
1561            .field("public", &self.data_map_address.is_some())
1562            .finish_non_exhaustive()
1563    }
1564}
1565
1566/// Return type for [`spawn_file_encryption`]: chunk receiver, `DataMap` oneshot, join handle.
1567type EncryptionChannels = (
1568    tokio::sync::mpsc::Receiver<Bytes>,
1569    tokio::sync::oneshot::Receiver<DataMap>,
1570    tokio::task::JoinHandle<Result<()>>,
1571);
1572
1573/// Spawn a blocking task that streams file encryption through a channel.
1574fn spawn_file_encryption(path: PathBuf) -> Result<EncryptionChannels> {
1575    let metadata = std::fs::metadata(&path)?;
1576    let data_size = usize::try_from(metadata.len())
1577        .map_err(|e| Error::Encryption(format!("file size exceeds platform usize: {e}")))?;
1578
1579    let (chunk_tx, chunk_rx) = tokio::sync::mpsc::channel(2);
1580    let (datamap_tx, datamap_rx) = tokio::sync::oneshot::channel();
1581
1582    let handle = tokio::task::spawn_blocking(move || {
1583        let file = std::fs::File::open(&path)?;
1584        let mut reader = std::io::BufReader::new(file);
1585
1586        let read_error: Arc<Mutex<Option<std::io::Error>>> = Arc::new(Mutex::new(None));
1587        let read_error_clone = Arc::clone(&read_error);
1588
1589        let data_iter = std::iter::from_fn(move || {
1590            let mut buffer = vec![0u8; 8192];
1591            match std::io::Read::read(&mut reader, &mut buffer) {
1592                Ok(0) => None,
1593                Ok(n) => {
1594                    buffer.truncate(n);
1595                    Some(Bytes::from(buffer))
1596                }
1597                Err(e) => {
1598                    let mut guard = read_error_clone
1599                        .lock()
1600                        .unwrap_or_else(|poisoned| poisoned.into_inner());
1601                    *guard = Some(e);
1602                    None
1603                }
1604            }
1605        });
1606
1607        let mut stream = stream_encrypt(data_size, data_iter)
1608            .map_err(|e| Error::Encryption(format!("stream_encrypt failed: {e}")))?;
1609
1610        for chunk_result in stream.chunks() {
1611            // Check for captured read errors immediately after each chunk.
1612            // stream_encrypt sees None (EOF) when a read fails, so it stops
1613            // producing chunks. We must detect this before sending the
1614            // partial results to avoid uploading a truncated DataMap.
1615            {
1616                let guard = read_error
1617                    .lock()
1618                    .unwrap_or_else(|poisoned| poisoned.into_inner());
1619                if let Some(ref e) = *guard {
1620                    return Err(Error::Io(std::io::Error::new(e.kind(), e.to_string())));
1621                }
1622            }
1623
1624            let (_hash, content) = chunk_result
1625                .map_err(|e| Error::Encryption(format!("chunk encryption failed: {e}")))?;
1626            if chunk_tx.blocking_send(content).is_err() {
1627                return Err(Error::Encryption("upload receiver dropped".to_string()));
1628            }
1629        }
1630
1631        // Final check: read error after last chunk (stream saw EOF).
1632        {
1633            let guard = read_error
1634                .lock()
1635                .unwrap_or_else(|poisoned| poisoned.into_inner());
1636            if let Some(ref e) = *guard {
1637                return Err(Error::Io(std::io::Error::new(e.kind(), e.to_string())));
1638            }
1639        }
1640
1641        let datamap = stream
1642            .into_datamap()
1643            .ok_or_else(|| Error::Encryption("no DataMap after encryption".to_string()))?;
1644        if datamap_tx.send(datamap).is_err() {
1645            warn!("DataMap receiver dropped — upload may have been cancelled");
1646        }
1647        Ok(())
1648    });
1649
1650    Ok((chunk_rx, datamap_rx, handle))
1651}
1652
1653/// RAII guard for the staging temp file used during a disk download.
1654///
1655/// Removes the file on drop — including a panic unwind out of the
1656/// `block_in_place` decrypt loop — unless [`commit`](Self::commit) has
1657/// promoted it to its final path. Centralizes the cleanup the explicit error
1658/// arms used to repeat.
1659struct TempDownload {
1660    /// `Some` while the staging file may need cleanup; `None` once committed.
1661    path: Option<PathBuf>,
1662}
1663
1664impl TempDownload {
1665    fn new(path: PathBuf) -> Self {
1666        Self { path: Some(path) }
1667    }
1668
1669    /// Path of the staging file (valid until `commit`).
1670    fn path(&self) -> &Path {
1671        self.path
1672            .as_deref()
1673            .expect("TempDownload::path called after commit")
1674    }
1675
1676    /// Rename the staged file to `dest`. On success the guard is defused so
1677    /// `Drop` is a no-op; on failure the guard stays armed and `Drop` removes
1678    /// the orphaned temp file.
1679    fn commit(mut self, dest: &Path) -> std::io::Result<()> {
1680        std::fs::rename(self.path(), dest)?; // err → guard armed → Drop cleans up
1681        self.path = None; // success → nothing left to clean
1682        Ok(())
1683    }
1684}
1685
1686impl Drop for TempDownload {
1687    fn drop(&mut self) {
1688        if let Some(path) = self.path.take() {
1689            if let Err(e) = std::fs::remove_file(&path) {
1690                // Absent file is fine (never created / already gone).
1691                if e.kind() != std::io::ErrorKind::NotFound {
1692                    warn!(
1693                        "Failed to remove temp download file {}: {e}",
1694                        path.display()
1695                    );
1696                }
1697            }
1698        }
1699    }
1700}
1701
1702impl Client {
1703    /// Upload a file to the network using streaming self-encryption.
1704    ///
1705    /// Automatically selects merkle batch payment for files that produce
1706    /// 64+ chunks (saves gas). Encrypted chunks are spilled to a temp
1707    /// directory so peak memory stays at ~256 MB regardless of file size.
1708    ///
1709    /// # Errors
1710    ///
1711    /// Returns an error if the file cannot be read, encryption fails,
1712    /// or any chunk cannot be stored.
1713    pub async fn file_upload(&self, path: &Path) -> Result<FileUploadResult> {
1714        self.file_upload_with_mode(path, PaymentMode::Auto).await
1715    }
1716
1717    /// Estimate the cost of uploading a file without actually uploading.
1718    ///
1719    /// Encrypts the file to determine chunk count and sizes, then requests
1720    /// a single quote from the network for a representative chunk. The
1721    /// per-chunk price is extrapolated to the total chunk count.
1722    ///
1723    /// The estimate is fast (~2-5s) and does not require a wallet. Spilled
1724    /// chunks are cleaned up automatically when the function returns.
1725    ///
1726    /// Gas cost is an advisory heuristic, not a live gas-oracle query. It is
1727    /// derived from realistic per-transaction budgets (`GAS_PER_WAVE_TX`,
1728    /// `GAS_PER_MERKLE_TX`) priced at `ARBITRUM_GAS_PRICE_WEI`. Real gas
1729    /// varies with network conditions.
1730    ///
1731    /// Sampled chunk addresses are spread across the whole file (not the first
1732    /// N) so a shared leading prefix doesn't bias the sample. When a sample
1733    /// returns a live quote the per-chunk price is extrapolated and the result
1734    /// is tagged [`CostEstimateConfidence::PricedSample`].
1735    ///
1736    /// When every sampled chunk is already stored the result is still `Ok`
1737    /// with `storage_cost_atto: "0"`, tagged either
1738    /// [`CostEstimateConfidence::VerifiedAllAlreadyStored`] when the whole file
1739    /// was sampled (exactly free) or
1740    /// [`CostEstimateConfidence::AllSamplesAlreadyStoredIncomplete`] when the
1741    /// tail was unsampled (a best-effort guess that payment reconciles).
1742    ///
1743    /// # Errors
1744    ///
1745    /// Returns an error if the file cannot be read, encryption fails, or the
1746    /// network cannot provide a quote.
1747    pub async fn estimate_upload_cost(
1748        &self,
1749        path: &Path,
1750        mode: PaymentMode,
1751        progress: Option<mpsc::Sender<UploadEvent>>,
1752    ) -> Result<UploadCostEstimate> {
1753        let file_size = std::fs::metadata(path).map_err(Error::Io)?.len();
1754
1755        if file_size < 3 {
1756            return Err(Error::InvalidData(
1757                "File too small: self-encryption requires at least 3 bytes".into(),
1758            ));
1759        }
1760
1761        check_disk_space_for_spill(file_size)?;
1762
1763        info!(
1764            "Estimating upload cost for {} ({file_size} bytes)",
1765            path.display()
1766        );
1767
1768        let (spill, _data_map) = self.encrypt_file_to_spill(path, progress.as_ref()).await?;
1769        let chunk_count = spill.len();
1770
1771        if let Some(ref tx) = progress {
1772            let _ = tx
1773                .send(UploadEvent::Encrypted {
1774                    total_chunks: chunk_count,
1775                })
1776                .await;
1777        }
1778
1779        info!("Encrypted into {chunk_count} chunks, requesting quote");
1780        let uses_merkle = should_use_merkle(chunk_count, mode);
1781
1782        // Sample chunk addresses spread evenly across the file (see
1783        // `distributed_sample_indices`) rather than the first N. A single
1784        // AlreadyStored result says nothing about the rest of the file, and a
1785        // positional sample lands on a shared leading prefix in the worst case,
1786        // so we spread the probe and only treat the whole file as "fully
1787        // stored" when every sample comes back stored.
1788        let sample_indices = distributed_sample_indices(spill.addresses.len(), ESTIMATE_SAMPLE_CAP);
1789        let mut sampled = 0usize;
1790        let mut all_already_stored = true;
1791        let mut quotes_opt: Option<Vec<QuoteEntry>> = None;
1792
1793        for &idx in &sample_indices {
1794            let addr = &spill.addresses[idx];
1795            sampled += 1;
1796            let chunk_bytes = spill.read_chunk(addr)?;
1797            let data_size = u64::try_from(chunk_bytes.len())
1798                .map_err(|e| Error::InvalidData(format!("chunk size too large: {e}")))?;
1799            let result = if uses_merkle {
1800                self.get_store_quotes_with_fault_tolerance(addr, data_size, DATA_TYPE_CHUNK)
1801                    .await
1802            } else {
1803                self.get_store_quotes(addr, data_size, DATA_TYPE_CHUNK)
1804                    .await
1805            };
1806            match result {
1807                Ok(q) => {
1808                    quotes_opt = Some(q);
1809                    all_already_stored = false;
1810                    break;
1811                }
1812                Err(Error::AlreadyStored) => {
1813                    debug!(
1814                        "Sample chunk {} already stored; trying next address ({sampled}/{})",
1815                        hex::encode(addr),
1816                        sample_indices.len()
1817                    );
1818                    continue;
1819                }
1820                Err(e) => return Err(e),
1821            }
1822        }
1823
1824        let quotes = match quotes_opt {
1825            Some(q) => q,
1826            None if all_already_stored && sampled == chunk_count => {
1827                // Every address in the file was sampled and every one is
1828                // already on the network — a zero-cost estimate is exact here.
1829                info!("All {chunk_count} chunks already stored; returning zero-cost estimate");
1830                return Ok(UploadCostEstimate {
1831                    file_size,
1832                    chunk_count,
1833                    storage_cost_atto: "0".into(),
1834                    estimated_gas_cost_wei: "0".into(),
1835                    payment_mode: if uses_merkle {
1836                        PaymentMode::Merkle
1837                    } else {
1838                        PaymentMode::Single
1839                    },
1840                    confidence: CostEstimateConfidence::VerifiedAllAlreadyStored,
1841                });
1842            }
1843            None => {
1844                // Every sampled chunk was already stored but the tail was not
1845                // sampled, so there is no live price to extrapolate. The
1846                // estimate is display-only and payment reconciles the true
1847                // cost, so return an optimistic zero flagged as incomplete
1848                // rather than erroring — callers still get a value to show.
1849                info!(
1850                    "All {sampled}/{chunk_count} sampled chunks already stored; \
1851                     returning incomplete zero-cost estimate"
1852                );
1853                return Ok(UploadCostEstimate {
1854                    file_size,
1855                    chunk_count,
1856                    storage_cost_atto: "0".into(),
1857                    estimated_gas_cost_wei: "0".into(),
1858                    payment_mode: if uses_merkle {
1859                        PaymentMode::Merkle
1860                    } else {
1861                        PaymentMode::Single
1862                    },
1863                    confidence: CostEstimateConfidence::AllSamplesAlreadyStoredIncomplete,
1864                });
1865            }
1866        };
1867
1868        // Use the median price × 3, matching the settlement multiplier both
1869        // payment paths now apply.
1870        let mut prices: Vec<Amount> = quotes.iter().map(|(_, _, _, price, _)| *price).collect();
1871        prices.sort();
1872        let median_price = prices
1873            .get(prices.len() / 2)
1874            .copied()
1875            .unwrap_or(Amount::ZERO);
1876        let per_chunk_cost = median_price * Amount::from(SINGLE_NODE_PAYMENT_MULTIPLIER);
1877
1878        let chunk_count_u64 = u64::try_from(chunk_count).unwrap_or(u64::MAX);
1879        // Merkle settles per *padded* leaf, not per chunk: the contract charges
1880        // `median16 × 2^depth` per batch and the tree rounds up to a power of
1881        // two, so a 65-chunk batch pays for 128 leaves. The leaf total is
1882        // summed over the batches the payment path really builds
1883        // (`merkle_batch_sizes`), so the estimate cannot drift from execution.
1884        let billable_units = if uses_merkle {
1885            merkle_billable_leaves(chunk_count_u64)
1886        } else {
1887            chunk_count_u64
1888        };
1889        let total_storage = per_chunk_cost * Amount::from(billable_units);
1890
1891        // Estimate gas cost from realistic per-transaction budgets rather
1892        // than a flat per-chunk or per-wave number.
1893        //
1894        // - Single mode: `batch_pay` packs up to UPLOAD_WAVE_SIZE chunks'
1895        //   close-group quotes into one `pay_for_quotes` call on Arbitrum.
1896        //   The dominant cost is one SSTORE per entry plus base tx overhead,
1897        //   so we use GAS_PER_WAVE_TX (≈1.5M) as a conservative upper bound
1898        //   on a full wave and multiply by the number of waves. The previous
1899        //   per-wave figure of 150k was closer to a single-entry transfer
1900        //   and understated cost by 5–10x for full waves.
1901        // - Merkle mode: one tx per sub-batch that verifies a merkle tree
1902        //   and posts a pool commitment (GAS_PER_MERKLE_TX ≈ 500k each).
1903        //
1904        // Gas is priced at ARBITRUM_GAS_PRICE_WEI (~0.1 gwei, a typical
1905        // Arbitrum baseline). Treat the result as advisory, not a commitment.
1906        let waves = u128::try_from(chunk_count.div_ceil(UPLOAD_WAVE_SIZE)).unwrap_or(u128::MAX);
1907        // One tx per batch the payment path builds — same partition the leaf
1908        // total above is derived from.
1909        let merkle_batches =
1910            u128::try_from(merkle_batch_sizes(chunk_count).len()).unwrap_or(u128::MAX);
1911        let estimated_gas: u128 = if uses_merkle {
1912            merkle_batches
1913                .saturating_mul(GAS_PER_MERKLE_TX)
1914                .saturating_mul(ARBITRUM_GAS_PRICE_WEI)
1915        } else {
1916            waves
1917                .saturating_mul(GAS_PER_WAVE_TX)
1918                .saturating_mul(ARBITRUM_GAS_PRICE_WEI)
1919        };
1920
1921        info!(
1922            "Estimate: {chunk_count} chunks, storage={total_storage} atto, gas~={estimated_gas} wei"
1923        );
1924
1925        Ok(UploadCostEstimate {
1926            file_size,
1927            chunk_count,
1928            storage_cost_atto: total_storage.to_string(),
1929            estimated_gas_cost_wei: estimated_gas.to_string(),
1930            payment_mode: if uses_merkle {
1931                PaymentMode::Merkle
1932            } else {
1933                PaymentMode::Single
1934            },
1935            confidence: CostEstimateConfidence::PricedSample,
1936        })
1937    }
1938
1939    /// Phase 1 of external-signer upload: encrypt file and prepare chunks.
1940    ///
1941    /// Equivalent to [`Client::file_prepare_upload_with_visibility`] with
1942    /// [`Visibility::Private`] — see that method for details.
1943    pub async fn file_prepare_upload(&self, path: &Path) -> Result<PreparedUpload> {
1944        self.file_prepare_upload_with_progress(path, Visibility::Private, None)
1945            .await
1946    }
1947
1948    /// Phase 1 of external-signer upload with explicit [`Visibility`] control.
1949    ///
1950    /// Equivalent to [`Client::file_prepare_upload_with_progress`] with
1951    /// `progress: None` — see that method for details.
1952    pub async fn file_prepare_upload_with_visibility(
1953        &self,
1954        path: &Path,
1955        visibility: Visibility,
1956    ) -> Result<PreparedUpload> {
1957        self.file_prepare_upload_with_progress(path, visibility, None)
1958            .await
1959    }
1960
1961    /// Phase 1 of external-signer upload with progress events.
1962    ///
1963    /// Equivalent to [`Client::file_prepare_upload_with_mode`] with
1964    /// [`PaymentMode::Auto`] — see that method for details.
1965    pub async fn file_prepare_upload_with_progress(
1966        &self,
1967        path: &Path,
1968        visibility: Visibility,
1969        progress: Option<mpsc::Sender<UploadEvent>>,
1970    ) -> Result<PreparedUpload> {
1971        self.file_prepare_upload_with_mode(path, visibility, PaymentMode::Auto, progress)
1972            .await
1973    }
1974
1975    /// Phase 1 of external-signer upload with an explicit [`PaymentMode`].
1976    ///
1977    /// Requires an EVM network (for contract price queries) but NOT a wallet.
1978    /// Returns a [`PreparedUpload`] containing the data map and either a
1979    /// [`PaymentIntent`] (wave-batch) or prepared merkle sub-batches that
1980    /// the external signer uses to construct and submit the on-chain payment
1981    /// transaction(s) — one per sub-batch (ADR-0003).
1982    ///
1983    /// `mode` mirrors the wallet path's [`Client::file_upload_with_mode`]:
1984    /// [`PaymentMode::Auto`] picks merkle at the chunk threshold,
1985    /// [`PaymentMode::Merkle`] forces merkle for ≥ 2 upload chunks (this is
1986    /// how tests exercise the external merkle flow with small files), and
1987    /// [`PaymentMode::Single`] forces wave-batch.
1988    ///
1989    /// When `visibility` is [`Visibility::Public`], the serialized `DataMap`
1990    /// is bundled into the payment batch as an additional chunk and its
1991    /// address is recorded on the returned [`PreparedUpload`]. After
1992    /// [`Client::finalize_upload`] (or `_merkle`) succeeds, that address is
1993    /// surfaced via [`FileUploadResult::data_map_address`] so the uploader
1994    /// can share a single address from which anyone can retrieve the file.
1995    ///
1996    /// When `progress` is `Some`, [`UploadEvent`]s are emitted on the channel
1997    /// during encryption ([`UploadEvent::Encrypting`] / [`UploadEvent::Encrypted`])
1998    /// and per-chunk quoting ([`UploadEvent::ChunkQuoted`]). Storage events are
1999    /// emitted later by [`Client::finalize_upload_with_progress`] /
2000    /// [`Client::finalize_upload_merkle_with_progress`].
2001    ///
2002    /// **Memory note:** on the merkle path, chunk bodies stay in the on-disk
2003    /// encryption spill inside the returned [`PreparedUpload`] and are read
2004    /// back ≤ store-cap at a time during finalize, so peak RAM stays bounded
2005    /// (~256 MB) regardless of file size (ADR-0003). The spill directory
2006    /// lives as long as the `PreparedUpload` does. The wave-batch path —
2007    /// below the merkle threshold, so < ~64 × 4 MiB of chunks (unless
2008    /// [`PaymentMode::Single`] forces it for a larger file) — still holds
2009    /// its chunk bodies resident.
2010    ///
2011    /// # Errors
2012    ///
2013    /// Returns an error if there is insufficient disk space, the file cannot
2014    /// be read, encryption fails, or quote collection fails.
2015    pub async fn file_prepare_upload_with_mode(
2016        &self,
2017        path: &Path,
2018        visibility: Visibility,
2019        mode: PaymentMode,
2020        progress: Option<mpsc::Sender<UploadEvent>>,
2021    ) -> Result<PreparedUpload> {
2022        debug!(
2023            "Preparing file upload for external signing (visibility={visibility:?}, mode={mode:?}): {}",
2024            path.display()
2025        );
2026
2027        let file_size = std::fs::metadata(path)?.len();
2028        check_disk_space_for_spill(file_size)?;
2029
2030        let (mut spill, data_map) = self.encrypt_file_to_spill(path, progress.as_ref()).await?;
2031
2032        info!(
2033            "Encrypted {} into {} chunks for external signing (spilled to disk)",
2034            path.display(),
2035            spill.len()
2036        );
2037
2038        // For public uploads, bundle the serialized DataMap as an extra chunk
2039        // in the same payment batch. This lets the external signer pay for
2040        // the data chunks and the DataMap chunk in one flow, and lets the
2041        // finalize step return the DataMap's chunk address as the shareable
2042        // retrieval address. It joins the spill like any data chunk so the
2043        // merkle path stays disk-backed; `push` dedups by address.
2044        let data_map_address = match visibility {
2045            Visibility::Private => None,
2046            Visibility::Public => {
2047                let serialized = rmp_serde::to_vec(&data_map).map_err(|e| {
2048                    Error::Serialization(format!("Failed to serialize DataMap: {e}"))
2049                })?;
2050                let address = compute_address(&serialized);
2051                info!(
2052                    "Public upload: bundling DataMap chunk ({} bytes) at address {}",
2053                    serialized.len(),
2054                    hex::encode(address)
2055                );
2056                spill.push(&serialized)?;
2057                Some(address)
2058            }
2059        };
2060
2061        let chunk_count = spill.len();
2062
2063        if let Some(ref tx) = progress {
2064            let _ = tx
2065                .send(UploadEvent::Encrypted {
2066                    total_chunks: chunk_count,
2067                })
2068                .await;
2069        }
2070
2071        let (payment_info, already_stored_addresses) = if should_use_merkle(chunk_count, mode) {
2072            // Merkle path: build tree(s), collect candidate pools, return for
2073            // external payment. Chunk bodies stay in the spill on disk.
2074            info!("Using merkle batch preparation for {chunk_count} file chunks");
2075
2076            let chunk_entries = spill.chunk_entries()?;
2077
2078            let merkle_plan = self
2079                .plan_merkle_upload(chunk_entries, DATA_TYPE_CHUNK, progress.as_ref())
2080                .await?;
2081
2082            if merkle_plan.to_upload.is_empty() {
2083                info!("All {chunk_count} file chunks already stored; no external payment needed");
2084                (
2085                    ExternalPaymentInfo::WaveBatch {
2086                        prepared_chunks: Vec::new(),
2087                        payment_intent: PaymentIntent::from_prepared_chunks(&[]),
2088                    },
2089                    merkle_plan.already_stored,
2090                )
2091            } else if !should_use_merkle(merkle_plan.to_upload.len(), mode) {
2092                info!(
2093                    "{} file chunks need upload after merkle preflight; preparing wave-batch payment",
2094                    merkle_plan.to_upload.len()
2095                );
2096                let chunk_data = spill.read_chunks(&merkle_plan.to_upload)?;
2097                let (payment_info, mut wave_already_stored) = self
2098                    .prepare_wave_batch_external_chunks(chunk_data, progress.as_ref(), chunk_count)
2099                    .await?;
2100                let mut already_stored = merkle_plan.already_stored;
2101                already_stored.append(&mut wave_already_stored);
2102                (payment_info, already_stored)
2103            } else {
2104                // One signature pays one tree, so the to-upload set is
2105                // partitioned into `MerkleTree`-sized sub-batches and the
2106                // signer pays each — the external equivalent of the wallet
2107                // path's multi-transaction split (ADR-0003).
2108                match self
2109                    .prepare_merkle_batches_external(
2110                        &merkle_plan.to_upload,
2111                        DATA_TYPE_CHUNK,
2112                        merkle_plan.to_upload_avg_size(),
2113                        self.merkle_external_batch_cap(),
2114                    )
2115                    .await
2116                {
2117                    Ok(prepared_batches) => {
2118                        info!(
2119                            "File prepared for external merkle signing: {} chunks in {} sub-batch(es) ({})",
2120                            merkle_plan.to_upload.len(),
2121                            prepared_batches.len(),
2122                            path.display()
2123                        );
2124
2125                        (
2126                            ExternalPaymentInfo::Merkle {
2127                                prepared_batches,
2128                                chunk_store: ExternalChunkStore::from_spill(spill),
2129                                chunk_addresses: merkle_plan.to_upload,
2130                            },
2131                            merkle_plan.already_stored,
2132                        )
2133                    }
2134                    Err(Error::InsufficientPeers(ref msg)) => {
2135                        info!(
2136                            "External merkle preparation needs more peers ({msg}); preparing wave-batch payment"
2137                        );
2138                        let chunk_data = spill.read_chunks(&merkle_plan.to_upload)?;
2139                        let (payment_info, mut wave_already_stored) = self
2140                            .prepare_wave_batch_external_chunks(
2141                                chunk_data,
2142                                progress.as_ref(),
2143                                chunk_count,
2144                            )
2145                            .await?;
2146                        let mut already_stored = merkle_plan.already_stored;
2147                        already_stored.append(&mut wave_already_stored);
2148                        (payment_info, already_stored)
2149                    }
2150                    Err(e) => return Err(e),
2151                }
2152            }
2153        } else {
2154            // Wave path: below the merkle threshold (or PaymentMode::Single),
2155            // chunk bodies come back resident for per-chunk quoting.
2156            let chunk_data = spill.read_all_chunks()?;
2157            self.prepare_wave_batch_external_chunks(chunk_data, progress.as_ref(), chunk_count)
2158                .await?
2159        };
2160
2161        // Surface the "DataMap chunk was already on the network" case
2162        // so debugging "why is data_map_address set but no storage cost
2163        // appears for it?" doesn't require reading the source. See the
2164        // `data_map_address` doc comment for why this is still a valid
2165        // `Some(addr)` outcome.
2166        if let Some(addr) = data_map_address {
2167            let data_map_needs_payment = match &payment_info {
2168                ExternalPaymentInfo::WaveBatch {
2169                    prepared_chunks, ..
2170                } => prepared_chunks.iter().any(|c| c.address == addr),
2171                ExternalPaymentInfo::Merkle {
2172                    chunk_addresses, ..
2173                } => chunk_addresses.contains(&addr),
2174            };
2175            if !data_map_needs_payment {
2176                info!(
2177                    "Public upload: DataMap chunk {} was already stored \
2178                     on the network — address is retrievable without a \
2179                     new payment",
2180                    hex::encode(addr)
2181                );
2182            }
2183        }
2184
2185        Ok(PreparedUpload {
2186            data_map,
2187            payment_info,
2188            data_map_address,
2189            already_stored_addresses,
2190            total_chunks: chunk_count,
2191        })
2192    }
2193
2194    async fn prepare_wave_batch_external_chunks(
2195        &self,
2196        chunk_data: Vec<Bytes>,
2197        progress: Option<&mpsc::Sender<UploadEvent>>,
2198        progress_total: usize,
2199    ) -> Result<(ExternalPaymentInfo, Vec<[u8; 32]>)> {
2200        let chunk_count = chunk_data.len();
2201        let chunks_with_addr: Vec<(Bytes, [u8; 32])> = chunk_data
2202            .into_iter()
2203            .map(|content| {
2204                let address = compute_address(&content);
2205                (content, address)
2206            })
2207            .collect();
2208
2209        // Wave-batch path: collect quotes per chunk concurrently, emitting
2210        // a `ChunkQuoted` event after each completion so callers can drive
2211        // a progress bar through the slow quote phase.
2212        let quote_limiter = self.controller().quote.clone();
2213        let quote_concurrency = quote_limiter.current().min(chunk_count.max(1));
2214        let mut quote_stream = stream::iter(chunks_with_addr)
2215            .map(|(content, address)| {
2216                let limiter = quote_limiter.clone();
2217                async move {
2218                    let result = observe_op(
2219                        &limiter,
2220                        || async move { self.prepare_chunk_payment(content).await },
2221                        classify_error,
2222                    )
2223                    .await;
2224                    (address, result)
2225                }
2226            })
2227            .buffer_unordered(quote_concurrency);
2228
2229        let mut prepared_chunks = Vec::with_capacity(chunk_count);
2230        let mut already_stored = Vec::new();
2231        let mut quoted = 0usize;
2232        while let Some((address, result)) = quote_stream.next().await {
2233            match result? {
2234                Some(prepared) => prepared_chunks.push(prepared),
2235                None => already_stored.push(address),
2236            }
2237            quoted += 1;
2238            if let Some(tx) = progress {
2239                let _ = tx.try_send(UploadEvent::ChunkQuoted {
2240                    quoted,
2241                    total: progress_total,
2242                });
2243            }
2244        }
2245
2246        let payment_intent = PaymentIntent::from_prepared_chunks(&prepared_chunks);
2247        info!(
2248            "Prepared external wave-batch payment: {} chunks, {} already stored, total {} atto",
2249            prepared_chunks.len(),
2250            already_stored.len(),
2251            payment_intent.total_amount,
2252        );
2253
2254        Ok((
2255            ExternalPaymentInfo::WaveBatch {
2256                prepared_chunks,
2257                payment_intent,
2258            },
2259            already_stored,
2260        ))
2261    }
2262
2263    /// Phase 2 of external-signer upload (wave-batch): finalize with externally-signed tx hashes.
2264    ///
2265    /// Takes a [`PreparedUpload`] that used wave-batch payment and a map
2266    /// of `quote_hash -> tx_hash` provided by the external signer after on-chain
2267    /// payment. Builds payment proofs and stores chunks on the network.
2268    ///
2269    /// # Errors
2270    ///
2271    /// Returns an error if the prepared upload used merkle payment (use
2272    /// [`Client::finalize_upload_merkle`] instead), proof construction fails,
2273    /// or any chunk cannot be stored.
2274    pub async fn finalize_upload(
2275        &self,
2276        prepared: PreparedUpload,
2277        tx_hash_map: &HashMap<QuoteHash, TxHash>,
2278    ) -> Result<FileUploadResult> {
2279        self.finalize_upload_with_progress(prepared, tx_hash_map, None)
2280            .await
2281    }
2282
2283    /// Phase 2 of external-signer upload (wave-batch) with progress events.
2284    ///
2285    /// Same as [`Client::finalize_upload`] but emits [`UploadEvent::ChunkStored`]
2286    /// on the provided channel as each chunk is successfully stored.
2287    ///
2288    /// # Errors
2289    ///
2290    /// Same as [`Client::finalize_upload`].
2291    pub async fn finalize_upload_with_progress(
2292        &self,
2293        prepared: PreparedUpload,
2294        tx_hash_map: &HashMap<QuoteHash, TxHash>,
2295        progress: Option<mpsc::Sender<UploadEvent>>,
2296    ) -> Result<FileUploadResult> {
2297        let data_map_address = prepared.data_map_address;
2298        let already_stored_addresses = prepared.already_stored_addresses;
2299        let already_stored_count = already_stored_addresses.len();
2300        let total_chunks = prepared.total_chunks;
2301        match prepared.payment_info {
2302            ExternalPaymentInfo::WaveBatch {
2303                prepared_chunks,
2304                payment_intent,
2305            } => {
2306                let paid_chunks = finalize_batch_payment(prepared_chunks, tx_hash_map)?;
2307                let wave_result = self
2308                    .store_paid_chunks_with_events(
2309                        paid_chunks,
2310                        progress.as_ref(),
2311                        already_stored_count,
2312                        total_chunks,
2313                    )
2314                    .await;
2315                if !wave_result.failed.is_empty() {
2316                    let failed_count = wave_result.failed.len();
2317                    let stored_count = already_stored_count + wave_result.stored.len();
2318                    let mut stored = already_stored_addresses;
2319                    stored.extend(wave_result.stored);
2320                    return Err(Error::PartialUpload {
2321                        stored,
2322                        stored_count,
2323                        failed: wave_result.failed,
2324                        failed_count,
2325                        total_chunks,
2326                        // Report the storage spend known from the payment intent
2327                        // the external signer was handed. Gas is paid by the
2328                        // signer out-of-band, so it stays unknown (0).
2329                        spend: Box::new(PartialUploadSpend {
2330                            storage_cost_atto: payment_intent.total_amount.to_string(),
2331                            gas_cost_wei: 0,
2332                        }),
2333                        reason: "finalize_upload: chunk storage failed after retries".into(),
2334                    });
2335                }
2336                let chunks_stored = already_stored_count + wave_result.stored.len();
2337
2338                info!("External-signer upload finalized: {chunks_stored} chunks stored");
2339
2340                let mut stats = WaveAggregateStats::default();
2341                stats.absorb(&wave_result);
2342
2343                Ok(FileUploadResult {
2344                    data_map: prepared.data_map,
2345                    chunks_stored,
2346                    chunks_failed: 0,
2347                    total_chunks,
2348                    payment_mode_used: PaymentMode::Single,
2349                    // Storage spend is known from the payment intent; gas is
2350                    // paid by the external signer out-of-band (unknown here).
2351                    storage_cost_atto: payment_intent.total_amount.to_string(),
2352                    gas_cost_wei: 0,
2353                    data_map_address,
2354                    chunk_attempts_total: stats.chunk_attempts_total,
2355                    store_durations_ms: stats.store_durations_ms,
2356                    retries_histogram: stats.retries_histogram,
2357                })
2358            }
2359            ExternalPaymentInfo::Merkle { .. } => Err(Error::Payment(
2360                "Cannot finalize merkle upload with wave-batch tx hashes. \
2361                 Use finalize_upload_merkle() instead."
2362                    .to_string(),
2363            )),
2364        }
2365    }
2366
2367    /// Per-batch leaf cap for external merkle preparation: the configured
2368    /// test override clamped to `3..=MAX_LEAVES` (see
2369    /// [`merkle_batch_sizes_with_cap`] for why 3 is the floor), or
2370    /// `MAX_LEAVES` (ADR-0003).
2371    fn merkle_external_batch_cap(&self) -> usize {
2372        self.config()
2373            .merkle_external_batch_cap
2374            .map_or(MAX_LEAVES, |cap| cap.clamp(3, MAX_LEAVES))
2375    }
2376
2377    /// Phase 2 of external-signer upload (merkle): finalize with winner pool hash.
2378    ///
2379    /// The single-batch special case of
2380    /// [`Client::finalize_upload_merkle_multi`]: valid only for uploads that
2381    /// prepared as exactly one merkle sub-batch (any fresh upload below
2382    /// `MAX_LEAVES` chunks). Generates proofs and stores chunks on the
2383    /// network.
2384    ///
2385    /// # Errors
2386    ///
2387    /// Returns an error if the prepared upload used wave-batch payment (use
2388    /// [`Client::finalize_upload`] instead), was prepared as more than one
2389    /// sub-batch (use [`Client::finalize_upload_merkle_multi`]), or proof
2390    /// generation fails. Chunks still short of quorum after all retries
2391    /// surface as [`Error::PartialUpload`] carrying the stored and failed
2392    /// addresses — the same contract as [`Client::finalize_upload`].
2393    /// Re-preparing the same file skips chunks that are already stored.
2394    pub async fn finalize_upload_merkle(
2395        &self,
2396        prepared: PreparedUpload,
2397        winner_pool_hash: [u8; 32],
2398    ) -> Result<FileUploadResult> {
2399        self.finalize_upload_merkle_with_progress(prepared, winner_pool_hash, None)
2400            .await
2401    }
2402
2403    /// Phase 2 of external-signer upload (merkle) with progress events.
2404    ///
2405    /// Same as [`Client::finalize_upload_merkle`] but emits [`UploadEvent::ChunkStored`]
2406    /// on the provided channel as each chunk is successfully stored.
2407    ///
2408    /// # Errors
2409    ///
2410    /// Same as [`Client::finalize_upload_merkle`].
2411    pub async fn finalize_upload_merkle_with_progress(
2412        &self,
2413        prepared: PreparedUpload,
2414        winner_pool_hash: [u8; 32],
2415        progress: Option<mpsc::Sender<UploadEvent>>,
2416    ) -> Result<FileUploadResult> {
2417        if let ExternalPaymentInfo::Merkle {
2418            prepared_batches, ..
2419        } = &prepared.payment_info
2420        {
2421            let batches = prepared_batches.len();
2422            if batches != 1 {
2423                return Err(Error::Payment(format!(
2424                    "This upload was prepared as {batches} merkle sub-batches; \
2425                     pay each and call finalize_upload_merkle_multi() with one \
2426                     winner hash per batch."
2427                )));
2428            }
2429        }
2430        self.finalize_upload_merkle_multi_with_progress(
2431            prepared,
2432            vec![Some(winner_pool_hash)],
2433            progress,
2434        )
2435        .await
2436    }
2437
2438    /// Phase 2 of external-signer upload (merkle): finalize with one winner
2439    /// pool hash per prepared sub-batch.
2440    ///
2441    /// `winner_pool_hashes` aligns with
2442    /// [`ExternalPaymentInfo::Merkle::prepared_batches`]: entry `i` is the
2443    /// `MerklePaymentMade` winner hash of batch `i`'s on-chain payment, or
2444    /// `None` if the signer never paid that batch (e.g. the user abandoned
2445    /// the flow midway). Paid batches make forward progress: their proofs
2446    /// are folded — mirroring the wallet path's multi-batch fold — and their
2447    /// chunks stored from the on-disk spill in a bounded fan-out; chunks of
2448    /// unpaid batches are reported through [`Error::PartialUpload`]
2449    /// (ADR-0003).
2450    ///
2451    /// # Errors
2452    ///
2453    /// Returns an error if the prepared upload used wave-batch payment, the
2454    /// hash count does not match the batch count, every entry is `None`, or
2455    /// proof generation fails. Chunks short of quorum after all retries —
2456    /// and all chunks of unpaid batches — surface as
2457    /// [`Error::PartialUpload`] carrying the stored and failed addresses.
2458    /// Re-preparing the same file skips chunks that are already stored.
2459    pub async fn finalize_upload_merkle_multi(
2460        &self,
2461        prepared: PreparedUpload,
2462        winner_pool_hashes: Vec<Option<[u8; 32]>>,
2463    ) -> Result<FileUploadResult> {
2464        self.finalize_upload_merkle_multi_with_progress(prepared, winner_pool_hashes, None)
2465            .await
2466    }
2467
2468    /// Same as [`Client::finalize_upload_merkle_multi`] but emits
2469    /// [`UploadEvent::ChunkStored`] on the provided channel as each chunk is
2470    /// successfully stored.
2471    ///
2472    /// # Errors
2473    ///
2474    /// Same as [`Client::finalize_upload_merkle_multi`].
2475    pub async fn finalize_upload_merkle_multi_with_progress(
2476        &self,
2477        prepared: PreparedUpload,
2478        winner_pool_hashes: Vec<Option<[u8; 32]>>,
2479        progress: Option<mpsc::Sender<UploadEvent>>,
2480    ) -> Result<FileUploadResult> {
2481        let data_map_address = prepared.data_map_address;
2482        let already_stored_addresses = prepared.already_stored_addresses;
2483        let total_chunks = prepared.total_chunks;
2484        match prepared.payment_info {
2485            ExternalPaymentInfo::Merkle {
2486                prepared_batches,
2487                chunk_store,
2488                chunk_addresses,
2489            } => {
2490                let batch_result =
2491                    fold_external_merkle_payments(prepared_batches, winner_pool_hashes)?;
2492
2493                let (chunks_stored, _storage_cost, _gas_cost, stats) = self
2494                    .upload_merkle_from_spill(
2495                        chunk_store.spill(),
2496                        &chunk_addresses,
2497                        &batch_result,
2498                        &already_stored_addresses,
2499                        progress.as_ref(),
2500                        // The external signer chose which sub-batches to pay.
2501                        // A refusal latched by some other operation on this
2502                        // client did not cause the gaps it left, and saying so
2503                        // would blame the wrong thing.
2504                        None,
2505                    )
2506                    .await?;
2507
2508                info!("External-signer merkle upload finalized: {chunks_stored} chunks stored");
2509
2510                Ok(FileUploadResult {
2511                    data_map: prepared.data_map,
2512                    chunks_stored,
2513                    chunks_failed: 0,
2514                    total_chunks,
2515                    payment_mode_used: PaymentMode::Merkle,
2516                    // The external signer pays on-chain out-of-band, so the
2517                    // spend is unknown to the library here.
2518                    storage_cost_atto: "0".into(),
2519                    gas_cost_wei: 0,
2520                    data_map_address,
2521                    chunk_attempts_total: stats.chunk_attempts_total,
2522                    store_durations_ms: stats.store_durations_ms,
2523                    retries_histogram: stats.retries_histogram,
2524                })
2525            }
2526            ExternalPaymentInfo::WaveBatch { .. } => Err(Error::Payment(
2527                "Cannot finalize wave-batch upload with merkle winner hashes. \
2528                 Use finalize_upload() instead."
2529                    .to_string(),
2530            )),
2531        }
2532    }
2533
2534    /// Finalize an external-signer merkle upload, returning a resume handle if
2535    /// some chunks remain unstored after retries.
2536    ///
2537    /// Behaves like [`Client::finalize_upload_merkle_multi`], but instead of
2538    /// surfacing a quorum shortfall as [`Error::PartialUpload`] it returns
2539    /// [`FinalizeOutcome::Partial`], carrying a [`MerkleFinalizeResume`] (inside
2540    /// [`FinalizeResume::Merkle`]) that owns the on-disk chunk spill and the
2541    /// already-signed payment proofs. The caller can hand that handle to
2542    /// [`Client::finalize_resume`] to store only the still-unstored chunks
2543    /// against the **same** on-chain payment — no re-quoting, no second
2544    /// signature, no double payment (#140).
2545    ///
2546    /// Unlike the non-resumable method, **every sub-batch must be paid**
2547    /// (`winner_pool_hashes` all `Some`). A resume handle cannot acquire proofs
2548    /// for unpaid chunks, so a partially-paid finalize could never drain to
2549    /// [`FinalizeOutcome::Complete`]; partial payment is rejected up front. To
2550    /// finalize a partial payment, use [`Client::finalize_upload_merkle_multi`],
2551    /// which reports the unpaid chunks through [`Error::PartialUpload`].
2552    ///
2553    /// # Errors
2554    ///
2555    /// Returns an error if any sub-batch is unpaid, the winner-hash count does
2556    /// not match the prepared batches, the payment info is wave-batch rather
2557    /// than merkle, or payment finalization fails. Store failures are **not**
2558    /// errors here: a quorum shortfall — and a fatal store abort, which keeps
2559    /// its progress the same way — comes back as [`FinalizeOutcome::Partial`].
2560    pub async fn finalize_upload_merkle_multi_resumable(
2561        &self,
2562        prepared: PreparedUpload,
2563        winner_pool_hashes: Vec<Option<[u8; 32]>>,
2564    ) -> Result<FinalizeOutcome> {
2565        self.finalize_upload_merkle_multi_resumable_with_progress(
2566            prepared,
2567            winner_pool_hashes,
2568            None,
2569        )
2570        .await
2571    }
2572
2573    /// Same as [`Client::finalize_upload_merkle_multi_resumable`] but emits
2574    /// [`UploadEvent::ChunkStored`] on the provided channel as each chunk is
2575    /// stored.
2576    ///
2577    /// # Errors
2578    ///
2579    /// Same as [`Client::finalize_upload_merkle_multi_resumable`].
2580    pub async fn finalize_upload_merkle_multi_resumable_with_progress(
2581        &self,
2582        prepared: PreparedUpload,
2583        winner_pool_hashes: Vec<Option<[u8; 32]>>,
2584        progress: Option<mpsc::Sender<UploadEvent>>,
2585    ) -> Result<FinalizeOutcome> {
2586        let data_map_address = prepared.data_map_address;
2587        let already_stored_addresses = prepared.already_stored_addresses;
2588        let total_chunks = prepared.total_chunks;
2589        let data_map = prepared.data_map;
2590        match prepared.payment_info {
2591            ExternalPaymentInfo::Merkle {
2592                prepared_batches,
2593                chunk_store,
2594                chunk_addresses,
2595            } => {
2596                require_fully_paid_for_resumable(&winner_pool_hashes)?;
2597                let batch_result =
2598                    fold_external_merkle_payments(prepared_batches, winner_pool_hashes)?;
2599                self.drive_merkle_finalize(
2600                    data_map,
2601                    data_map_address,
2602                    total_chunks,
2603                    chunk_store,
2604                    chunk_addresses,
2605                    batch_result,
2606                    already_stored_addresses,
2607                    progress.as_ref(),
2608                )
2609                .await
2610            }
2611            ExternalPaymentInfo::WaveBatch { .. } => Err(Error::Payment(
2612                "Cannot finalize wave-batch upload with merkle winner hashes. \
2613                 Use finalize_upload_resumable() instead."
2614                    .to_string(),
2615            )),
2616        }
2617    }
2618
2619    /// Finalize an external-signer wave-batch upload, returning a resume handle
2620    /// if some chunks remain unstored after retries.
2621    ///
2622    /// Behaves like [`Client::finalize_upload`], but instead of surfacing a
2623    /// storage failure as [`Error::PartialUpload`] it returns
2624    /// [`FinalizeOutcome::Partial`], carrying a [`WaveFinalizeResume`] (inside
2625    /// [`FinalizeResume::Wave`]) that owns the already-paid chunks still needing
2626    /// storage. The caller can hand that handle to [`Client::finalize_resume`]
2627    /// to re-store only those chunks against the **same** on-chain payment — no
2628    /// re-quoting, no second signature, no double payment (#140).
2629    ///
2630    /// # Errors
2631    ///
2632    /// Returns an error if a `tx_hash` is missing for a quote, the payment info
2633    /// is merkle rather than wave-batch, or payment finalization fails. A plain
2634    /// storage shortfall is **not** an error — it comes back as
2635    /// [`FinalizeOutcome::Partial`].
2636    pub async fn finalize_upload_resumable(
2637        &self,
2638        prepared: PreparedUpload,
2639        tx_hash_map: &HashMap<QuoteHash, TxHash>,
2640    ) -> Result<FinalizeOutcome> {
2641        self.finalize_upload_resumable_with_progress(prepared, tx_hash_map, None)
2642            .await
2643    }
2644
2645    /// Same as [`Client::finalize_upload_resumable`] but emits
2646    /// [`UploadEvent::ChunkStored`] on the provided channel as each chunk is
2647    /// stored.
2648    ///
2649    /// # Errors
2650    ///
2651    /// Same as [`Client::finalize_upload_resumable`].
2652    pub async fn finalize_upload_resumable_with_progress(
2653        &self,
2654        prepared: PreparedUpload,
2655        tx_hash_map: &HashMap<QuoteHash, TxHash>,
2656        progress: Option<mpsc::Sender<UploadEvent>>,
2657    ) -> Result<FinalizeOutcome> {
2658        let data_map_address = prepared.data_map_address;
2659        let already_stored_count = prepared.already_stored_addresses.len();
2660        let total_chunks = prepared.total_chunks;
2661        let data_map = prepared.data_map;
2662        match prepared.payment_info {
2663            ExternalPaymentInfo::WaveBatch {
2664                prepared_chunks,
2665                payment_intent,
2666            } => {
2667                let paid_chunks = finalize_batch_payment(prepared_chunks, tx_hash_map)?;
2668                let storage_cost_atto = payment_intent.total_amount.to_string();
2669                Ok(self
2670                    .drive_wave_finalize(
2671                        data_map,
2672                        data_map_address,
2673                        total_chunks,
2674                        already_stored_count,
2675                        paid_chunks,
2676                        storage_cost_atto,
2677                        progress.as_ref(),
2678                    )
2679                    .await)
2680            }
2681            ExternalPaymentInfo::Merkle { .. } => Err(Error::Payment(
2682                "Cannot finalize merkle upload with wave-batch tx hashes. \
2683                 Use finalize_upload_merkle_multi_resumable() instead."
2684                    .to_string(),
2685            )),
2686        }
2687    }
2688
2689    /// Resume an external-signer finalize that returned
2690    /// [`FinalizeOutcome::Partial`], storing only the still-unstored chunks
2691    /// against the already-signed payment carried by the [`FinalizeResume`]
2692    /// handle.
2693    ///
2694    /// No re-quoting and no new signature: the handle owns the retained chunk
2695    /// bodies (wave path) or the spill + merkle proofs (merkle path). Every
2696    /// chunk in the handle has its payment material, so the upload always
2697    /// *can* complete once the network cooperates. Safe to call repeatedly —
2698    /// each call stores what it can and either completes the upload
2699    /// ([`FinalizeOutcome::Complete`]) or hands back the remainder, so a
2700    /// caller can loop until it drains or gives up (#140).
2701    ///
2702    /// **Bound that loop.** Store failures — including persistent ones, such
2703    /// as a chunk whose close group stays unreachable — surface as
2704    /// [`FinalizeOutcome::Partial`] on every call, never as `Err`, so an
2705    /// unbounded `while let Partial` loop will spin for as long as the
2706    /// failure persists. Cap the attempts (or apply backoff between them) and
2707    /// treat a handle that stops shrinking as stuck.
2708    ///
2709    /// # Errors
2710    ///
2711    /// Store failures are not errors — every store-side outcome, fatal aborts
2712    /// included, comes back as [`FinalizeOutcome::Partial`] with the payment
2713    /// material retained for retry. `Err` is reserved for failures outside
2714    /// the chunk store itself.
2715    pub async fn finalize_resume(&self, resume: FinalizeResume) -> Result<FinalizeOutcome> {
2716        self.finalize_resume_with_progress(resume, None).await
2717    }
2718
2719    /// Same as [`Client::finalize_resume`] but emits [`UploadEvent::ChunkStored`]
2720    /// as each remaining chunk is stored.
2721    ///
2722    /// # Errors
2723    ///
2724    /// Same as [`Client::finalize_resume`].
2725    pub async fn finalize_resume_with_progress(
2726        &self,
2727        resume: FinalizeResume,
2728        progress: Option<mpsc::Sender<UploadEvent>>,
2729    ) -> Result<FinalizeOutcome> {
2730        match resume {
2731            FinalizeResume::Wave(w) => {
2732                let WaveFinalizeResume {
2733                    data_map,
2734                    data_map_address,
2735                    total_chunks,
2736                    stored_count,
2737                    failed_paid_chunks,
2738                    storage_cost_atto,
2739                } = *w;
2740                Ok(self
2741                    .drive_wave_finalize(
2742                        data_map,
2743                        data_map_address,
2744                        total_chunks,
2745                        stored_count,
2746                        failed_paid_chunks,
2747                        storage_cost_atto,
2748                        progress.as_ref(),
2749                    )
2750                    .await)
2751            }
2752            FinalizeResume::Merkle(m) => {
2753                let MerkleFinalizeResume {
2754                    data_map,
2755                    data_map_address,
2756                    total_chunks,
2757                    chunk_store,
2758                    unstored_addresses,
2759                    batch_result,
2760                    stored_addresses,
2761                } = *m;
2762                self.drive_merkle_finalize(
2763                    data_map,
2764                    data_map_address,
2765                    total_chunks,
2766                    chunk_store,
2767                    unstored_addresses,
2768                    batch_result,
2769                    stored_addresses,
2770                    progress.as_ref(),
2771                )
2772                .await
2773            }
2774        }
2775    }
2776
2777    /// Drive one merkle store pass over `to_store` (reading bodies from the
2778    /// spill on demand and re-attaching proofs from `batch_result`), shared by
2779    /// the initial resumable finalize and [`Client::finalize_resume`].
2780    ///
2781    /// On a quorum shortfall — or a fatal store abort, which
2782    /// `upload_merkle_from_spill` folds into [`Error::PartialUpload`] with its
2783    /// progress preserved — it captures the retained spill, proofs, and the
2784    /// cumulative stored/unstored sets into a [`MerkleFinalizeResume`] and
2785    /// returns [`FinalizeOutcome::Partial`], so the same on-chain payment can
2786    /// be retried without re-signing. `Err` is reserved for failures outside
2787    /// the store fan-out (e.g. invalid payment material).
2788    #[allow(clippy::too_many_arguments)]
2789    async fn drive_merkle_finalize(
2790        &self,
2791        data_map: DataMap,
2792        data_map_address: Option<[u8; 32]>,
2793        total_chunks: usize,
2794        chunk_store: ExternalChunkStore,
2795        to_store: Vec<[u8; 32]>,
2796        batch_result: MerkleBatchPaymentResult,
2797        stored_addresses: Vec<[u8; 32]>,
2798        progress: Option<&mpsc::Sender<UploadEvent>>,
2799    ) -> Result<FinalizeOutcome> {
2800        let store_result = self
2801            .upload_merkle_from_spill(
2802                chunk_store.spill(),
2803                &to_store,
2804                &batch_result,
2805                &stored_addresses,
2806                progress,
2807                // External-signer payment material; see above.
2808                None,
2809            )
2810            .await;
2811        assemble_merkle_finalize_outcome(
2812            store_result,
2813            data_map,
2814            data_map_address,
2815            total_chunks,
2816            chunk_store,
2817            batch_result,
2818        )
2819    }
2820
2821    /// Drive one wave-batch store pass over `paid_chunks`, shared by the initial
2822    /// resumable finalize and [`Client::finalize_resume`].
2823    ///
2824    /// Retains each paid chunk (cheaply — bodies are ref-counted `Bytes`) so a
2825    /// storage shortfall can hand the failed subset back in a
2826    /// [`WaveFinalizeResume`] ([`FinalizeOutcome::Partial`]) for re-store against
2827    /// the same payment, instead of the shortfall being dropped. The store never
2828    /// errors on a partial, so this is infallible.
2829    #[allow(clippy::too_many_arguments)]
2830    async fn drive_wave_finalize(
2831        &self,
2832        data_map: DataMap,
2833        data_map_address: Option<[u8; 32]>,
2834        total_chunks: usize,
2835        already_stored_count: usize,
2836        paid_chunks: Vec<PaidChunk>,
2837        storage_cost_atto: String,
2838        progress: Option<&mpsc::Sender<UploadEvent>>,
2839    ) -> FinalizeOutcome {
2840        // Retain address -> paid chunk so the failed subset can be re-stored on
2841        // resume; cloning is cheap since the chunk body is a ref-counted `Bytes`.
2842        let retained: HashMap<[u8; 32], PaidChunk> =
2843            paid_chunks.iter().map(|c| (c.address, c.clone())).collect();
2844        let wave_result = self
2845            .store_paid_chunks_with_events(
2846                paid_chunks,
2847                progress,
2848                already_stored_count,
2849                total_chunks,
2850            )
2851            .await;
2852        assemble_wave_finalize_outcome(
2853            wave_result,
2854            retained,
2855            data_map,
2856            data_map_address,
2857            total_chunks,
2858            already_stored_count,
2859            storage_cost_atto,
2860        )
2861    }
2862
2863    /// Upload a file with a specific payment mode.
2864    ///
2865    /// Before encryption, checks that the temp directory has enough free
2866    /// disk space for the spilled chunks (~1.1× source file size).
2867    ///
2868    /// Encrypted chunks are spilled to a temp directory during encryption
2869    /// so that only their 32-byte addresses stay in memory. At upload time,
2870    /// chunks are read back one wave at a time (~64 × 4 MB ≈ 256 MB peak).
2871    ///
2872    /// # Errors
2873    ///
2874    /// Returns an error if there is insufficient disk space, the file cannot
2875    /// be read, encryption fails, or any chunk cannot be stored.
2876    #[allow(clippy::too_many_lines)]
2877    pub async fn file_upload_with_mode(
2878        &self,
2879        path: &Path,
2880        mode: PaymentMode,
2881    ) -> Result<FileUploadResult> {
2882        self.file_upload_with_progress(path, mode, None).await
2883    }
2884
2885    /// Upload a file publicly, storing the serialized [`DataMap`] as part of
2886    /// the same upload payment batch.
2887    ///
2888    /// The returned [`FileUploadResult::data_map_address`] can be shared for
2889    /// public downloads via [`Client::data_map_fetch`].
2890    #[allow(clippy::too_many_lines)]
2891    pub async fn file_upload_public_with_mode(
2892        &self,
2893        path: &Path,
2894        mode: PaymentMode,
2895    ) -> Result<FileUploadResult> {
2896        self.file_upload_with_visibility_and_progress(path, mode, Visibility::Public, None)
2897            .await
2898    }
2899
2900    /// Upload a file with progress events sent to the given channel.
2901    ///
2902    /// Same as [`Client::file_upload_with_mode`] but sends [`UploadEvent`]s to the
2903    /// provided channel for UI progress feedback.
2904    #[allow(clippy::too_many_lines)]
2905    pub async fn file_upload_with_progress(
2906        &self,
2907        path: &Path,
2908        mode: PaymentMode,
2909        progress: Option<mpsc::Sender<UploadEvent>>,
2910    ) -> Result<FileUploadResult> {
2911        self.file_upload_with_visibility_and_progress(path, mode, Visibility::Private, progress)
2912            .await
2913    }
2914
2915    /// Public file upload with progress events.
2916    ///
2917    /// Same as [`Client::file_upload_public_with_mode`] but sends
2918    /// [`UploadEvent`]s to the provided channel for UI progress feedback.
2919    #[allow(clippy::too_many_lines)]
2920    pub async fn file_upload_public_with_progress(
2921        &self,
2922        path: &Path,
2923        mode: PaymentMode,
2924        progress: Option<mpsc::Sender<UploadEvent>>,
2925    ) -> Result<FileUploadResult> {
2926        self.file_upload_with_visibility_and_progress(path, mode, Visibility::Public, progress)
2927            .await
2928    }
2929
2930    #[allow(clippy::too_many_lines)]
2931    async fn file_upload_with_visibility_and_progress(
2932        &self,
2933        path: &Path,
2934        mode: PaymentMode,
2935        visibility: Visibility,
2936        progress: Option<mpsc::Sender<UploadEvent>>,
2937    ) -> Result<FileUploadResult> {
2938        debug!(
2939            "Streaming file upload with mode {mode:?}, visibility {visibility:?}: {}",
2940            path.display()
2941        );
2942
2943        // Pre-flight: verify enough temp disk space for the chunk spill.
2944        let file_size = std::fs::metadata(path)?.len();
2945        check_disk_space_for_spill(file_size)?;
2946
2947        // Phase 1: Encrypt file and spill chunks to temp directory.
2948        // Only 32-byte addresses stay in memory — chunk data lives on disk.
2949        let (mut spill, data_map) = self.encrypt_file_to_spill(path, progress.as_ref()).await?;
2950
2951        let data_map_address = match visibility {
2952            Visibility::Private => None,
2953            Visibility::Public => {
2954                let serialized = rmp_serde::to_vec(&data_map).map_err(|e| {
2955                    Error::Serialization(format!("Failed to serialize DataMap: {e}"))
2956                })?;
2957                let address = compute_address(&serialized);
2958                info!(
2959                    "Public upload: adding DataMap chunk ({} bytes) at address {} to payment batch",
2960                    serialized.len(),
2961                    hex::encode(address)
2962                );
2963                spill.push(&serialized)?;
2964                Some(address)
2965            }
2966        };
2967
2968        let chunk_count = spill.len();
2969        info!(
2970            "Encrypted {} into {chunk_count} chunks (spilled to disk)",
2971            path.display()
2972        );
2973        if let Some(ref tx) = progress {
2974            let _ = tx
2975                .send(UploadEvent::Encrypted {
2976                    total_chunks: chunk_count,
2977                })
2978                .await;
2979        }
2980
2981        // Phase 2: Decide payment mode and upload in waves from disk.
2982        //
2983        // For the merkle path, attempt to resume from a cached
2984        // receipt before paying again. The cache is keyed by the
2985        // CANONICAL source path so `./foo`, `/abs/foo`, and any
2986        // symlink alias all resolve to the same cache entry — a
2987        // crash-and-retry from a different cwd or via a different
2988        // alias still hits the receipt. Canonicalize may fail (the
2989        // file could have been moved between phase 1 and here); we
2990        // fall back to the display string in that case, which
2991        // preserves pre-fix behaviour rather than dropping cache
2992        // resume entirely.
2993        let file_path_key = std::fs::canonicalize(path)
2994            .map(|p| p.display().to_string())
2995            .unwrap_or_else(|_| path.display().to_string());
2996        let (chunks_stored, actual_mode, storage_cost_atto, gas_cost_wei, stats) = if self
2997            .should_use_merkle(chunk_count, mode)
2998        {
2999            info!("Using merkle batch payment for {chunk_count} file chunks");
3000
3001            let cached_merkle =
3002                crate::data::client::cached_merkle::try_load_for_file(&file_path_key)
3003                    .map(|(_cache_path, cached)| cached);
3004
3005            let merkle_plan = match self
3006                .plan_merkle_upload(spill.chunk_entries()?, DATA_TYPE_CHUNK, progress.as_ref())
3007                .await
3008            {
3009                Ok(plan) => plan,
3010                Err(e) => {
3011                    if let Some(cached) = cached_merkle
3012                        .as_ref()
3013                        .filter(|cached| cached_merkle_covers_addresses(cached, &spill.addresses))
3014                    {
3015                        info!(
3016                            "Merkle preflight failed ({e}); \
3017                             resuming with cached merkle proofs"
3018                        );
3019                        let (stored, sc, gc, stats) = self
3020                            .upload_merkle_from_spill(
3021                                &spill,
3022                                &spill.addresses,
3023                                cached,
3024                                &[],
3025                                progress.as_ref(),
3026                                // Resumed from a cached receipt: no payment ran
3027                                // on this pass, and the cache is only reused
3028                                // when it covers every address.
3029                                None,
3030                            )
3031                            .await?;
3032                        crate::data::client::cached_merkle::try_delete_for_file(&file_path_key);
3033                        return Ok(FileUploadResult {
3034                            data_map,
3035                            chunks_stored: stored,
3036                            chunks_failed: 0,
3037                            total_chunks: chunk_count,
3038                            payment_mode_used: PaymentMode::Merkle,
3039                            storage_cost_atto: sc,
3040                            gas_cost_wei: gc,
3041                            data_map_address,
3042                            chunk_attempts_total: stats.chunk_attempts_total,
3043                            store_durations_ms: stats.store_durations_ms,
3044                            retries_histogram: stats.retries_histogram,
3045                        });
3046                    }
3047                    match &e {
3048                        Error::InsufficientPeers(msg) if mode == PaymentMode::Auto => {
3049                            info!(
3050                                "Merkle preflight needs more peers ({msg}), \
3051                                 falling back to wave-batch"
3052                            );
3053                            let (stored, sc, gc, fb_stats) = self
3054                                .upload_waves_single(
3055                                    &spill,
3056                                    progress.as_ref(),
3057                                    Some(&file_path_key),
3058                                )
3059                                .await?;
3060                            crate::data::client::cached_single::try_delete_for_file(&file_path_key);
3061                            return Ok(FileUploadResult {
3062                                data_map,
3063                                chunks_stored: stored,
3064                                chunks_failed: 0,
3065                                total_chunks: chunk_count,
3066                                payment_mode_used: PaymentMode::Single,
3067                                storage_cost_atto: sc,
3068                                gas_cost_wei: gc,
3069                                data_map_address,
3070                                chunk_attempts_total: fb_stats.chunk_attempts_total,
3071                                store_durations_ms: fb_stats.store_durations_ms,
3072                                retries_histogram: fb_stats.retries_histogram,
3073                            });
3074                        }
3075                        _ => return Err(e),
3076                    }
3077                }
3078            };
3079
3080            if merkle_plan.to_upload.is_empty() {
3081                info!("All {chunk_count} merkle chunks already stored; skipping payment");
3082                crate::data::client::cached_merkle::try_delete_for_file(&file_path_key);
3083                crate::data::client::cached_single::try_delete_for_file(&file_path_key);
3084                (
3085                    chunk_count,
3086                    PaymentMode::Merkle,
3087                    "0".to_string(),
3088                    0,
3089                    WaveAggregateStats::default(),
3090                )
3091            } else if !self.should_use_merkle(merkle_plan.to_upload.len(), mode) {
3092                let remaining_chunks = merkle_plan.to_upload.len();
3093                if let Some(cached) = cached_merkle
3094                    .as_ref()
3095                    .filter(|cached| cached_merkle_covers_addresses(cached, &merkle_plan.to_upload))
3096                {
3097                    info!(
3098                        "{remaining_chunks} chunks remain below merkle threshold; \
3099                         reusing cached merkle proofs"
3100                    );
3101                    let (stored, sc, gc, stats) = self
3102                        .upload_merkle_from_spill(
3103                            &spill,
3104                            &merkle_plan.to_upload,
3105                            cached,
3106                            &merkle_plan.already_stored,
3107                            progress.as_ref(),
3108                            // Resumed from a cached receipt; see above.
3109                            None,
3110                        )
3111                        .await?;
3112                    crate::data::client::cached_merkle::try_delete_for_file(&file_path_key);
3113                    (stored, PaymentMode::Merkle, sc, gc, stats)
3114                } else {
3115                    if cached_merkle.is_some() {
3116                        info!(
3117                            "{remaining_chunks} chunks remain below merkle threshold, \
3118                             and the cached merkle receipt does not cover them. \
3119                             Discarding cache and using single-node payment."
3120                        );
3121                        crate::data::client::cached_merkle::try_delete_for_file(&file_path_key);
3122                    } else {
3123                        info!(
3124                            "{remaining_chunks} chunks need upload after merkle preflight; \
3125                             using single-node payment"
3126                        );
3127                    }
3128                    let (stored, sc, gc, stats) = self
3129                        .upload_spill_addresses_single(
3130                            &spill,
3131                            &merkle_plan.to_upload,
3132                            progress.as_ref(),
3133                            &merkle_plan.already_stored,
3134                            chunk_count,
3135                            Some(&file_path_key),
3136                        )
3137                        .await?;
3138                    crate::data::client::cached_single::try_delete_for_file(&file_path_key);
3139                    (stored, PaymentMode::Single, sc, gc, stats)
3140                }
3141            } else {
3142                let batch_result = if let Some(cached) = cached_merkle.as_ref() {
3143                    // Validate the cache against the chunks that still need
3144                    // storage. Extra proofs are harmless: a previous attempt
3145                    // may have paid for chunks that are now already stored.
3146                    if cached_merkle_covers_addresses(cached, &merkle_plan.to_upload) {
3147                        info!(
3148                            "Skipping merkle payment phase; resuming with \
3149                             cached proofs for {} remaining chunks",
3150                            merkle_plan.to_upload.len()
3151                        );
3152                        Ok(cached.clone())
3153                    } else {
3154                        info!(
3155                            "Cached merkle receipt does not cover the current \
3156                             remaining chunks (cached={}, remaining={}). \
3157                             Discarding cache and paying fresh.",
3158                            cached.proofs.len(),
3159                            merkle_plan.to_upload.len()
3160                        );
3161                        crate::data::client::cached_merkle::try_delete_for_file(&file_path_key);
3162                        self.pay_for_merkle_batch(
3163                            &merkle_plan.to_upload,
3164                            DATA_TYPE_CHUNK,
3165                            merkle_plan.to_upload_avg_size(),
3166                        )
3167                        .await
3168                        .inspect(|result| {
3169                            crate::data::client::cached_merkle::try_save(&file_path_key, result);
3170                        })
3171                    }
3172                } else {
3173                    self.pay_for_merkle_batch(
3174                        &merkle_plan.to_upload,
3175                        DATA_TYPE_CHUNK,
3176                        merkle_plan.to_upload_avg_size(),
3177                    )
3178                    .await
3179                    .inspect(|result| {
3180                        // Save BEFORE the store phase so a crash
3181                        // mid-upload leaves a resumable receipt.
3182                        crate::data::client::cached_merkle::try_save(&file_path_key, result);
3183                    })
3184                };
3185
3186                let batch_result = match batch_result {
3187                    Ok(result) => result,
3188                    Err(Error::InsufficientPeers(ref msg)) if mode == PaymentMode::Auto => {
3189                        info!("Merkle needs more peers ({msg}), falling back to wave-batch");
3190                        let (stored, sc, gc, fb_stats) = self
3191                            .upload_spill_addresses_single(
3192                                &spill,
3193                                &merkle_plan.to_upload,
3194                                progress.as_ref(),
3195                                &merkle_plan.already_stored,
3196                                chunk_count,
3197                                Some(&file_path_key),
3198                            )
3199                            .await?;
3200                        crate::data::client::cached_single::try_delete_for_file(&file_path_key);
3201                        return Ok(FileUploadResult {
3202                            data_map,
3203                            chunks_stored: stored,
3204                            chunks_failed: 0,
3205                            total_chunks: chunk_count,
3206                            payment_mode_used: PaymentMode::Single,
3207                            storage_cost_atto: sc,
3208                            gas_cost_wei: gc,
3209                            data_map_address,
3210                            chunk_attempts_total: fb_stats.chunk_attempts_total,
3211                            store_durations_ms: fb_stats.store_durations_ms,
3212                            retries_histogram: fb_stats.retries_histogram,
3213                        });
3214                    }
3215                    Err(e) => return Err(e),
3216                };
3217
3218                // Read straight after this upload's own payment returned. A
3219                // multi-sub-batch payment that is refused part-way hands back
3220                // a partial receipt rather than an error, so the verdict that
3221                // stopped it reaches the store path only here. The latch is
3222                // empty unless this payment set it: `pay_for_merkle_batch`
3223                // refuses before spending whenever one is already present.
3224                let payment_refusal = self.corroborated_settlement_refusal();
3225
3226                let (stored, sc, gc, stats) = self
3227                    .upload_merkle_from_spill(
3228                        &spill,
3229                        &merkle_plan.to_upload,
3230                        &batch_result,
3231                        &merkle_plan.already_stored,
3232                        progress.as_ref(),
3233                        payment_refusal.as_deref(),
3234                    )
3235                    .await?;
3236                // Upload succeeded end-to-end; the cached receipt is
3237                // no longer needed.
3238                crate::data::client::cached_merkle::try_delete_for_file(&file_path_key);
3239                (stored, PaymentMode::Merkle, sc, gc, stats)
3240            }
3241        } else {
3242            let (stored, sc, gc, stats) = self
3243                .upload_waves_single(&spill, progress.as_ref(), Some(&file_path_key))
3244                .await?;
3245            // Full file success: drop any cached single-node receipt.
3246            crate::data::client::cached_single::try_delete_for_file(&file_path_key);
3247            (stored, PaymentMode::Single, sc, gc, stats)
3248        };
3249
3250        info!(
3251            "File uploaded with {actual_mode:?}: {chunks_stored} chunks stored ({})",
3252            path.display()
3253        );
3254
3255        Ok(FileUploadResult {
3256            data_map,
3257            chunks_stored,
3258            chunks_failed: 0,
3259            total_chunks: chunk_count,
3260            payment_mode_used: actual_mode,
3261            storage_cost_atto,
3262            gas_cost_wei,
3263            data_map_address,
3264            chunk_attempts_total: stats.chunk_attempts_total,
3265            store_durations_ms: stats.store_durations_ms,
3266            retries_histogram: stats.retries_histogram,
3267        })
3268    }
3269
3270    /// Encrypt a file and spill chunks to a temp directory.
3271    ///
3272    /// Logs progress every 100 chunks so users get feedback during
3273    /// multi-GB encryptions.
3274    ///
3275    /// Returns the spill buffer (addresses on disk) and the `DataMap`.
3276    async fn encrypt_file_to_spill(
3277        &self,
3278        path: &Path,
3279        progress: Option<&mpsc::Sender<UploadEvent>>,
3280    ) -> Result<(ChunkSpill, DataMap)> {
3281        let (mut chunk_rx, datamap_rx, handle) = spawn_file_encryption(path.to_path_buf())?;
3282
3283        let mut spill = ChunkSpill::new()?;
3284        while let Some(content) = chunk_rx.recv().await {
3285            spill.push(&content)?;
3286            let chunks_done = spill.len();
3287            if let Some(tx) = progress {
3288                if chunks_done.is_multiple_of(10) {
3289                    let _ = tx.send(UploadEvent::Encrypting { chunks_done }).await;
3290                }
3291            }
3292            if chunks_done % 100 == 0 {
3293                let mb = spill.total_bytes() / (1024 * 1024);
3294                info!(
3295                    "Encryption progress: {chunks_done} chunks spilled ({mb} MB) — {}",
3296                    path.display()
3297                );
3298            }
3299        }
3300
3301        // Await encryption completion to catch errors before paying.
3302        handle
3303            .await
3304            .map_err(|e| Error::Encryption(format!("encryption task panicked: {e}")))?
3305            .map_err(|e| Error::Encryption(format!("encryption failed: {e}")))?;
3306
3307        let data_map = datamap_rx
3308            .await
3309            .map_err(|_| Error::Encryption("no DataMap from encryption thread".to_string()))?;
3310
3311        Ok((spill, data_map))
3312    }
3313
3314    /// Upload chunks from a spill using wave-based per-chunk (single) payments.
3315    ///
3316    /// Reads one wave at a time from disk, prepares quotes, pays, and stores.
3317    /// Peak memory: ~`UPLOAD_WAVE_SIZE × MAX_CHUNK_SIZE` (~256 MB).
3318    ///
3319    /// Returns `(chunks_stored, storage_cost_atto, gas_cost_wei)`.
3320    async fn upload_waves_single(
3321        &self,
3322        spill: &ChunkSpill,
3323        progress: Option<&mpsc::Sender<UploadEvent>>,
3324        resume_key: Option<&str>,
3325    ) -> Result<(usize, String, u128, WaveAggregateStats)> {
3326        self.upload_spill_addresses_single(
3327            spill,
3328            &spill.addresses,
3329            progress,
3330            &[],
3331            spill.len(),
3332            resume_key,
3333        )
3334        .await
3335    }
3336
3337    async fn upload_spill_addresses_single(
3338        &self,
3339        spill: &ChunkSpill,
3340        addresses: &[[u8; 32]],
3341        progress: Option<&mpsc::Sender<UploadEvent>>,
3342        already_stored_addresses: &[[u8; 32]],
3343        total_chunks: usize,
3344        resume_key: Option<&str>,
3345    ) -> Result<(usize, String, u128, WaveAggregateStats)> {
3346        let mut total_stored = already_stored_addresses.len();
3347        let mut total_storage = Amount::ZERO;
3348        let mut total_gas: u128 = 0;
3349        let mut agg_stats = WaveAggregateStats::default();
3350        // A wave whose chunks fall short of quorum after retries must not abort
3351        // the file: its failures are accumulated here and surfaced as a single
3352        // `PartialUpload` only after every wave has been attempted, mirroring
3353        // `upload_merkle_from_spill`. Aborting on the first failed wave (the old `?`)
3354        // discarded all later waves' progress — already self-encrypted, spilled,
3355        // and in some cases already paid for — converting high per-chunk success
3356        // into 0% per-file success.
3357        // Seed with the addresses a preflight already confirmed stored (e.g.
3358        // the merkle-fallback path passes `merkle_plan.already_stored`), so a
3359        // returned `PartialUpload.stored` lists every stored chunk and
3360        // `stored_count == stored.len()` holds for programmatic callers.
3361        let mut stored_addresses: Vec<[u8; 32]> = already_stored_addresses.to_vec();
3362        let mut failed: Vec<([u8; 32], String)> = Vec::new();
3363        let waves: Vec<&[[u8; 32]]> = addresses.chunks(UPLOAD_WAVE_SIZE).collect();
3364        let wave_count = waves.len();
3365
3366        // Unconditional breadcrumb: lets a clean run confirm the continue-on-
3367        // partial single-node path is in effect (the old path aborted the file
3368        // on the first failed wave instead of continuing across all waves).
3369        info!(
3370            "single-node upload: {} chunk(s) in {wave_count} wave(s) (continue-on-partial)",
3371            addresses.len()
3372        );
3373
3374        for (wave_idx, wave_addrs) in waves.into_iter().enumerate() {
3375            let wave_num = wave_idx + 1;
3376            let wave_data: Vec<Bytes> = wave_addrs
3377                .iter()
3378                .map(|addr| spill.read_chunk(addr))
3379                .collect::<Result<Vec<_>>>()?;
3380
3381            info!(
3382                "Wave {wave_num}/{wave_count}: quoting {} chunks — {total_stored}/{total_chunks} stored so far",
3383                wave_data.len()
3384            );
3385            if let Some(tx) = progress {
3386                let _ = tx
3387                    .send(UploadEvent::QuotingChunks {
3388                        wave: wave_num,
3389                        total_waves: wave_count,
3390                        chunks_in_wave: wave_data.len(),
3391                    })
3392                    .await;
3393            }
3394            // Fold this wave's result. A quorum shortfall (`PartialUpload`) is
3395            // recoverable and its parts are returned to be recorded here;
3396            // genuinely fatal errors abort the file, as in
3397            // `upload_merkle_from_spill`.
3398            let outcome = match fold_single_wave(
3399                self.batch_upload_chunks_with_events(
3400                    wave_data,
3401                    progress,
3402                    total_stored,
3403                    total_chunks,
3404                    resume_key,
3405                )
3406                .await,
3407            ) {
3408                Ok(outcome) => outcome,
3409                // A corroborated settlement refusal is terminal, but on any wave
3410                // after the first it lands after earlier waves have paid and
3411                // stored. Bare, it would report the upload as costing nothing
3412                // and lose the stored set; carry both instead. On the first
3413                // wave nothing has been paid, so the bare refusal — whose
3414                // wording says nothing was charged — is exactly right.
3415                Err(Error::ClientUpdateRequired(refusal)) if wave_idx > 0 => {
3416                    return Err(settlement_refusal_after_paid_waves(
3417                        &refusal,
3418                        wave_num,
3419                        wave_count,
3420                        stored_addresses,
3421                        total_stored,
3422                        &addresses[wave_idx * UPLOAD_WAVE_SIZE..],
3423                        total_chunks,
3424                        total_storage,
3425                        total_gas,
3426                    ));
3427                }
3428                Err(e) => return Err(e),
3429            };
3430
3431            if !outcome.failed.is_empty() {
3432                warn!(
3433                    "Wave {wave_num}/{wave_count}: {} chunk(s) failed to store after retries; \
3434                     continuing with remaining waves",
3435                    outcome.failed.len()
3436                );
3437            }
3438
3439            total_stored += outcome.stored.len();
3440            stored_addresses.extend(outcome.stored);
3441            failed.extend(outcome.failed);
3442            total_storage += outcome.storage_atto;
3443            total_gas = total_gas.saturating_add(outcome.gas_wei);
3444            // Merge per-wave stats (a quorum-short wave contributes none, since
3445            // `PartialUpload` carries no stats).
3446            agg_stats.chunk_attempts_total = agg_stats
3447                .chunk_attempts_total
3448                .saturating_add(outcome.stats.chunk_attempts_total);
3449            agg_stats
3450                .store_durations_ms
3451                .extend(outcome.stats.store_durations_ms);
3452            for (slot, count) in agg_stats
3453                .retries_histogram
3454                .iter_mut()
3455                .zip(outcome.stats.retries_histogram.iter())
3456            {
3457                *slot = slot.saturating_add(*count);
3458            }
3459        }
3460
3461        // Any chunk still failed after every wave was attempted means the file
3462        // is not fully stored — surface it as `PartialUpload` (never silently
3463        // succeed with missing chunks), carrying the real on-chain spend.
3464        if !failed.is_empty() {
3465            let failed_count = failed.len();
3466            warn!(
3467                "single-node upload incomplete: {failed_count}/{total_chunks} chunks failed after retries"
3468            );
3469            return Err(Error::PartialUpload {
3470                stored: stored_addresses,
3471                stored_count: total_stored,
3472                failed,
3473                failed_count,
3474                total_chunks,
3475                spend: Box::new(PartialUploadSpend {
3476                    storage_cost_atto: total_storage.to_string(),
3477                    gas_cost_wei: total_gas,
3478                }),
3479                reason: format!("{failed_count} chunk(s) failed to store after retries"),
3480            });
3481        }
3482
3483        Ok((
3484            total_stored,
3485            total_storage.to_string(),
3486            total_gas,
3487            agg_stats,
3488        ))
3489    }
3490
3491    /// Upload chunks from a spill using pre-computed merkle proofs.
3492    ///
3493    /// Stores the whole file as a **single cap-bounded fan-out** — not in fixed
3494    /// waves. The store concurrency limiter is the only throttle: `store_one`
3495    /// reads each chunk's body from the on-disk spill on demand, so at most
3496    /// `store_cap` (≤ 64) bodies are ever resident, giving the same
3497    /// `~store_cap × MAX_CHUNK_SIZE` peak-memory bound the old 64-chunk waves
3498    /// gave — but with **no wave barrier**, so a slow straggler (e.g. a chunk
3499    /// whose close-group peers are stale relayed addresses that take minutes to
3500    /// revalidate) no longer stalls the rest of the file behind it.
3501    ///
3502    /// A chunk that is transiently short of quorum (`InsufficientPeers` /
3503    /// `CloseGroupShortfall` / `RemotePut`) does **not** abort the file, nor
3504    /// block the pass: the store pass is a **single attempt** (no in-pass
3505    /// backoff), and quorum-short chunks are collected into a deferred set. After
3506    /// the pass, [`merkle_deferred_retry`] retries that set in concurrent rounds
3507    /// ([`DEFERRED_ROUND_DELAYS_SECS`] delays), re-reading each body from the
3508    /// spill and reusing its proof. Non-quorum errors (e.g. a missing proof)
3509    /// stay fatal and abort immediately.
3510    ///
3511    /// Returns `(chunks_stored, storage_cost_atto, gas_cost_wei)` on success.
3512    /// Costs come from the `batch_result` which was populated during payment.
3513    ///
3514    /// # Errors
3515    ///
3516    /// Returns [`Error::PartialUpload`] if any chunk is still short of quorum
3517    /// after the store pass and every deferred round (other chunks remain
3518    /// stored), or the underlying error for a non-quorum failure.
3519    async fn upload_merkle_from_spill(
3520        &self,
3521        spill: &ChunkSpill,
3522        addresses: &[[u8; 32]],
3523        batch_result: &MerkleBatchPaymentResult,
3524        already_stored_addresses: &[[u8; 32]],
3525        progress: Option<&mpsc::Sender<UploadEvent>>,
3526        payment_refusal: Option<&str>,
3527    ) -> Result<(usize, String, u128, WaveAggregateStats)> {
3528        let mut total_stored = already_stored_addresses.len();
3529        let total_chunks = total_stored + addresses.len();
3530        let mut stored_addresses: Vec<[u8; 32]> = already_stored_addresses.to_vec();
3531        let mut failed: Vec<([u8; 32], String)> = Vec::new();
3532        let mut agg_stats = WaveAggregateStats::default();
3533
3534        // Chunks without a merkle proof cannot be stored: a partial
3535        // `pay_for_merkle_multi_batch` result carries proofs only for the
3536        // sub-batches that both settled and produced proofs. Record them as
3537        // failed (surfaced via `PartialUpload` once the
3538        // storable chunks have been attempted) rather than letting its
3539        // "missing proof" error abort the whole file and discard every other
3540        // chunk's progress.
3541        let (to_store, missing_proof) =
3542            partition_addresses_by_proof(addresses, &batch_result.proofs);
3543        if !missing_proof.is_empty() {
3544            match payment_refusal {
3545                Some(reason) => warn!(
3546                    "{} chunk(s) lack a merkle proof ({reason}); reporting them as failed",
3547                    missing_proof.len()
3548                ),
3549                None => warn!(
3550                    "{} chunk(s) lack a merkle proof (partial payment); reporting them as failed",
3551                    missing_proof.len()
3552                ),
3553            }
3554            for addr in &missing_proof {
3555                let hex_addr = hex::encode(addr);
3556                failed.push((
3557                    *addr,
3558                    match payment_refusal {
3559                        Some(reason) => format!("No merkle proof for chunk {hex_addr}: {reason}"),
3560                        None => format!("Missing merkle proof for chunk {hex_addr}"),
3561                    },
3562                ));
3563            }
3564        }
3565
3566        let store_limiter = self.controller().store.clone();
3567
3568        // Store one chunk to its (freshly re-collected) close group, reusing the
3569        // chunk's merkle proof. Reads the body from the on-disk spill on demand,
3570        // so the whole-file store runs as ONE cap-bounded fan-out with no per-wave
3571        // barrier: a slow straggler (e.g. a chunk whose close-group peers are
3572        // stale relayed addresses that take minutes to revalidate) no longer
3573        // holds back the rest of the file. Only the ≤cap in-flight stores hold a
3574        // body, so peak resident memory is `cap × MAX_CHUNK_SIZE`; the cap is
3575        // clamped to `MERKLE_STORE_MAX_IN_FLIGHT` (below) so it stays within the
3576        // ~256 MiB bound the fixed 64-chunk waves gave even if `adaptive.max.store`
3577        // is configured above 64.
3578        // Shared across every deferred round so a converged routing table yields
3579        // a fresh group. Only a quorum shortfall is recoverable; a missing proof
3580        // or a failed spill read stays fatal. Mirrors `merkle_upload_chunks`.
3581        let store_one = |addr: [u8; 32]| {
3582            let limiter = store_limiter.clone();
3583            let proof_bytes = batch_result.proofs.get(&addr).cloned();
3584            async move {
3585                let started = std::time::Instant::now();
3586                let proof = proof_bytes.ok_or_else(|| {
3587                    Error::Payment(format!(
3588                        "Missing merkle proof for chunk {}",
3589                        hex::encode(addr)
3590                    ))
3591                })?;
3592                let content = spill.read_chunk(&addr)?;
3593                let peers = self.put_target_peers(&addr).await?;
3594                observe_op(
3595                    &limiter,
3596                    || async move { self.chunk_put_to_close_group(content, proof, &peers).await },
3597                    classify_error,
3598                )
3599                .await
3600                .map(|_| started)
3601            }
3602        };
3603
3604        info!(
3605            "Storing {} chunks (merkle) as a single cap-bounded pass — {total_stored}/{total_chunks} stored so far",
3606            to_store.len()
3607        );
3608
3609        // Store the WHOLE file in one cap-bounded fan-out (`max_attempts = 1`, no
3610        // backoff): no wave barrier, so a slow straggler (dead-relay peers) can't
3611        // hold back the rest of the file. The store cap re-reads the limiter per
3612        // slot, so it maxes at 64 → ≤64 bodies resident (bodies read from spill on
3613        // demand by `store_one`), the same peak-memory bound the fixed 64-chunk
3614        // waves gave. Quorum-short chunks are collected and deferred to the
3615        // post-pass concurrent retry rather than parking slots behind a backoff.
3616        // `merkle_store_cap` clamps to `MERKLE_STORE_MAX_IN_FLIGHT` so a high
3617        // configured `adaptive.max.store` can't hold more than the wave-era
3618        // ~256 MB of spilled bodies resident (PR #137 review).
3619        let cap = || merkle_store_cap(store_limiter.current());
3620        let outcome = merkle_store_with_retry(
3621            to_store.clone(),
3622            cap,
3623            1,
3624            std::time::Duration::ZERO,
3625            progress,
3626            total_stored,
3627            total_chunks,
3628            &store_one,
3629        )
3630        .await?;
3631
3632        // Record confirmed stores from the explicit set the store helper reports.
3633        // Using that set (rather than inferring "chunks minus failed") keeps
3634        // `stored_addresses` correct even when a fatal abort leaves some chunks
3635        // neither stored nor reported short of quorum.
3636        stored_addresses.extend(&outcome.stored_addresses);
3637        total_stored = outcome.stored;
3638
3639        // Merge store stats (durations, attempts, per-round histogram).
3640        agg_stats.chunk_attempts_total = agg_stats
3641            .chunk_attempts_total
3642            .saturating_add(outcome.stats.chunk_attempts_total);
3643        agg_stats
3644            .store_durations_ms
3645            .extend(outcome.stats.store_durations_ms);
3646        for (slot, count) in agg_stats
3647            .retries_histogram
3648            .iter_mut()
3649            .zip(outcome.stats.retries_histogram.iter())
3650        {
3651            *slot = slot.saturating_add(*count);
3652        }
3653
3654        if let Some(e) = outcome.fatal {
3655            // A non-quorum store error is fatal (missing proofs were filtered out
3656            // above, so this is a genuine network/store failure). Preserve every
3657            // chunk stored so far and report every not-stored chunk as failed, so
3658            // the `PartialUpload` counts are accurate.
3659            warn!("merkle store aborted: {e}");
3660            let mut known_failed = failed;
3661            known_failed.extend(outcome.failed_addresses);
3662            return Err(partial_upload_after_fatal(
3663                addresses,
3664                stored_addresses,
3665                total_stored,
3666                total_chunks,
3667                known_failed,
3668                PartialUploadSpend {
3669                    storage_cost_atto: batch_result.storage_cost_atto.clone(),
3670                    gas_cost_wei: batch_result.gas_cost_wei,
3671                },
3672                merkle_fatal_reason(
3673                    &format!("merkle chunk store aborted: {e}"),
3674                    missing_proof.len(),
3675                    payment_refusal,
3676                ),
3677            ));
3678        }
3679
3680        // Non-fatal: quorum-short chunks are deferred (not failed yet) for the
3681        // post-pass concurrent retry. A deferred chunk joins `stored_addresses`
3682        // only if/when a later round stores it.
3683        let deferred: Vec<([u8; 32], String)> = outcome.failed_addresses;
3684
3685        // The store pass never blocked on backoff; now retry the deferred set in
3686        // concurrent rounds. Bodies are re-read from the spill by `store_one`
3687        // (peak RAM unchanged) and proofs re-attached. Chunks still short after
3688        // the final round become `failed`; a non-quorum error aborts as
3689        // `PartialUpload`.
3690        if !deferred.is_empty() {
3691            info!(
3692                "Deferring {} merkle chunk(s) short of quorum for concurrent retry after the store pass",
3693                deferred.len()
3694            );
3695            let dr = merkle_deferred_retry(
3696                deferred,
3697                &DEFERRED_ROUND_DELAYS_SECS,
3698                |n: usize| merkle_store_cap(store_limiter.current()).min(n.max(1)),
3699                progress,
3700                total_stored,
3701                total_chunks,
3702                &store_one,
3703            )
3704            .await?;
3705
3706            stored_addresses.extend(dr.stored_addresses);
3707            total_stored = dr.stored;
3708
3709            // Merge the deferred pass's stats — its histogram is already mapped
3710            // to the right per-round slots — into the file aggregate.
3711            agg_stats.chunk_attempts_total = agg_stats
3712                .chunk_attempts_total
3713                .saturating_add(dr.stats.chunk_attempts_total);
3714            agg_stats
3715                .store_durations_ms
3716                .extend(dr.stats.store_durations_ms);
3717            for (slot, count) in agg_stats
3718                .retries_histogram
3719                .iter_mut()
3720                .zip(dr.stats.retries_histogram.iter())
3721            {
3722                *slot = slot.saturating_add(*count);
3723            }
3724
3725            if let Some(reason) = dr.fatal {
3726                // A non-quorum store error during a deferred round is fatal, the
3727                // same as in the wave path: preserve everything stored so far and
3728                // report every not-stored chunk as failed.
3729                warn!("merkle deferred retry aborted: {reason}");
3730                let mut known_failed = failed;
3731                known_failed.extend(dr.failed_addresses);
3732                return Err(partial_upload_after_fatal(
3733                    addresses,
3734                    stored_addresses,
3735                    total_stored,
3736                    total_chunks,
3737                    known_failed,
3738                    PartialUploadSpend {
3739                        storage_cost_atto: batch_result.storage_cost_atto.clone(),
3740                        gas_cost_wei: batch_result.gas_cost_wei,
3741                    },
3742                    merkle_fatal_reason(
3743                        &format!("merkle chunk store aborted: {reason}"),
3744                        missing_proof.len(),
3745                        payment_refusal,
3746                    ),
3747                ));
3748            }
3749            failed.extend(dr.failed_addresses);
3750        }
3751
3752        // A file with any permanently-failed chunk is not fully stored — surface
3753        // it as `PartialUpload`, but only after the store pass and every deferred
3754        // retry round are exhausted (never silently succeed with missing chunks).
3755        if !failed.is_empty() {
3756            let failed_count = failed.len();
3757            let total_attempts = 1 + DEFERRED_ROUND_DELAYS_SECS.len();
3758            let reason = merkle_partial_reason(
3759                failed_count,
3760                missing_proof.len(),
3761                total_attempts,
3762                payment_refusal,
3763            );
3764            warn!(
3765                "merkle upload incomplete: {failed_count}/{total_chunks} chunks failed — {reason}"
3766            );
3767            return Err(Error::PartialUpload {
3768                stored: stored_addresses,
3769                stored_count: total_stored,
3770                failed,
3771                failed_count,
3772                total_chunks,
3773                spend: Box::new(PartialUploadSpend {
3774                    storage_cost_atto: batch_result.storage_cost_atto.clone(),
3775                    gas_cost_wei: batch_result.gas_cost_wei,
3776                }),
3777                reason,
3778            });
3779        }
3780
3781        Ok((
3782            total_stored,
3783            batch_result.storage_cost_atto.clone(),
3784            batch_result.gas_cost_wei,
3785            agg_stats,
3786        ))
3787    }
3788
3789    /// Download and decrypt a file from the network, writing it to disk.
3790    ///
3791    /// Uses `streaming_decrypt` so that only one batch of chunks lives in
3792    /// memory at a time, avoiding OOM on large files. Chunks are fetched
3793    /// concurrently within each batch, then decrypted data is written to
3794    /// disk incrementally.
3795    ///
3796    /// Returns the number of bytes written.
3797    ///
3798    /// # Panics
3799    ///
3800    /// Requires a multi-threaded Tokio runtime (`flavor = "multi_thread"`).
3801    /// Will panic if called from a `current_thread` runtime because
3802    /// `streaming_decrypt` takes a synchronous callback that must bridge
3803    /// back to async via `block_in_place`.
3804    ///
3805    /// # Errors
3806    ///
3807    /// Returns an error if any chunk cannot be retrieved, decryption fails,
3808    /// or the file cannot be written.
3809    pub async fn file_download(&self, data_map: &DataMap, output: &Path) -> Result<u64> {
3810        self.file_download_with_progress(data_map, output, None)
3811            .await
3812    }
3813
3814    /// Download and decrypt a file, trying the requested number of
3815    /// closest peers for every chunk fetch.
3816    ///
3817    /// Returns the number of bytes written.
3818    ///
3819    /// # Errors
3820    ///
3821    /// Returns an error if any chunk cannot be retrieved, decryption fails,
3822    /// or the file cannot be written.
3823    pub async fn file_download_from_closest_peers(
3824        &self,
3825        data_map: &DataMap,
3826        output: &Path,
3827        peer_count: NonZeroUsize,
3828    ) -> Result<u64> {
3829        self.file_download_with_progress_from_closest_peers(data_map, output, None, peer_count)
3830            .await
3831    }
3832
3833    /// Download and decrypt a file with progress events, trying the
3834    /// requested number of closest peers for every chunk fetch.
3835    ///
3836    /// Same as [`Client::file_download_from_closest_peers`] but sends
3837    /// [`DownloadEvent`]s for UI feedback.
3838    ///
3839    /// # Errors
3840    ///
3841    /// Returns an error if any chunk cannot be retrieved, decryption fails,
3842    /// or the file cannot be written.
3843    pub async fn file_download_with_progress_from_closest_peers(
3844        &self,
3845        data_map: &DataMap,
3846        output: &Path,
3847        progress: Option<mpsc::Sender<DownloadEvent>>,
3848        peer_count: NonZeroUsize,
3849    ) -> Result<u64> {
3850        self.file_download_with_progress_using_peer_count(
3851            data_map,
3852            output,
3853            progress,
3854            peer_count.get(),
3855            None,
3856        )
3857        .await
3858    }
3859
3860    /// Download a file with progress and optional per-attempt JSONL diagnostics.
3861    ///
3862    /// Passing `None` preserves the standard path without diagnostic records.
3863    pub async fn file_download_with_progress_and_diagnostics_from_closest_peers(
3864        &self,
3865        data_map: &DataMap,
3866        output: &Path,
3867        progress: Option<mpsc::Sender<DownloadEvent>>,
3868        peer_count: NonZeroUsize,
3869        diagnostics: Option<DownloadDiagnosticsSender>,
3870    ) -> Result<u64> {
3871        self.file_download_with_progress_using_peer_count(
3872            data_map,
3873            output,
3874            progress,
3875            peer_count.get(),
3876            diagnostics,
3877        )
3878        .await
3879    }
3880
3881    /// Download and decrypt a file with peer-health diagnostics.
3882    ///
3883    /// Each file chunk is fetched by querying every selected closest peer,
3884    /// not by returning after the first successful peer. The returned report
3885    /// records which peers had each chunk and which did not. DataMap
3886    /// resolution still uses the normal early-return fetch path; diagnostics
3887    /// are for file chunks only.
3888    ///
3889    /// # Errors
3890    ///
3891    /// Returns an error if any chunk cannot be retrieved, decryption fails,
3892    /// or the file cannot be written.
3893    pub async fn file_download_with_peer_report_from_closest_peers(
3894        &self,
3895        data_map: &DataMap,
3896        output: &Path,
3897        progress: Option<mpsc::Sender<DownloadEvent>>,
3898        peer_count: NonZeroUsize,
3899    ) -> Result<FileDownloadWithPeerReport> {
3900        let chunk_reports = Arc::new(Mutex::new(Vec::new()));
3901        let bytes_written = self
3902            .file_download_with_progress_using_peer_count_and_reports(
3903                data_map,
3904                output,
3905                progress,
3906                peer_count.get(),
3907                Some(chunk_reports.clone()),
3908                None,
3909            )
3910            .await?;
3911
3912        let chunk_reports = chunk_reports
3913            .lock()
3914            .map_err(|_| Error::Storage("file chunk peer report lock poisoned".to_string()))?
3915            .clone();
3916        let chunk_reports = file_chunk_reports_from_recorded_sweeps(chunk_reports);
3917
3918        Ok(FileDownloadWithPeerReport {
3919            bytes_written,
3920            chunk_reports,
3921        })
3922    }
3923
3924    async fn download_fetch_file_chunk(
3925        &self,
3926        idx: usize,
3927        hash: XorName,
3928        context: FileDownloadFetchContext,
3929        is_deferred_retry: bool,
3930        attempt: usize,
3931    ) -> std::result::Result<DownloadBatchEntry, self_encryption::Error> {
3932        let addr = hash.0;
3933        let addr_hex = hex::encode(addr);
3934
3935        let chunk_content = if let Some(peer_reports) = context.peer_reports {
3936            match self
3937                .chunk_get_from_closest_peer_group(&addr, context.peer_count)
3938                .await
3939            {
3940                Ok(results) => {
3941                    let (content, sweep) = file_chunk_sweep_report_from_peer_results(
3942                        attempt,
3943                        is_deferred_retry,
3944                        &results,
3945                    );
3946                    peer_reports
3947                        .lock()
3948                        .map_err(|_| {
3949                            self_encryption::Error::Generic(
3950                                "file chunk peer report lock poisoned".to_string(),
3951                            )
3952                        })?
3953                        .push(RecordedFileChunkPeerSweep {
3954                            index: idx + 1,
3955                            address: addr,
3956                            sweep,
3957                        });
3958                    content
3959                }
3960                Err(e) => {
3961                    if is_deferred_retry {
3962                        info!(
3963                            "Deferred all-peer retry for {addr_hex} hit transient error: {e}; re-deferring"
3964                        );
3965                    } else {
3966                        info!("First-pass all-peer fetch error for {addr_hex}: {e}; deferring");
3967                    }
3968                    peer_reports
3969                        .lock()
3970                        .map_err(|_| {
3971                            self_encryption::Error::Generic(
3972                                "file chunk peer report lock poisoned".to_string(),
3973                            )
3974                        })?
3975                        .push(RecordedFileChunkPeerSweep {
3976                            index: idx + 1,
3977                            address: addr,
3978                            sweep: file_chunk_sweep_report_from_error(
3979                                attempt,
3980                                is_deferred_retry,
3981                                &e,
3982                            ),
3983                        });
3984                    None
3985                }
3986            }
3987        } else {
3988            // Normal path: early-return after the first peer that has the
3989            // chunk. When diagnostics are enabled we thread a per-chunk
3990            // diagnostics context through so each peer attempt in the sweep
3991            // is recorded; when disabled (`None`) this is a zero-cost pass.
3992            let diag = context.diagnostics.as_ref().map(|sender| {
3993                ChunkFetchDiagnostics::new(
3994                    sender,
3995                    attempt,
3996                    idx + 1,
3997                    addr,
3998                    self.controller().fetch.current(),
3999                )
4000            });
4001            match self
4002                .chunk_get_observed_from_closest_peers(&addr, context.peer_count, diag.as_ref())
4003                .await
4004            {
4005                Ok(Some(chunk)) => Some(chunk.content),
4006                Ok(None) => None,
4007                Err(e) => {
4008                    if is_deferred_retry {
4009                        info!(
4010                            "Deferred retry for {addr_hex} hit transient error: {e}; re-deferring"
4011                        );
4012                    } else {
4013                        info!("First-pass fetch error for {addr_hex}: {e}; deferring");
4014                    }
4015                    None
4016                }
4017            }
4018        };
4019
4020        let Some(content) = chunk_content else {
4021            return Ok((idx, Err(hash)));
4022        };
4023
4024        let fetched = context
4025            .fetched_ref
4026            .fetch_add(1, std::sync::atomic::Ordering::Relaxed)
4027            + 1;
4028        if is_deferred_retry {
4029            info!(
4030                "Downloaded {fetched}/{} (deferred retry)",
4031                context.total_chunks
4032            );
4033        } else {
4034            let total_chunks = context.total_chunks;
4035            info!("Downloaded {fetched}/{total_chunks}");
4036        }
4037        if let Some(ref tx) = context.progress_ref {
4038            let _ = tx.try_send(DownloadEvent::ChunksFetched {
4039                fetched,
4040                total: context.total_chunks,
4041            });
4042        }
4043
4044        Ok((idx, Ok(content)))
4045    }
4046
4047    /// Shared download core: resolve the DataMap, then fetch + streaming-decrypt
4048    /// the file one batch at a time, handing each decrypted plaintext segment
4049    /// (in order) to `on_chunk`. Constant memory — only one decrypt batch is
4050    /// resident at a time. Returns the total plaintext bytes produced.
4051    ///
4052    /// `on_chunk` is async so a sink can apply backpressure (e.g. a bounded
4053    /// channel). Driving the decrypt iterator runs the batched chunk fetch via
4054    /// `block_in_place`, so this requires a multi-threaded Tokio runtime.
4055    ///
4056    /// Every chunk fetch tries `peer_count` closest peers.
4057    ///
4058    /// Progress reporting (via `progress`):
4059    /// 1. Resolves hierarchical DataMaps to the root level first (reports as
4060    ///    `ChunksFetched` with `total: 0` during resolution)
4061    /// 2. Once the root DataMap is known, sends `total_chunks` with accurate count
4062    /// 3. Fetches data chunks with accurate `fetched/total` progress
4063    async fn download_decrypted_chunks<F, Fut>(
4064        &self,
4065        data_map: &DataMap,
4066        progress: Option<mpsc::Sender<DownloadEvent>>,
4067        peer_count: usize,
4068        peer_reports: Option<Arc<Mutex<Vec<RecordedFileChunkPeerSweep>>>>,
4069        diagnostics: Option<DownloadDiagnosticsSender>,
4070        mut on_chunk: F,
4071    ) -> Result<u64>
4072    where
4073        F: FnMut(Bytes) -> Fut,
4074        Fut: std::future::Future<Output = Result<()>>,
4075    {
4076        let handle = Handle::current();
4077
4078        // Phase 1: Resolve hierarchical DataMap to root level.
4079        // This fetches child DataMap chunks (typically 3) to discover the real chunk count.
4080        let root_map = if data_map.is_child() {
4081            let dm_chunks = data_map.len();
4082            if let Some(ref tx) = progress {
4083                let _ = tx.try_send(DownloadEvent::ResolvingDataMap {
4084                    total_map_chunks: dm_chunks,
4085                });
4086            }
4087
4088            let resolve_progress = progress.clone();
4089            let resolve_counter = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4090
4091            let resolved = tokio::task::block_in_place(|| {
4092                let counter_ref = resolve_counter.clone();
4093                let progress_ref = resolve_progress.clone();
4094                let fetch_limiter = self.controller().fetch.clone();
4095                let fetch = |batch: &[(usize, XorName)]| {
4096                    let batch_owned: Vec<(usize, XorName)> = batch.to_vec();
4097                    let counter = counter_ref.clone();
4098                    let prog = progress_ref.clone();
4099                    let limiter = fetch_limiter.clone();
4100                    handle.block_on(async {
4101                        // Use rebucketed_unordered so the in-flight cap
4102                        // is re-read from the limiter as each slot frees.
4103                        // `buffer_unordered` snapshots the cap once at
4104                        // pipeline build, which means observe_op
4105                        // signals from inside chunk_get cannot reduce
4106                        // concurrency on the current batch — exactly
4107                        // the case where load-shedding is needed.
4108                        let mut results = rebucketed_unordered(
4109                            &limiter,
4110                            batch_owned,
4111                            |(idx, hash): (usize, XorName)| {
4112                                let counter = counter.clone();
4113                                let prog = prog.clone();
4114                                async move {
4115                                    let addr = hash.0;
4116                                    // chunk_get_observed feeds the
4117                                    // adaptive fetch limiter once per
4118                                    // call via chunk_get_outcome
4119                                    // (Ok(None) -> Timeout is the
4120                                    // load-shedding signal for
4121                                    // sustained close-group exhaustion).
4122                                    let chunk = self
4123                                        .chunk_get_observed_from_closest_peers(
4124                                            &addr, peer_count, None,
4125                                        )
4126                                        .await
4127                                        .map_err(|e| {
4128                                            self_encryption::Error::Generic(format!(
4129                                                "DataMap resolution failed: {e}"
4130                                            ))
4131                                        })?
4132                                        .ok_or_else(|| {
4133                                            self_encryption::Error::Generic(format!(
4134                                                "DataMap chunk not found: {}",
4135                                                hex::encode(addr)
4136                                            ))
4137                                        })?;
4138                                    let fetched = counter
4139                                        .fetch_add(1, std::sync::atomic::Ordering::Relaxed)
4140                                        + 1;
4141                                    if let Some(ref tx) = prog {
4142                                        let _ =
4143                                            tx.try_send(DownloadEvent::MapChunkFetched { fetched });
4144                                    }
4145                                    Ok::<_, self_encryption::Error>((idx, chunk.content))
4146                                }
4147                            },
4148                        )
4149                        .await?;
4150                        // CRITICAL: self_encryption::get_root_data_map_parallel
4151                        // pairs the returned Vec POSITIONALLY with the input
4152                        // hashes via .zip() and discards our idx field.
4153                        // rebucketed_unordered preserves first-completion
4154                        // order, so sort by idx to restore input order
4155                        // before returning.
4156                        results.sort_by_key(|(idx, _)| *idx);
4157                        Ok(results)
4158                    })
4159                };
4160                get_root_data_map_parallel(data_map.clone(), &fetch)
4161            })
4162            .map_err(|e| Error::Encryption(format!("DataMap resolution failed: {e}")))?;
4163
4164            info!(
4165                "Resolved hierarchical DataMap: {} data chunks",
4166                resolved.len()
4167            );
4168            resolved
4169        } else {
4170            data_map.clone()
4171        };
4172
4173        // Phase 2: Now we know the real chunk count.
4174        let total_chunks = root_map.len();
4175        if let Some(ref tx) = progress {
4176            let _ = tx.try_send(DownloadEvent::DataMapResolved { total_chunks });
4177        }
4178
4179        // Phase 3: Fetch and decrypt data chunks with accurate progress.
4180        let fetched_counter = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4181        let fetched_for_closure = fetched_counter.clone();
4182        let progress_for_closure = progress.clone();
4183        let peer_reports_for_closure = peer_reports.clone();
4184        let diagnostics_for_closure = diagnostics.clone();
4185
4186        let fetch_limiter_outer = self.controller().fetch.clone();
4187        let usable_memory = usable_memory_bytes();
4188        let configured_batch_floor = stream_decrypt_batch_size();
4189        let fetch_cap = fetch_limiter_outer.current();
4190        let decrypt_batch_size = adaptive_stream_decrypt_batch_size(
4191            total_chunks,
4192            fetch_cap,
4193            configured_batch_floor,
4194            usable_memory,
4195        );
4196        info!(
4197            total_chunks,
4198            fetch_cap,
4199            configured_batch_floor,
4200            ?usable_memory,
4201            decrypt_batch_size,
4202            "Selected adaptive stream decrypt batch size"
4203        );
4204
4205        let stream = streaming_decrypt_with_batch_size(
4206            &root_map,
4207            |batch: &[(usize, XorName)]| {
4208                let batch_owned: Vec<(usize, XorName)> = batch.to_vec();
4209                let fetch_context = FileDownloadFetchContext {
4210                    total_chunks,
4211                    peer_count,
4212                    fetched_ref: fetched_for_closure.clone(),
4213                    progress_ref: progress_for_closure.clone(),
4214                    peer_reports: peer_reports_for_closure.clone(),
4215                    diagnostics: diagnostics_for_closure.clone(),
4216                };
4217                let fetch_limiter = fetch_limiter_outer.clone();
4218
4219                tokio::task::block_in_place(|| {
4220                    handle.block_on(async {
4221                        // First pass: try every chunk in the batch. Both normal
4222                        // and diagnostic modes preserve the closest-peer
4223                        // early-return path; diagnostics only records the peers
4224                        // actually attempted before a chunk is found.
4225                        // Any missing chunk or transient fetch error is encoded
4226                        // as Err(hash), so one noisy chunk does not abort the
4227                        // whole batch before the deferred retry rounds run.
4228                        let first_fetch_context = fetch_context.clone();
4229                        let raw: Vec<DownloadBatchEntry> = rebucketed_unordered(
4230                            &fetch_limiter,
4231                            batch_owned,
4232                            |(idx, hash): (usize, XorName)| {
4233                                let fetch_context = first_fetch_context.clone();
4234                                async move {
4235                                    self.download_fetch_file_chunk(
4236                                        idx,
4237                                        hash,
4238                                        fetch_context,
4239                                        false,
4240                                        FIRST_DIAGNOSTIC_FETCH_ATTEMPT,
4241                                    )
4242                                    .await
4243                                }
4244                            },
4245                        )
4246                        .await?;
4247
4248                        // Partition: things we already have vs the
4249                        // deferred set we need to retry.
4250                        let mut results: Vec<(usize, bytes::Bytes)> = Vec::new();
4251                        let mut deferred: Vec<(usize, XorName)> = Vec::new();
4252                        for (idx, inner) in raw {
4253                            match inner {
4254                                Ok(bytes) => results.push((idx, bytes)),
4255                                Err(hash) => deferred.push((idx, hash)),
4256                            }
4257                        }
4258
4259                        // Deferred retry pass: retry the deferred chunks
4260                        // in CONCURRENT rounds (reusing the fetch
4261                        // limiter's cap), not serially. The first round
4262                        // fires immediately — most deferrals on a
4263                        // healthy-but-lossy link are peer-side noise
4264                        // that clears in well under a second, and
4265                        // serializing them behind mandatory multi-second
4266                        // sleeps was the single biggest throughput sink
4267                        // on such links (a batch deferring ~20 chunks
4268                        // burned minutes of near-zero throughput even
4269                        // though every chunk succeeded on its first
4270                        // retry). Only chunks that survive a round get a
4271                        // longer back-off before the next, so genuine
4272                        // saturation still gets time to settle.
4273                        if !deferred.is_empty() {
4274                            // Round delays in seconds. Round 0 is
4275                            // immediate; later rounds back off to ride
4276                            // out sustained saturation.
4277                            const DEFERRED_ROUND_DELAYS_SECS: [u64; 3] = [0, 15, 45];
4278                            info!(
4279                                "Deferring {} chunk(s) for concurrent retry after batch settles",
4280                                deferred.len()
4281                            );
4282                            let mut remaining = deferred;
4283                            for (round, &delay_secs) in
4284                                DEFERRED_ROUND_DELAYS_SECS.iter().enumerate()
4285                            {
4286                                if remaining.is_empty() {
4287                                    break;
4288                                }
4289                                if delay_secs > 0 {
4290                                    tokio::time::sleep(std::time::Duration::from_secs(delay_secs))
4291                                        .await;
4292                                }
4293                                info!(
4294                                    "Deferred retry round {}/{}: {} chunk(s)",
4295                                    round + 1,
4296                                    DEFERRED_ROUND_DELAYS_SECS.len(),
4297                                    remaining.len(),
4298                                );
4299                                let round_input = std::mem::take(&mut remaining);
4300                                let retry_fetch_context = fetch_context.clone();
4301                                let round_results: Vec<DownloadBatchEntry> = rebucketed_unordered(
4302                                    &fetch_limiter,
4303                                    round_input,
4304                                    |(idx, hash): (usize, XorName)| {
4305                                        let fetch_context = retry_fetch_context.clone();
4306                                        async move {
4307                                            self.download_fetch_file_chunk(
4308                                                idx,
4309                                                hash,
4310                                                fetch_context,
4311                                                true,
4312                                                round + DEFERRED_RETRY_ATTEMPT_OFFSET,
4313                                            )
4314                                            .await
4315                                        }
4316                                    },
4317                                )
4318                                .await?;
4319                                for (idx, inner) in round_results {
4320                                    match inner {
4321                                        Ok(bytes) => results.push((idx, bytes)),
4322                                        Err(hash) => remaining.push((idx, hash)),
4323                                    }
4324                                }
4325                            }
4326                            if let Some((_, hash)) = remaining.first() {
4327                                return Err(self_encryption::Error::Generic(format!(
4328                                    "Chunk not found after {} deferred retry rounds: {}",
4329                                    DEFERRED_ROUND_DELAYS_SECS.len(),
4330                                    hex::encode(hash.0),
4331                                )));
4332                            }
4333                        }
4334
4335                        // streaming_decrypt itself sort_by_keys before
4336                        // zipping, but the same closure is also passed
4337                        // through get_root_data_map_parallel internally
4338                        // (see self_encryption::stream_decrypt.rs::new), and
4339                        // THAT path zips positionally without sorting. Sort
4340                        // here so both consumers see input order.
4341                        results.sort_by_key(|(idx, _)| *idx);
4342                        Ok(results)
4343                    })
4344                })
4345            },
4346            decrypt_batch_size,
4347        )
4348        .map_err(|e| Error::Encryption(format!("streaming decrypt failed: {e}")))?;
4349
4350        // Drive the iterator (each `next()` runs the batched fetch via
4351        // block_in_place) and hand each decrypted segment to the sink in
4352        // order. Awaiting the sink between items yields back to the runtime so
4353        // a bounded sink can apply backpressure.
4354        let mut bytes_total = 0u64;
4355        for chunk_result in stream {
4356            let chunk: Bytes =
4357                chunk_result.map_err(|e| Error::Encryption(format!("decryption failed: {e}")))?;
4358            bytes_total += chunk.len() as u64;
4359            on_chunk(chunk).await?;
4360        }
4361        Ok(bytes_total)
4362    }
4363
4364    /// Download and decrypt a file to disk, with optional progress events.
4365    ///
4366    /// Same as [`Client::file_download`] but sends [`DownloadEvent`]s for UI
4367    /// feedback. Streams to a temp file (one decrypt batch resident at a time)
4368    /// and renames atomically on success. A `TempDownload` guard removes the
4369    /// staging file on any error path, including a panic.
4370    pub async fn file_download_with_progress(
4371        &self,
4372        data_map: &DataMap,
4373        output: &Path,
4374        progress: Option<mpsc::Sender<DownloadEvent>>,
4375    ) -> Result<u64> {
4376        self.file_download_with_progress_using_peer_count(
4377            data_map,
4378            output,
4379            progress,
4380            self.config().close_group_size,
4381            None,
4382        )
4383        .await
4384    }
4385
4386    /// Download and decrypt a file to disk with progress events, trying
4387    /// `peer_count` closest peers for every chunk fetch.
4388    ///
4389    /// Streams to a temp file (one decrypt batch resident at a time) and
4390    /// renames atomically on success.
4391    async fn file_download_with_progress_using_peer_count(
4392        &self,
4393        data_map: &DataMap,
4394        output: &Path,
4395        progress: Option<mpsc::Sender<DownloadEvent>>,
4396        peer_count: usize,
4397        diagnostics: Option<DownloadDiagnosticsSender>,
4398    ) -> Result<u64> {
4399        self.file_download_with_progress_using_peer_count_and_reports(
4400            data_map,
4401            output,
4402            progress,
4403            peer_count,
4404            None,
4405            diagnostics,
4406        )
4407        .await
4408    }
4409
4410    async fn file_download_with_progress_using_peer_count_and_reports(
4411        &self,
4412        data_map: &DataMap,
4413        output: &Path,
4414        progress: Option<mpsc::Sender<DownloadEvent>>,
4415        peer_count: usize,
4416        peer_reports: Option<Arc<Mutex<Vec<RecordedFileChunkPeerSweep>>>>,
4417        diagnostics: Option<DownloadDiagnosticsSender>,
4418    ) -> Result<u64> {
4419        debug!("Downloading file to {}", output.display());
4420
4421        let parent = output.parent().unwrap_or_else(|| Path::new("."));
4422        let unique: u64 = rand::random();
4423        let tmp_path = parent.join(format!(".ant_download_{}_{unique}.tmp", std::process::id()));
4424
4425        // Guard removes the staging file on any early return OR a panic unwind
4426        // out of the `block_in_place` decrypt loop; defused only by a
4427        // successful commit(). Centralizes what used to be three duplicated
4428        // cleanup arms.
4429        let tmp = TempDownload::new(tmp_path);
4430        let mut file = std::fs::File::create(tmp.path())?;
4431
4432        let bytes_written = self
4433            .download_decrypted_chunks(
4434                data_map,
4435                progress,
4436                peer_count,
4437                peer_reports,
4438                diagnostics,
4439                |bytes| {
4440                    let r = file.write_all(&bytes).map_err(Error::from);
4441                    std::future::ready(r)
4442                },
4443            )
4444            .await?;
4445        file.flush()?;
4446        drop(file); // close the handle before rename (Windows won't rename an open file)
4447
4448        tmp.commit(output)?;
4449        info!(
4450            "File downloaded: {bytes_written} bytes written to {}",
4451            output.display()
4452        );
4453        Ok(bytes_written)
4454    }
4455
4456    /// Download and decrypt a file, streaming the plaintext to `sink` instead
4457    /// of writing to disk.
4458    ///
4459    /// Constant memory (one decrypt batch resident at a time); the caller
4460    /// receives bytes progressively as each batch decrypts, suitable for
4461    /// forwarding to an HTTP chunked body or a gRPC response stream. The
4462    /// bounded `sink` applies backpressure. If the receiver is dropped (e.g.
4463    /// the client disconnected) the download stops early and returns
4464    /// [`Error::Cancelled`].
4465    ///
4466    /// The channel item type is `Result<Bytes, Error>`, so the caller sets up:
4467    ///
4468    /// ```ignore
4469    /// let (tx, rx) = tokio::sync::mpsc::channel::<Result<Bytes, Error>>(8);
4470    /// ```
4471    ///
4472    /// Typically the caller `tokio::spawn`s this and converts the matching
4473    /// `Receiver` into its response stream. Requires a multi-threaded Tokio
4474    /// runtime (the decrypt iterator uses `block_in_place`).
4475    pub async fn file_download_to_sender(
4476        &self,
4477        data_map: &DataMap,
4478        sink: mpsc::Sender<std::result::Result<Bytes, Error>>,
4479        progress: Option<mpsc::Sender<DownloadEvent>>,
4480    ) -> Result<u64> {
4481        let peer_count = self.config().close_group_size;
4482        self.download_decrypted_chunks(data_map, progress, peer_count, None, None, |bytes| {
4483            let sink = sink.clone();
4484            async move {
4485                sink.send(Ok(bytes))
4486                    .await
4487                    .map_err(|_| Error::Cancelled("download stream receiver dropped".into()))
4488            }
4489        })
4490        .await
4491    }
4492}
4493
4494#[cfg(test)]
4495#[allow(clippy::unwrap_used)]
4496mod tests {
4497    use super::*;
4498
4499    /// Throwaway payment result — the assembler only moves it into the resume
4500    /// handle, never inspects it.
4501    fn dummy_batch_result() -> MerkleBatchPaymentResult {
4502        MerkleBatchPaymentResult {
4503            proofs: HashMap::new(),
4504            chunk_count: 0,
4505            storage_cost_atto: "0".into(),
4506            gas_cost_wei: 0,
4507            merkle_payment_timestamp: 0,
4508        }
4509    }
4510
4511    fn empty_chunk_store() -> ExternalChunkStore {
4512        ExternalChunkStore::from_spill(ChunkSpill::new().unwrap())
4513    }
4514
4515    /// A minimal already-paid chunk — the wave assembler only moves it and reads
4516    /// its `address`, so the body/proof/targets can be trivial.
4517    fn paid_chunk(address: [u8; 32]) -> PaidChunk {
4518        PaidChunk {
4519            content: Bytes::from_static(b"x"),
4520            address,
4521            quoted_peers: Vec::new(),
4522            proof_bytes: Vec::new(),
4523        }
4524    }
4525
4526    #[test]
4527    fn assemble_complete_on_full_store() {
4528        let outcome = assemble_merkle_finalize_outcome(
4529            Ok((3, "0".into(), 0, WaveAggregateStats::default())),
4530            DataMap::new(vec![]),
4531            Some([9u8; 32]),
4532            3,
4533            empty_chunk_store(),
4534            dummy_batch_result(),
4535        )
4536        .expect("a fully-stored pass is not an error");
4537        match outcome {
4538            FinalizeOutcome::Complete(result) => {
4539                assert_eq!(result.chunks_stored, 3);
4540                assert_eq!(result.chunks_failed, 0);
4541                assert_eq!(result.total_chunks, 3);
4542                assert_eq!(result.data_map_address, Some([9u8; 32]));
4543                assert!(matches!(result.payment_mode_used, PaymentMode::Merkle));
4544            }
4545            FinalizeOutcome::Partial { .. } => panic!("expected Complete"),
4546        }
4547    }
4548
4549    #[test]
4550    fn assemble_partial_retains_resume_for_unstored() {
4551        let a = [1u8; 32];
4552        let b = [2u8; 32];
4553        let c = [3u8; 32];
4554        // One chunk stored, two still short of quorum after retries.
4555        let store_result = Err(Error::PartialUpload {
4556            stored: vec![a],
4557            stored_count: 1,
4558            failed: vec![(b, "quorum".into()), (c, "quorum".into())],
4559            failed_count: 2,
4560            total_chunks: 3,
4561            spend: Box::new(PartialUploadSpend {
4562                storage_cost_atto: "777".into(),
4563                gas_cost_wei: 0,
4564            }),
4565            reason: "merkle chunk store aborted".into(),
4566        });
4567        let outcome = assemble_merkle_finalize_outcome(
4568            store_result,
4569            DataMap::new(vec![]),
4570            Some([9u8; 32]),
4571            3,
4572            empty_chunk_store(),
4573            dummy_batch_result(),
4574        )
4575        .expect("a quorum shortfall is Ok(Partial), never Err");
4576        match outcome {
4577            FinalizeOutcome::Partial { result, resume } => {
4578                // Snapshot reports real progress + spend from the payment.
4579                assert_eq!(result.chunks_stored, 1);
4580                assert_eq!(result.chunks_failed, 2);
4581                assert_eq!(result.total_chunks, 3);
4582                assert_eq!(result.storage_cost_atto, "777");
4583                let FinalizeResume::Merkle(m) = resume else {
4584                    panic!("expected a merkle resume handle");
4585                };
4586                // Resume targets exactly the unstored chunks, carries the stored
4587                // set forward as already-stored, and preserves public + total.
4588                assert_eq!(m.unstored_addresses, vec![b, c]);
4589                assert_eq!(m.stored_addresses, vec![a]);
4590                assert_eq!(m.total_chunks, 3);
4591                assert_eq!(m.data_map_address, Some([9u8; 32]));
4592            }
4593            FinalizeOutcome::Complete(_) => panic!("expected Partial"),
4594        }
4595    }
4596
4597    #[test]
4598    fn resumable_guard_rejects_partial_payment() {
4599        // Regression for the PR #172 review: a Some/None mix must not reach the
4600        // resumable path — its resume handle could never acquire proofs for the
4601        // unpaid chunks, so repeated finalize_resume calls would return Partial
4602        // forever instead of draining to Complete.
4603        let err = require_fully_paid_for_resumable(&[Some([1u8; 32]), None, Some([2u8; 32])])
4604            .expect_err("a mix of paid and unpaid sub-batches must be rejected");
4605        match err {
4606            Error::Payment(msg) => {
4607                assert!(msg.contains("1/3"), "counts unpaid batches: {msg}");
4608                assert!(
4609                    msg.contains("finalize_upload_merkle_multi()"),
4610                    "points at the non-resumable path: {msg}"
4611                );
4612            }
4613            other => panic!("expected Error::Payment, got {other:?}"),
4614        }
4615    }
4616
4617    #[test]
4618    fn resumable_guard_accepts_fully_paid() {
4619        require_fully_paid_for_resumable(&[Some([1u8; 32]), Some([2u8; 32])])
4620            .expect("fully-paid winner hashes pass the guard");
4621        require_fully_paid_for_resumable(&[]).expect(
4622            "an empty set has no unpaid batch — fold_external_merkle_payments \
4623             rejects it as nothing-to-finalize",
4624        );
4625    }
4626
4627    #[test]
4628    fn merkle_resume_handle_drains_to_complete() {
4629        // Regression for the PR #172 review: drive the resume-handoff contract
4630        // through two passes and prove the handle drains. Pass 1 stores one of
4631        // three chunks; the Partial handle carries the unstored set plus the
4632        // original payment. Pass 2 re-drives exactly that handle's material and
4633        // stores the rest, reaching Complete with whole-file counts.
4634        let a = [1u8; 32];
4635        let b = [2u8; 32];
4636        let c = [3u8; 32];
4637        let first_pass = Err(Error::PartialUpload {
4638            stored: vec![a],
4639            stored_count: 1,
4640            failed: vec![(b, "quorum".into()), (c, "quorum".into())],
4641            failed_count: 2,
4642            total_chunks: 3,
4643            spend: Box::new(PartialUploadSpend {
4644                storage_cost_atto: "777".into(),
4645                gas_cost_wei: 0,
4646            }),
4647            reason: "quorum shortfall".into(),
4648        });
4649        let outcome = assemble_merkle_finalize_outcome(
4650            first_pass,
4651            DataMap::new(vec![]),
4652            Some([9u8; 32]),
4653            3,
4654            empty_chunk_store(),
4655            dummy_batch_result(),
4656        )
4657        .expect("a quorum shortfall is Ok(Partial), never Err");
4658        let FinalizeOutcome::Partial { resume, .. } = outcome else {
4659            panic!("expected Partial after a shortfall pass");
4660        };
4661        let FinalizeResume::Merkle(m) = resume else {
4662            panic!("expected a merkle resume handle");
4663        };
4664        assert_eq!(m.unstored_addresses, vec![b, c]);
4665
4666        // Second pass: finalize_resume feeds the handle's own fields back into
4667        // the drive; simulate its store pass succeeding for the remainder.
4668        let second_pass = Ok((3, "0".into(), 0, WaveAggregateStats::default()));
4669        let outcome = assemble_merkle_finalize_outcome(
4670            second_pass,
4671            m.data_map,
4672            m.data_map_address,
4673            m.total_chunks,
4674            m.chunk_store,
4675            m.batch_result,
4676        )
4677        .expect("a fully-stored resume pass is not an error");
4678        match outcome {
4679            FinalizeOutcome::Complete(result) => {
4680                assert_eq!(result.chunks_stored, 3);
4681                assert_eq!(result.chunks_failed, 0);
4682                assert_eq!(result.total_chunks, 3);
4683                assert_eq!(result.data_map_address, Some([9u8; 32]));
4684            }
4685            FinalizeOutcome::Partial { .. } => panic!("expected Complete after the drain pass"),
4686        }
4687    }
4688
4689    #[test]
4690    fn assemble_propagates_fatal_error() {
4691        // A non-recoverable error is not folded into a resumable outcome.
4692        let outcome = assemble_merkle_finalize_outcome(
4693            Err(Error::Payment("on-chain call reverted".into())),
4694            DataMap::new(vec![]),
4695            None,
4696            3,
4697            empty_chunk_store(),
4698            dummy_batch_result(),
4699        );
4700        assert!(matches!(outcome, Err(Error::Payment(_))));
4701    }
4702
4703    #[test]
4704    fn assemble_wave_complete_when_all_stored() {
4705        let a = [1u8; 32];
4706        let wave_result = WaveResult {
4707            stored: vec![a],
4708            failed: Vec::new(),
4709            chunk_attempts_total: 1,
4710            store_durations_ms: vec![5],
4711            retries_per_chunk: vec![0],
4712        };
4713        let mut retained = HashMap::new();
4714        retained.insert(a, paid_chunk(a));
4715        let outcome = assemble_wave_finalize_outcome(
4716            wave_result,
4717            retained,
4718            DataMap::new(vec![]),
4719            Some([9u8; 32]),
4720            1,
4721            0,
4722            "500".into(),
4723        );
4724        match outcome {
4725            FinalizeOutcome::Complete(result) => {
4726                assert_eq!(result.chunks_stored, 1);
4727                assert_eq!(result.chunks_failed, 0);
4728                assert_eq!(result.storage_cost_atto, "500");
4729                assert!(matches!(result.payment_mode_used, PaymentMode::Single));
4730            }
4731            FinalizeOutcome::Partial { .. } => panic!("expected Complete"),
4732        }
4733    }
4734
4735    #[test]
4736    fn assemble_wave_partial_retains_failed_paid_chunks() {
4737        let a = [1u8; 32]; // stored
4738        let b = [2u8; 32]; // failed
4739        let c = [3u8; 32]; // failed
4740        let wave_result = WaveResult {
4741            stored: vec![a],
4742            failed: vec![(b, "quorum".into()), (c, "quorum".into())],
4743            chunk_attempts_total: 3,
4744            store_durations_ms: vec![5],
4745            retries_per_chunk: vec![0],
4746        };
4747        // All three were paid; only the two failures should be retained.
4748        let mut retained = HashMap::new();
4749        for addr in [a, b, c] {
4750            retained.insert(addr, paid_chunk(addr));
4751        }
4752        let outcome = assemble_wave_finalize_outcome(
4753            wave_result,
4754            retained,
4755            DataMap::new(vec![]),
4756            Some([9u8; 32]),
4757            3,
4758            0,
4759            "500".into(),
4760        );
4761        match outcome {
4762            FinalizeOutcome::Partial { result, resume } => {
4763                assert_eq!(result.chunks_stored, 1);
4764                assert_eq!(result.chunks_failed, 2);
4765                assert_eq!(result.storage_cost_atto, "500");
4766                let FinalizeResume::Wave(w) = resume else {
4767                    panic!("expected a wave resume handle");
4768                };
4769                // Exactly the two failed chunks are kept for re-store — no re-pay.
4770                let mut got: Vec<[u8; 32]> =
4771                    w.failed_paid_chunks.iter().map(|pc| pc.address).collect();
4772                got.sort();
4773                assert_eq!(got, vec![b, c]);
4774                assert_eq!(w.stored_count, 1);
4775                assert_eq!(w.total_chunks, 3);
4776            }
4777            FinalizeOutcome::Complete(_) => panic!("expected Partial"),
4778        }
4779    }
4780
4781    #[test]
4782    fn merkle_store_cap_clamps_to_memory_bound() {
4783        // Below the ceiling: pass the adaptive cap through unchanged.
4784        assert_eq!(merkle_store_cap(8), 8);
4785        assert_eq!(merkle_store_cap(64), 64);
4786        // A configured `adaptive.max.store` above the ceiling must be clamped so
4787        // the whole-file fan-out can't pin more than ~256 MB of bodies (PR #137).
4788        assert_eq!(merkle_store_cap(512), MERKLE_STORE_MAX_IN_FLIGHT);
4789        assert_eq!(merkle_store_cap(usize::MAX), MERKLE_STORE_MAX_IN_FLIGHT);
4790        // Never zero — always make progress.
4791        assert_eq!(merkle_store_cap(0), 1);
4792    }
4793
4794    #[test]
4795    fn distributed_sample_indices_spreads_across_large_file() {
4796        // cap 5 over 100 chunks: first and last included, evenly spread.
4797        assert_eq!(distributed_sample_indices(100, 5), vec![0, 24, 49, 74, 99]);
4798    }
4799
4800    #[test]
4801    fn distributed_sample_indices_covers_whole_small_file() {
4802        // total <= cap returns every index, preserving the exact
4803        // "whole file sampled" detection in estimate_upload_cost.
4804        assert_eq!(distributed_sample_indices(3, 5), vec![0, 1, 2]);
4805        assert_eq!(distributed_sample_indices(5, 5), vec![0, 1, 2, 3, 4]);
4806    }
4807
4808    /// The estimator bills the padded tree, and the leaf total it bills comes
4809    /// from the batches the payment path really builds — a `[255, 2]` split of
4810    /// 257 chunks, not a `[256, 1]` one that could never be paid.
4811    #[test]
4812    fn estimator_leaf_total_is_the_padded_payment_partition() {
4813        for chunks in [2u64, 64, 65, 100, 129, 255, 256, 257, 300, 512, 513, 769] {
4814            let from_partition: u64 = merkle_batch_sizes(chunks as usize)
4815                .into_iter()
4816                .map(|size| size.next_power_of_two() as u64)
4817                .sum();
4818            assert_eq!(
4819                merkle_billable_leaves(chunks),
4820                from_partition,
4821                "{chunks} chunks must be billed for the partition the payment path pays"
4822            );
4823        }
4824    }
4825
4826    #[test]
4827    fn distributed_sample_indices_is_in_range_and_increasing() {
4828        assert!(distributed_sample_indices(0, 5).is_empty());
4829        assert_eq!(distributed_sample_indices(1, 5), vec![0]);
4830        for total in 1..200usize {
4831            let idx = distributed_sample_indices(total, 5);
4832            assert_eq!(*idx.first().unwrap(), 0);
4833            assert_eq!(*idx.last().unwrap(), total - 1);
4834            assert!(idx.iter().all(|&i| i < total));
4835            assert!(idx.windows(2).all(|w| w[0] < w[1]));
4836        }
4837    }
4838
4839    #[test]
4840    fn disk_space_check_passes_for_small_file() {
4841        // A 1 KB file should always pass the disk space check
4842        check_disk_space_for_spill(1024).unwrap();
4843    }
4844
4845    #[test]
4846    fn disk_space_check_fails_for_absurd_size() {
4847        // Requesting space for a 1 exabyte file should fail on any real system
4848        let result = check_disk_space_for_spill(u64::MAX / 2);
4849        assert!(result.is_err());
4850        let err = result.unwrap_err();
4851        assert!(
4852            matches!(err, Error::InsufficientDiskSpace(_)),
4853            "expected InsufficientDiskSpace, got: {err}"
4854        );
4855    }
4856
4857    /// External multi-batch payment fold: winner-hash validation and
4858    /// paid/unpaid mixes (ADR-0003).
4859    mod external_merkle_fold {
4860        use super::*;
4861        use crate::data::client::merkle::test_support::{
4862            make_prepared_merkle_batch, winner_hash_for,
4863        };
4864
4865        #[test]
4866        fn hash_count_mismatch_is_rejected() {
4867            let batches = vec![make_prepared_merkle_batch(2), make_prepared_merkle_batch(3)];
4868            let err = fold_external_merkle_payments(batches, vec![None]).unwrap_err();
4869            assert!(
4870                err.to_string().contains("winner pool hash entries"),
4871                "unexpected error: {err}"
4872            );
4873        }
4874
4875        #[test]
4876        fn all_unpaid_is_rejected() {
4877            let batches = vec![make_prepared_merkle_batch(2)];
4878            let err = fold_external_merkle_payments(batches, vec![None]).unwrap_err();
4879            assert!(
4880                err.to_string().contains("No merkle sub-batch was paid"),
4881                "unexpected error: {err}"
4882            );
4883        }
4884
4885        /// A k-of-N payment makes forward progress: the paid batch's proofs
4886        /// fold in, the unpaid batch contributes none — so the store phase
4887        /// reports its chunks via `PartialUpload` instead of aborting.
4888        #[test]
4889        fn paid_batches_fold_and_unpaid_contribute_no_proofs() {
4890            let paid = make_prepared_merkle_batch(2);
4891            let unpaid = make_prepared_merkle_batch(3);
4892            let winner = winner_hash_for(&paid);
4893            let merged =
4894                fold_external_merkle_payments(vec![paid, unpaid], vec![Some(winner), None])
4895                    .unwrap();
4896            assert_eq!(merged.proofs.len(), 2, "proofs cover only the paid batch");
4897            assert_eq!(merged.chunk_count, 2);
4898        }
4899    }
4900
4901    #[test]
4902    fn adaptive_stream_decrypt_batch_size_tracks_fetch_headroom() {
4903        let batch_size = adaptive_stream_decrypt_batch_size(1_000, 64, 10, Some(u64::MAX));
4904
4905        assert_eq!(batch_size, 64 * DOWNLOAD_STREAM_BATCH_FETCH_MULTIPLIER);
4906    }
4907
4908    #[test]
4909    fn adaptive_stream_decrypt_batch_size_caps_to_total_chunks() {
4910        let batch_size = adaptive_stream_decrypt_batch_size(12, 64, 10, Some(u64::MAX));
4911
4912        assert_eq!(batch_size, 12);
4913    }
4914
4915    #[test]
4916    fn adaptive_stream_decrypt_batch_size_honours_configured_floor() {
4917        let batch_size = adaptive_stream_decrypt_batch_size(1_000, 1, 32, None);
4918
4919        assert_eq!(batch_size, 32);
4920    }
4921
4922    #[test]
4923    fn adaptive_stream_decrypt_batch_size_does_not_expand_without_memory_reading() {
4924        let batch_size = adaptive_stream_decrypt_batch_size(1_000, 64, 10, None);
4925
4926        assert_eq!(batch_size, 10);
4927    }
4928
4929    #[test]
4930    fn adaptive_stream_decrypt_batch_size_caps_to_memory_budget() {
4931        let estimated_bytes_per_chunk = (self_encryption::MAX_CHUNK_SIZE as u64)
4932            .saturating_mul(DOWNLOAD_STREAM_BATCH_BYTES_PER_CHUNK_MULTIPLIER)
4933            .max(1);
4934        let usable_memory = estimated_bytes_per_chunk
4935            .saturating_mul(16)
4936            .saturating_mul(DOWNLOAD_STREAM_BATCH_MEMORY_BUDGET_DIVISOR);
4937        let batch_size = adaptive_stream_decrypt_batch_size(1_000, 256, 10, Some(usable_memory));
4938
4939        assert_eq!(batch_size, 16);
4940    }
4941
4942    #[test]
4943    fn adaptive_stream_decrypt_batch_size_keeps_one_chunk_when_memory_is_tight() {
4944        let batch_size = adaptive_stream_decrypt_batch_size(1_000, 64, 10, Some(1));
4945
4946        assert_eq!(batch_size, 1);
4947    }
4948
4949    #[test]
4950    fn cached_merkle_covers_only_when_all_addresses_have_proofs() {
4951        let covered = compute_address(&Bytes::from_static(b"covered"));
4952        let extra = compute_address(&Bytes::from_static(b"extra"));
4953        let missing = compute_address(&Bytes::from_static(b"missing"));
4954        let cached = MerkleBatchPaymentResult {
4955            proofs: HashMap::from([(covered, vec![1]), (extra, vec![2])]),
4956            chunk_count: 2,
4957            storage_cost_atto: "0".to_string(),
4958            gas_cost_wei: 0,
4959            merkle_payment_timestamp: 0,
4960        };
4961
4962        assert!(cached_merkle_covers_addresses(&cached, &[covered]));
4963        assert!(cached_merkle_covers_addresses(&cached, &[covered, extra]));
4964        assert!(!cached_merkle_covers_addresses(
4965            &cached,
4966            &[covered, missing]
4967        ));
4968    }
4969
4970    /// A partial merkle payment leaves some addresses without a proof. Those
4971    /// must be split out so `upload_merkle_from_spill` reports them as failed
4972    /// (`PartialUpload`) instead of aborting the whole file — preserving the
4973    /// addresses' original order in each group.
4974    #[test]
4975    fn partition_addresses_by_proof_splits_paid_and_unpaid() {
4976        let paid_a = [1u8; 32];
4977        let unpaid_b = [2u8; 32];
4978        let paid_c = [3u8; 32];
4979        let unpaid_d = [4u8; 32];
4980        let proofs: HashMap<[u8; 32], Vec<u8>> =
4981            HashMap::from([(paid_a, vec![0xaa]), (paid_c, vec![0xcc])]);
4982
4983        let (to_store, missing) =
4984            partition_addresses_by_proof(&[paid_a, unpaid_b, paid_c, unpaid_d], &proofs);
4985
4986        assert_eq!(to_store, vec![paid_a, paid_c]);
4987        assert_eq!(missing, vec![unpaid_b, unpaid_d]);
4988    }
4989
4990    /// The real storer wording, as `ant_protocol::client_update_required_message`
4991    /// builds it. Used verbatim so the tests exercise the "nothing was charged"
4992    /// clause that has to be scoped before it is quoted.
4993    fn real_refusal() -> String {
4994        ant_protocol::client_update_required_message(1, 2)
4995    }
4996
4997    /// The defect: a multi-batch merkle payment stops when storers refuse this
4998    /// client's settlement version, and the chunks its later sub-batches never
4999    /// covered reach the store path with no proof. The CLI prints this reason
5000    /// and nothing else, so calling them short of quorum after N attempts
5001    /// reports a failure they never had and drops the storer's upgrade
5002    /// instruction, which is the only thing that makes the next attempt work.
5003    #[test]
5004    fn the_partial_reason_carries_a_refusal_instead_of_a_bogus_shortfall() {
5005        let refusal = real_refusal();
5006        assert!(
5007            refusal.contains("ant update"),
5008            "the storer wording must carry the instruction: {refusal}"
5009        );
5010
5011        // Every failed chunk is one the payment never covered.
5012        let all_proofless = merkle_partial_reason(3, 3, 4, Some(&refusal));
5013        assert!(all_proofless.contains("ant update"), "{all_proofless}");
5014        assert!(
5015            !all_proofless.contains("short of quorum"),
5016            "{all_proofless}"
5017        );
5018
5019        // Mixed: two with no proof, one genuinely short of quorum. Both halves
5020        // survive and the counts still add up to the three that failed.
5021        let mixed = merkle_partial_reason(3, 2, 4, Some(&refusal));
5022        assert!(mixed.contains("ant update"), "{mixed}");
5023        assert!(mixed.contains("2 chunk(s) have no merkle proof"), "{mixed}");
5024        assert!(
5025            mixed.contains("1 chunk(s) short of quorum after 4 attempts"),
5026            "{mixed}"
5027        );
5028
5029        // No refusal: still not a quorum shortfall, just no proof.
5030        let silent = merkle_partial_reason(2, 2, 4, None);
5031        assert!(!silent.contains("short of quorum"), "{silent}");
5032        assert!(
5033            silent.contains("2 chunk(s) have no merkle proof"),
5034            "{silent}"
5035        );
5036        assert!(!silent.contains("refused"), "{silent}");
5037    }
5038
5039    /// The storer's wording says nothing was charged. That is true of the
5040    /// sub-batch it refused and false of the upload, whose earlier sub-batches
5041    /// settled — and the CLI prints that spend on the same line. Quoting it
5042    /// unscoped would read as "spent X ... nothing was charged".
5043    #[test]
5044    fn the_refusal_is_scoped_before_its_nothing_was_charged_clause_is_quoted() {
5045        let refusal = real_refusal();
5046        assert!(
5047            refusal.contains("nothing was charged"),
5048            "precondition: {refusal}"
5049        );
5050
5051        let reason = merkle_partial_reason(2, 2, 4, Some(&refusal));
5052
5053        // The scope has to come BEFORE the quoted refusal, or the reader hits
5054        // "nothing was charged" with no qualification.
5055        let scope = reason
5056            .find("settled for earlier sub-batches")
5057            .expect("the reason must scope the refusal");
5058        let charged = reason
5059            .find("nothing was charged")
5060            .expect("the storer wording must still be quoted in full");
5061        assert!(scope < charged, "scope must precede the claim: {reason}");
5062    }
5063
5064    /// The ordinary case — every chunk had a proof and simply could not reach
5065    /// quorum — must read exactly as it did before.
5066    #[test]
5067    fn the_partial_reason_is_unchanged_when_every_chunk_had_a_proof() {
5068        assert_eq!(
5069            merkle_partial_reason(2, 0, 4, None),
5070            "2 chunk(s) short of quorum after 4 attempts"
5071        );
5072        // A refusal is irrelevant when nothing is missing a proof: the
5073        // failures really are quorum shortfalls.
5074        assert_eq!(
5075            merkle_partial_reason(2, 0, 4, Some(&real_refusal())),
5076            "2 chunk(s) short of quorum after 4 attempts"
5077        );
5078    }
5079
5080    /// A sub-batch settles on-chain BEFORE its proofs are generated, so a
5081    /// proof-generation failure leaves chunks that were charged for and have
5082    /// no proof. The wording must never claim a proof-less chunk went unpaid,
5083    /// or it tells the user their money is safe when it is not.
5084    #[test]
5085    fn the_reason_never_claims_a_proofless_chunk_went_unpaid() {
5086        // The clause the store path contributes, with no storer wording mixed
5087        // in, so this asserts on our own words only.
5088        let ours = proofless_clause(2, None).expect("two proofless chunks produce a clause");
5089        assert_eq!(ours, "2 chunk(s) have no merkle proof");
5090        assert!(!ours.contains("paid"), "{ours}");
5091        assert!(!ours.contains("charged"), "{ours}");
5092
5093        assert!(proofless_clause(0, None).is_none());
5094        assert!(
5095            proofless_clause(0, Some(&real_refusal())).is_none(),
5096            "no proofless chunks means no clause, refusal or not"
5097        );
5098    }
5099
5100    /// A store abort and a refusal that stopped the payment are independent
5101    /// failures with different remedies. The abort is the immediate cause and
5102    /// leads, but dropping the other leaves its remedy only in the per-chunk
5103    /// messages, which the CLI does not print.
5104    #[test]
5105    fn a_fatal_store_abort_still_reports_the_refusal() {
5106        let abort = "merkle chunk store aborted: connection reset";
5107        let refusal = real_refusal();
5108
5109        let both = merkle_fatal_reason(abort, 5, Some(&refusal));
5110        assert!(both.starts_with(abort), "the abort leads: {both}");
5111        assert!(both.contains("ant update"), "{both}");
5112        assert!(both.contains("5 chunk(s) have no merkle proof"), "{both}");
5113
5114        // Every chunk had a proof, so there is nothing to add.
5115        assert_eq!(merkle_fatal_reason(abort, 0, Some(&refusal)), abort);
5116        assert_eq!(merkle_fatal_reason(abort, 0, None), abort);
5117    }
5118
5119    /// A wave that returns `Ok` contributes its stored chunks, parsed cost, and
5120    /// stats; nothing is recorded as failed.
5121    #[test]
5122    fn fold_single_wave_keeps_ok_wave() {
5123        let stored = vec![[1u8; 32], [2u8; 32]];
5124        let stats = WaveAggregateStats {
5125            chunk_attempts_total: 7,
5126            ..Default::default()
5127        };
5128
5129        let outcome = fold_single_wave(Ok((stored.clone(), "100".to_string(), 9, stats))).unwrap();
5130
5131        assert_eq!(outcome.stored, stored);
5132        assert!(outcome.failed.is_empty());
5133        assert_eq!(outcome.storage_atto.to_string(), "100");
5134        assert_eq!(outcome.gas_wei, 9);
5135        assert_eq!(outcome.stats.chunk_attempts_total, 7);
5136    }
5137
5138    /// The core V2-461 semantic: a wave short of quorum (`PartialUpload`) is
5139    /// recoverable — its stored chunks, failed chunks, and on-chain spend are
5140    /// folded so the caller can continue to the next wave rather than aborting
5141    /// the whole file.
5142    #[test]
5143    fn fold_single_wave_folds_partial_upload() {
5144        let stored = vec![[3u8; 32]];
5145        let failed = vec![([4u8; 32], "short of quorum".to_string())];
5146        let err = Error::PartialUpload {
5147            stored: stored.clone(),
5148            stored_count: 1,
5149            failed: failed.clone(),
5150            failed_count: 1,
5151            total_chunks: 2,
5152            spend: Box::new(PartialUploadSpend {
5153                storage_cost_atto: "250".to_string(),
5154                gas_cost_wei: 11,
5155            }),
5156            reason: "wave store failed after retries".to_string(),
5157        };
5158
5159        let outcome = fold_single_wave(Err(err)).unwrap();
5160
5161        assert_eq!(outcome.stored, stored);
5162        assert_eq!(outcome.failed, failed);
5163        assert_eq!(outcome.storage_atto.to_string(), "250");
5164        assert_eq!(outcome.gas_wei, 11);
5165        // `PartialUpload` carries no stats, so the failed wave contributes none.
5166        assert_eq!(outcome.stats.chunk_attempts_total, 0);
5167    }
5168
5169    /// A non-`PartialUpload` error (wallet/payment-infrastructure failure) is
5170    /// fatal and must abort the file, not be folded into the failed set.
5171    #[test]
5172    fn fold_single_wave_propagates_fatal_error() {
5173        let result = fold_single_wave(Err(Error::Payment("wallet unavailable".to_string())));
5174
5175        assert!(
5176            matches!(result, Err(Error::Payment(_))),
5177            "fatal payment error must propagate, got: {result:?}"
5178        );
5179    }
5180
5181    /// A settlement refusal on a later wave must not be reported as if nothing
5182    /// was charged: the earlier waves paid before storing. The refusal is
5183    /// reshaped into a `PartialUpload` that carries the real spend, the stored
5184    /// set (for resume), every un-quoted chunk as failed, and the storer's
5185    /// upgrade instruction in the reason.
5186    #[test]
5187    fn settlement_refusal_after_paid_waves_carries_spend_and_upgrade_instruction() {
5188        let refusal = "your client is too old to pay the current storage rate. Run `ant update`";
5189        let stored = vec![[1u8; 32], [2u8; 32]];
5190        let remaining = [[3u8; 32], [4u8; 32], [5u8; 32]];
5191
5192        let err = settlement_refusal_after_paid_waves(
5193            refusal,
5194            2,
5195            3,
5196            stored.clone(),
5197            stored.len(),
5198            &remaining,
5199            5,
5200            Amount::from(700u64),
5201            13,
5202        );
5203
5204        let Error::PartialUpload {
5205            stored: got_stored,
5206            stored_count,
5207            failed,
5208            failed_count,
5209            total_chunks,
5210            spend,
5211            reason,
5212        } = err
5213        else {
5214            panic!("expected PartialUpload, got: {err:?}");
5215        };
5216        assert_eq!(got_stored, stored);
5217        assert_eq!(stored_count, 2);
5218        assert_eq!(failed_count, 3);
5219        assert_eq!(total_chunks, 5);
5220        // Every un-quoted chunk is listed, none of them as "stored".
5221        let failed_addrs: Vec<[u8; 32]> = failed.iter().map(|(a, _)| *a).collect();
5222        assert_eq!(failed_addrs, remaining.to_vec());
5223        assert!(failed.iter().all(|(_, why)| why.contains("not quoted")));
5224        // The spend is what the earlier waves actually paid, not zero.
5225        assert_eq!(spend.storage_cost_atto, "700");
5226        assert_eq!(spend.gas_cost_wei, 13);
5227        // The user learns both facts: earlier waves paid, and how to upgrade.
5228        assert!(reason.contains("wave 2/3"), "reason: {reason}");
5229        assert!(
5230            reason.contains("2 chunk(s) in earlier wave(s) were already paid"),
5231            "reason: {reason}"
5232        );
5233        assert!(
5234            reason.contains("3 chunk(s) were neither quoted nor paid"),
5235            "reason: {reason}"
5236        );
5237        assert!(reason.contains(refusal), "reason: {reason}");
5238    }
5239
5240    #[test]
5241    fn partition_addresses_by_proof_handles_all_or_nothing() {
5242        let a = [5u8; 32];
5243        let b = [6u8; 32];
5244
5245        // No proofs at all → every address is missing.
5246        let empty: HashMap<[u8; 32], Vec<u8>> = HashMap::new();
5247        let (to_store, missing) = partition_addresses_by_proof(&[a, b], &empty);
5248        assert!(to_store.is_empty());
5249        assert_eq!(missing, vec![a, b]);
5250
5251        // All proofs present → nothing missing.
5252        let full: HashMap<[u8; 32], Vec<u8>> = HashMap::from([(a, vec![1]), (b, vec![2])]);
5253        let (to_store, missing) = partition_addresses_by_proof(&[a, b], &full);
5254        assert_eq!(to_store, vec![a, b]);
5255        assert!(missing.is_empty());
5256    }
5257
5258    #[test]
5259    fn chunk_spill_round_trip() {
5260        let mut spill = ChunkSpill::new().unwrap();
5261        let data1 = vec![0xAA; 1024];
5262        let data2 = vec![0xBB; 2048];
5263
5264        spill.push(&data1).unwrap();
5265        spill.push(&data2).unwrap();
5266
5267        assert_eq!(spill.len(), 2);
5268        assert_eq!(spill.total_bytes(), 1024 + 2048);
5269        let chunk_entries = spill.chunk_entries().unwrap();
5270        let entry_total: u64 = chunk_entries.iter().map(|(_, size)| *size).sum();
5271        assert_eq!(entry_total, 1024 + 2048);
5272
5273        // Read back and verify
5274        let chunk1 = spill.read_chunk(spill.addresses.first().unwrap()).unwrap();
5275        assert_eq!(&chunk1[..], &data1[..]);
5276
5277        let chunk2 = spill.read_chunk(spill.addresses.get(1).unwrap()).unwrap();
5278        assert_eq!(&chunk2[..], &data2[..]);
5279
5280        // Verify waves with 1-chunk wave size
5281        let waves: Vec<_> = spill.addresses.chunks(1).collect();
5282        assert_eq!(waves.len(), 2);
5283    }
5284
5285    #[test]
5286    fn chunk_spill_cleanup_on_drop() {
5287        let dir;
5288        {
5289            let spill = ChunkSpill::new().unwrap();
5290            dir = spill.dir.clone();
5291            assert!(dir.exists());
5292        }
5293        // After drop, the directory should be cleaned up
5294        assert!(!dir.exists(), "spill dir should be removed on drop");
5295    }
5296
5297    #[test]
5298    fn chunk_spill_deduplicates_identical_content() {
5299        let mut spill = ChunkSpill::new().unwrap();
5300        let data = vec![0xCC; 512];
5301
5302        spill.push(&data).unwrap();
5303        spill.push(&data).unwrap(); // same content, should be skipped
5304        spill.push(&data).unwrap(); // again
5305
5306        assert_eq!(spill.len(), 1, "duplicate chunks should be deduplicated");
5307        assert_eq!(
5308            spill.total_bytes(),
5309            512,
5310            "total_bytes should count unique only"
5311        );
5312
5313        // Different content should still be added
5314        let data2 = vec![0xDD; 256];
5315        spill.push(&data2).unwrap();
5316        assert_eq!(spill.len(), 2);
5317        assert_eq!(spill.total_bytes(), 512 + 256);
5318    }
5319}
5320
5321/// Compile-time assertions that Client file method futures are Send.
5322#[cfg(test)]
5323mod send_assertions {
5324    use super::*;
5325
5326    fn _assert_send<T: Send>(_: &T) {}
5327
5328    #[allow(dead_code, unreachable_code, clippy::diverging_sub_expression)]
5329    async fn _file_upload_is_send(client: &Client) {
5330        let fut = client.file_upload(Path::new("/dev/null"));
5331        _assert_send(&fut);
5332    }
5333
5334    #[allow(dead_code, unreachable_code, clippy::diverging_sub_expression)]
5335    async fn _file_upload_with_mode_is_send(client: &Client) {
5336        let fut = client.file_upload_with_mode(Path::new("/dev/null"), PaymentMode::Auto);
5337        _assert_send(&fut);
5338    }
5339
5340    #[allow(
5341        dead_code,
5342        unreachable_code,
5343        unused_variables,
5344        clippy::diverging_sub_expression
5345    )]
5346    async fn _file_download_is_send(client: &Client) {
5347        let dm: DataMap = todo!();
5348        let fut = client.file_download(&dm, Path::new("/dev/null"));
5349        _assert_send(&fut);
5350    }
5351}