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