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