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