Skip to main content

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