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