Skip to main content

ant_core/data/client/
file.rs

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