use super::*;
use crate::data::client::adaptive::observe_op;
use crate::data::client::batch::{
finalize_batch_payment, PaidChunk, PaymentIntent, PreparedChunk, WaveAggregateStats, WaveResult,
};
use crate::data::client::chunk::{ChunkFetchDiagnostics, ChunkPeerGetResult};
use crate::data::client::classify_error;
use crate::data::client::diagnostics::DownloadDiagnosticsSender;
use crate::data::client::merkle::{
finalize_merkle_batch, merge_merkle_batch_results, merkle_batch_sizes, merkle_billable_leaves,
merkle_deferred_retry, merkle_store_with_retry, should_use_merkle, MerkleBatchPaymentResult,
PaymentMode, PreparedMerkleBatch, DEFERRED_ROUND_DELAYS_SECS,
};
use crate::data::client::Client;
use crate::data::error::{Error, PartialUploadSpend, Result};
use ant_protocol::evm::{Amount, PaymentQuote, QuoteHash, TxHash, MAX_LEAVES};
use ant_protocol::transport::{MultiAddr, PeerId};
use ant_protocol::{compute_address, XorName as ChunkAddress, DATA_TYPE_CHUNK};
use bytes::Bytes;
use fs2::FileExt;
use futures::stream::StreamExt;
use self_encryption::{
stream_decrypt_batch_size, stream_encrypt, streaming_decrypt_with_batch_size, DataMap,
};
use std::collections::{HashMap, HashSet};
use std::io::Write;
use std::num::NonZeroUsize;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use tokio::runtime::Handle;
use tokio::sync::mpsc;
use tracing::{debug, info, warn};
use xor_name::XorName;
type QuoteEntry = (
PeerId,
Vec<MultiAddr>,
PaymentQuote,
Amount,
Option<Vec<u8>>,
);
type DownloadBatchEntry = (usize, std::result::Result<Bytes, XorName>);
#[derive(Debug, Clone)]
struct RecordedFileChunkPeerSweep {
index: usize,
address: ChunkAddress,
sweep: FileChunkPeerSweepReport,
}
#[derive(Clone)]
struct FileDownloadFetchContext {
total_chunks: usize,
peer_count: usize,
fetched_ref: Arc<std::sync::atomic::AtomicUsize>,
progress_ref: Option<mpsc::Sender<DownloadEvent>>,
peer_reports: Option<Arc<Mutex<Vec<RecordedFileChunkPeerSweep>>>>,
diagnostics: Option<DownloadDiagnosticsSender>,
}
const UPLOAD_WAVE_SIZE: usize = super::super::batch::PAYMENT_WAVE_SIZE;
const MERKLE_STORE_MAX_IN_FLIGHT: usize = 64;
fn merkle_store_cap(limiter_current: usize) -> usize {
limiter_current.clamp(1, MERKLE_STORE_MAX_IN_FLIGHT)
}
const DOWNLOAD_STREAM_BATCH_FETCH_MULTIPLIER: usize = 4;
const DOWNLOAD_STREAM_BATCH_MEMORY_BUDGET_DIVISOR: u64 = 4;
const DOWNLOAD_STREAM_BATCH_BYTES_PER_CHUNK_MULTIPLIER: u64 = 3;
const ESTIMATE_SAMPLE_CAP: usize = 5;
const FIRST_DIAGNOSTIC_FETCH_ATTEMPT: usize = 1;
fn distributed_sample_indices(total: usize, cap: usize) -> Vec<usize> {
if total == 0 {
return Vec::new();
}
let sample_limit = total.min(cap);
if sample_limit <= 1 {
return vec![0];
}
let mut indices: Vec<usize> = (0..sample_limit)
.map(|i| i * (total - 1) / (sample_limit - 1))
.collect();
indices.dedup(); indices
}
fn file_chunk_sweep_report_from_peer_results(
attempt: usize,
deferred_retry: bool,
results: &[ChunkPeerGetResult],
) -> (Option<Bytes>, FileChunkPeerSweepReport) {
let mut content = None;
let peers = results
.iter()
.map(|result| {
if content.is_none() {
if let Ok(Some(chunk)) = &result.chunk_result {
content = Some(chunk.content.clone());
}
}
FileChunkPeerReportPeer {
peer_id: result.peer_id,
peer_addrs: result.peer_addrs.clone(),
xor_distance: result.xor_distance,
status: file_chunk_peer_status(&result.chunk_result),
}
})
.collect();
(
content,
FileChunkPeerSweepReport {
attempt,
deferred_retry,
error: None,
peers,
},
)
}
fn file_chunk_sweep_report_from_error(
attempt: usize,
deferred_retry: bool,
error: &Error,
) -> FileChunkPeerSweepReport {
FileChunkPeerSweepReport {
attempt,
deferred_retry,
error: Some(error.to_string()),
peers: Vec::new(),
}
}
fn file_chunk_reports_from_recorded_sweeps(
mut sweeps: Vec<RecordedFileChunkPeerSweep>,
) -> Vec<FileChunkPeerReport> {
sweeps.sort_by_key(|record| (record.index, record.sweep.attempt));
let mut reports: Vec<FileChunkPeerReport> = Vec::new();
for record in sweeps {
if let Some(report) = reports
.last_mut()
.filter(|report| report.index == record.index)
{
report.sweeps.push(record.sweep);
continue;
}
reports.push(FileChunkPeerReport {
index: record.index,
address: record.address,
sweeps: vec![record.sweep],
});
}
reports
}
fn file_chunk_peer_status(
chunk_result: &std::result::Result<Option<ant_protocol::DataChunk>, Error>,
) -> FileChunkPeerStatus {
match chunk_result {
Ok(Some(chunk)) => FileChunkPeerStatus::Found {
bytes: chunk.content.len(),
},
Ok(None) => FileChunkPeerStatus::NotFound,
Err(Error::Timeout(e)) => FileChunkPeerStatus::Timeout { message: e.clone() },
Err(Error::Network(e)) => FileChunkPeerStatus::NetworkError { message: e.clone() },
Err(e) => FileChunkPeerStatus::Error {
message: e.to_string(),
},
}
}
const GAS_PER_WAVE_TX: u128 = 1_500_000;
const GAS_PER_MERKLE_TX: u128 = 500_000;
const ARBITRUM_GAS_PRICE_WEI: u128 = 100_000_000;
const DISK_SPACE_HEADROOM_PERCENT: u64 = 10;
const SPILL_STALE_GRACE_SECS: u64 = 30;
const SPILL_DIR_PREFIX: &str = "spill_";
const SPILL_LOCK_NAME: &str = ".lock";
struct ChunkSpill {
dir: PathBuf,
_lock: std::fs::File,
addresses: Vec<[u8; 32]>,
seen: HashSet<[u8; 32]>,
sizes: HashMap<[u8; 32], u64>,
total_bytes: u64,
}
impl ChunkSpill {
fn spill_root() -> Result<PathBuf> {
use crate::config;
let root = config::data_dir()
.map_err(|e| Error::Config(format!("cannot determine data dir for spill: {e}")))?
.join("spill");
Ok(root)
}
fn new() -> Result<Self> {
let root = Self::spill_root()?;
std::fs::create_dir_all(&root)?;
Self::cleanup_stale(&root);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let unique: u64 = rand::random();
let dir = root.join(format!("{SPILL_DIR_PREFIX}{now}_{unique}"));
std::fs::create_dir(&dir)?;
let lock_path = dir.join(SPILL_LOCK_NAME);
let lock_file = std::fs::File::create(&lock_path).map_err(|e| {
Error::Io(std::io::Error::new(
e.kind(),
format!("failed to create spill lockfile: {e}"),
))
})?;
lock_file.try_lock_exclusive().map_err(|e| {
Error::Io(std::io::Error::new(
e.kind(),
format!("failed to lock spill lockfile: {e}"),
))
})?;
Ok(Self {
dir,
_lock: lock_file,
addresses: Vec::new(),
seen: HashSet::new(),
sizes: HashMap::new(),
total_bytes: 0,
})
}
fn cleanup_stale(root: &Path) {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
if now == 0 {
warn!("System clock before Unix epoch, skipping spill cleanup");
return;
}
let entries = match std::fs::read_dir(root) {
Ok(entries) => entries,
Err(_) => return,
};
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
let suffix = match name_str.strip_prefix(SPILL_DIR_PREFIX) {
Some(s) => s,
None => continue,
};
let timestamp: u64 = match suffix.split('_').next().and_then(|s| s.parse().ok()) {
Some(ts) => ts,
None => continue,
};
if now.saturating_sub(timestamp) < SPILL_STALE_GRACE_SECS {
continue;
}
let file_type = match entry.file_type() {
Ok(ft) => ft,
Err(_) => continue,
};
if !file_type.is_dir() {
continue;
}
let path = entry.path();
let lock_path = path.join(SPILL_LOCK_NAME);
if let Ok(lock_file) = std::fs::File::open(&lock_path) {
use fs2::FileExt;
if lock_file.try_lock_exclusive().is_err() {
debug!("Skipping active spill dir: {}", path.display());
continue;
}
drop(lock_file);
}
info!("Cleaning up stale spill dir: {}", path.display());
if let Err(e) = std::fs::remove_dir_all(&path) {
warn!("Failed to clean up stale spill dir {}: {e}", path.display());
}
}
}
#[allow(dead_code)]
pub(crate) fn run_cleanup() {
if let Ok(root) = Self::spill_root() {
Self::cleanup_stale(&root);
}
}
fn push(&mut self, content: &[u8]) -> Result<()> {
let address = compute_address(content);
if !self.seen.insert(address) {
return Ok(());
}
let path = self.dir.join(hex::encode(address));
std::fs::write(&path, content)?;
let content_len = content.len() as u64;
self.sizes.insert(address, content_len);
self.total_bytes += content_len;
self.addresses.push(address);
Ok(())
}
fn len(&self) -> usize {
self.addresses.len()
}
fn total_bytes(&self) -> u64 {
self.total_bytes
}
fn chunk_entries(&self) -> Result<Vec<([u8; 32], u64)>> {
self.addresses
.iter()
.map(|address| {
self.sizes
.get(address)
.copied()
.map(|size| (*address, size))
.ok_or_else(|| {
Error::Storage(format!(
"missing size for spilled chunk {}",
hex::encode(address)
))
})
})
.collect()
}
fn read_chunk(&self, address: &[u8; 32]) -> Result<Bytes> {
let path = self.dir.join(hex::encode(address));
let data = std::fs::read(&path).map_err(|e| {
Error::Io(std::io::Error::new(
e.kind(),
format!("reading spilled chunk {}: {e}", hex::encode(address)),
))
})?;
Ok(Bytes::from(data))
}
fn read_chunks(&self, addresses: &[[u8; 32]]) -> Result<Vec<Bytes>> {
addresses.iter().map(|addr| self.read_chunk(addr)).collect()
}
fn read_all_chunks(&self) -> Result<Vec<Bytes>> {
self.read_chunks(&self.addresses)
}
fn cleanup(&self) {
if let Err(e) = std::fs::remove_dir_all(&self.dir) {
warn!(
"Failed to clean up chunk spill dir {}: {e}",
self.dir.display()
);
}
}
}
impl Drop for ChunkSpill {
fn drop(&mut self) {
self.cleanup();
}
}
#[cfg(test)]
fn cached_merkle_covers_addresses(
cached: &MerkleBatchPaymentResult,
addresses: &[[u8; 32]],
) -> bool {
addresses
.iter()
.all(|addr| cached.proofs.contains_key(addr))
}
fn partition_addresses_by_proof(
addresses: &[[u8; 32]],
proofs: &HashMap<[u8; 32], Vec<u8>>,
) -> (Vec<[u8; 32]>, Vec<[u8; 32]>) {
addresses
.iter()
.copied()
.partition(|addr| proofs.contains_key(addr))
}
fn proofless_clause(proofless_count: usize, payment_refusal: Option<&str>) -> Option<String> {
if proofless_count == 0 {
return None;
}
Some(match payment_refusal {
Some(refusal) => format!(
"{proofless_count} chunk(s) have no merkle proof because storers refused this \
client's settlement version during payment. That refusal covers those chunks; \
any spend reported here settled for earlier sub-batches. {refusal}"
),
None => format!("{proofless_count} chunk(s) have no merkle proof"),
})
}
fn merkle_partial_reason(
failed_count: usize,
proofless_count: usize,
total_attempts: usize,
payment_refusal: Option<&str>,
) -> String {
let quorum = |n: usize| format!("{n} chunk(s) short of quorum after {total_attempts} attempts");
match proofless_clause(proofless_count, payment_refusal) {
None => quorum(failed_count),
Some(proofless) => match failed_count.saturating_sub(proofless_count) {
0 => proofless,
short => format!("{}; {proofless}", quorum(short)),
},
}
}
fn merkle_fatal_reason(
abort: &str,
proofless_count: usize,
payment_refusal: Option<&str>,
) -> String {
match proofless_clause(proofless_count, payment_refusal) {
Some(proofless) => format!("{abort}; {proofless}"),
None => abort.to_string(),
}
}
fn partial_upload_after_fatal(
addresses: &[[u8; 32]],
stored_addresses: Vec<[u8; 32]>,
stored_count: usize,
total_chunks: usize,
known_failed: Vec<([u8; 32], String)>,
spend: PartialUploadSpend,
reason: String,
) -> Error {
let stored_set: HashSet<[u8; 32]> = stored_addresses.iter().copied().collect();
let mut failed_map: HashMap<[u8; 32], String> = HashMap::new();
for (addr, msg) in known_failed {
if !stored_set.contains(&addr) {
failed_map.entry(addr).or_insert(msg);
}
}
for addr in addresses {
if !stored_set.contains(addr) {
failed_map.entry(*addr).or_insert_with(|| reason.clone());
}
}
let failed: Vec<([u8; 32], String)> = failed_map.into_iter().collect();
let failed_count = failed.len();
Error::PartialUpload {
stored: stored_addresses,
stored_count,
failed,
failed_count,
total_chunks,
spend: Box::new(spend),
reason,
}
}
fn require_fully_paid_for_resumable(winner_pool_hashes: &[Option<[u8; 32]>]) -> Result<()> {
let unpaid = winner_pool_hashes.iter().filter(|h| h.is_none()).count();
if unpaid > 0 {
return Err(Error::Payment(format!(
"{unpaid}/{} sub-batch(es) unpaid: the resumable finalize requires every \
sub-batch to be paid, because a resume handle cannot acquire proofs for \
unpaid chunks and would never drain to Complete. Pay every sub-batch, or \
use finalize_upload_merkle_multi() to finalize a partial payment (its \
unpaid chunks are reported through PartialUpload).",
winner_pool_hashes.len()
)));
}
Ok(())
}
fn fold_external_merkle_payments(
prepared_batches: Vec<PreparedMerkleBatch>,
winner_pool_hashes: Vec<Option<[u8; 32]>>,
) -> Result<MerkleBatchPaymentResult> {
let batch_count = prepared_batches.len();
if winner_pool_hashes.len() != batch_count {
return Err(Error::Payment(format!(
"Expected {batch_count} winner pool hash entries (one per \
prepared sub-batch), got {}.",
winner_pool_hashes.len()
)));
}
let mut paid = Vec::with_capacity(batch_count);
let mut unpaid_batches = 0usize;
for (batch, hash) in prepared_batches.into_iter().zip(winner_pool_hashes) {
match hash {
Some(h) => paid.push(finalize_merkle_batch(batch, h)?),
None => unpaid_batches += 1,
}
}
if paid.is_empty() {
return Err(Error::Payment(
"No merkle sub-batch was paid — nothing to finalize. \
Pay at least one batch or drop the prepared upload."
.to_string(),
));
}
if unpaid_batches > 0 {
warn!(
"External merkle finalize: {unpaid_batches}/{batch_count} sub-batch(es) \
unpaid; their chunks will be reported as failed"
);
}
Ok(merge_merkle_batch_results(paid))
}
fn assemble_merkle_finalize_outcome(
store_result: Result<(usize, String, u128, WaveAggregateStats)>,
data_map: DataMap,
data_map_address: Option<[u8; 32]>,
total_chunks: usize,
chunk_store: ExternalChunkStore,
batch_result: MerkleBatchPaymentResult,
) -> Result<FinalizeOutcome> {
match store_result {
Ok((chunks_stored, _storage_cost, _gas_cost, stats)) => {
info!("External-signer merkle upload finalized: {chunks_stored} chunks stored");
Ok(FinalizeOutcome::Complete(FileUploadResult {
data_map,
chunks_stored,
chunks_failed: 0,
total_chunks,
payment_mode_used: PaymentMode::Merkle,
storage_cost_atto: "0".into(),
gas_cost_wei: 0,
data_map_address,
chunk_attempts_total: stats.chunk_attempts_total,
store_durations_ms: stats.store_durations_ms,
retries_histogram: stats.retries_histogram,
}))
}
Err(Error::PartialUpload {
stored,
stored_count,
failed,
failed_count,
spend,
..
}) => {
let unstored_addresses: Vec<[u8; 32]> = failed.iter().map(|(addr, _)| *addr).collect();
let result = FileUploadResult {
data_map: data_map.clone(),
chunks_stored: stored_count,
chunks_failed: failed_count,
total_chunks,
payment_mode_used: PaymentMode::Merkle,
storage_cost_atto: spend.storage_cost_atto.clone(),
gas_cost_wei: spend.gas_cost_wei,
data_map_address,
chunk_attempts_total: 0,
store_durations_ms: Vec::new(),
retries_histogram: [0; 4],
};
let resume = MerkleFinalizeResume {
data_map,
data_map_address,
total_chunks,
chunk_store,
unstored_addresses,
batch_result,
stored_addresses: stored,
};
Ok(FinalizeOutcome::Partial {
result,
resume: FinalizeResume::Merkle(Box::new(resume)),
})
}
Err(e) => Err(e),
}
}
fn assemble_wave_finalize_outcome(
wave_result: WaveResult,
mut retained: HashMap<[u8; 32], PaidChunk>,
data_map: DataMap,
data_map_address: Option<[u8; 32]>,
total_chunks: usize,
already_stored_count: usize,
storage_cost_atto: String,
) -> FinalizeOutcome {
let stored_count = already_stored_count + wave_result.stored.len();
if wave_result.failed.is_empty() {
info!("External-signer upload finalized: {stored_count} chunks stored");
let mut stats = WaveAggregateStats::default();
stats.absorb(&wave_result);
return FinalizeOutcome::Complete(FileUploadResult {
data_map,
chunks_stored: stored_count,
chunks_failed: 0,
total_chunks,
payment_mode_used: PaymentMode::Single,
storage_cost_atto,
gas_cost_wei: 0,
data_map_address,
chunk_attempts_total: stats.chunk_attempts_total,
store_durations_ms: stats.store_durations_ms,
retries_histogram: stats.retries_histogram,
});
}
let failed_count = wave_result.failed.len();
let failed_paid_chunks: Vec<PaidChunk> = wave_result
.failed
.iter()
.filter_map(|(addr, _)| retained.remove(addr))
.collect();
let result = FileUploadResult {
data_map: data_map.clone(),
chunks_stored: stored_count,
chunks_failed: failed_count,
total_chunks,
payment_mode_used: PaymentMode::Single,
storage_cost_atto: storage_cost_atto.clone(),
gas_cost_wei: 0,
data_map_address,
chunk_attempts_total: 0,
store_durations_ms: Vec::new(),
retries_histogram: [0; 4],
};
let resume = WaveFinalizeResume {
data_map,
data_map_address,
total_chunks,
stored_count,
failed_paid_chunks,
storage_cost_atto,
};
FinalizeOutcome::Partial {
result,
resume: FinalizeResume::Wave(Box::new(resume)),
}
}
#[derive(Debug)]
#[cfg(test)]
struct SingleWaveOutcome {
stored: Vec<[u8; 32]>,
failed: Vec<([u8; 32], String)>,
storage_atto: Amount,
gas_wei: u128,
stats: WaveAggregateStats,
}
#[cfg(test)]
fn fold_single_wave(
result: Result<(Vec<[u8; 32]>, String, u128, WaveAggregateStats)>,
) -> Result<SingleWaveOutcome> {
match result {
Ok((stored, storage, gas, stats)) => Ok(SingleWaveOutcome {
stored,
failed: Vec::new(),
storage_atto: storage.parse().unwrap_or(Amount::ZERO),
gas_wei: gas,
stats,
}),
Err(Error::PartialUpload {
stored,
failed,
spend,
..
}) => Ok(SingleWaveOutcome {
stored,
failed,
storage_atto: spend.storage_cost_atto.parse().unwrap_or(Amount::ZERO),
gas_wei: spend.gas_cost_wei,
stats: WaveAggregateStats::default(),
}),
Err(e) => Err(e),
}
}
#[allow(clippy::too_many_arguments)]
#[cfg(test)]
fn settlement_refusal_after_paid_waves(
refusal: &str,
wave_num: usize,
wave_count: usize,
stored_addresses: Vec<[u8; 32]>,
total_stored: usize,
remaining: &[[u8; 32]],
total_chunks: usize,
total_storage: Amount,
total_gas: u128,
) -> Error {
let remaining_count = remaining.len();
let refused_note = format!(
"not quoted: storers refused this client's settlement version at wave \
{wave_num}/{wave_count}"
);
let failed: Vec<([u8; 32], String)> = remaining
.iter()
.map(|addr| (*addr, refused_note.clone()))
.collect();
Error::PartialUpload {
stored: stored_addresses,
stored_count: total_stored,
failed,
failed_count: remaining_count,
total_chunks,
spend: Box::new(PartialUploadSpend {
storage_cost_atto: total_storage.to_string(),
gas_cost_wei: total_gas,
}),
reason: format!(
"storers refused this client's settlement version at wave {wave_num}/{wave_count}: \
the {total_stored} chunk(s) in earlier wave(s) were already paid for and stored, \
and the remaining {remaining_count} chunk(s) were neither quoted nor paid. {refusal}"
),
}
}
fn check_disk_space_for_spill(file_size: u64) -> Result<()> {
let spill_root = ChunkSpill::spill_root()?;
std::fs::create_dir_all(&spill_root)?;
let available = fs2::available_space(&spill_root).map_err(|e| {
Error::Io(std::io::Error::new(
e.kind(),
format!(
"failed to query disk space on {}: {e}",
spill_root.display()
),
))
})?;
let headroom = file_size / DISK_SPACE_HEADROOM_PERCENT;
let required = file_size.saturating_add(headroom);
if available < required {
let avail_mb = available / (1024 * 1024);
let req_mb = required / (1024 * 1024);
return Err(Error::InsufficientDiskSpace(format!(
"need ~{req_mb} MB in spill dir ({}) but only {avail_mb} MB available",
spill_root.display()
)));
}
debug!(
"Disk space check passed: {available} bytes available, {required} bytes required (spill: {})",
spill_root.display()
);
Ok(())
}
fn usable_memory_bytes() -> Option<u64> {
let mut system = sysinfo::System::new();
system.refresh_memory();
let available_memory = system.available_memory();
let free_memory = system.free_memory();
let used_memory = system.used_memory();
let total_memory = system.total_memory();
let unused_memory = total_memory.saturating_sub(used_memory);
let mut usable = [available_memory, free_memory, unused_memory]
.into_iter()
.filter(|bytes| *bytes > 0)
.max();
let cgroup_free_memory = system
.cgroup_limits()
.filter(|limits| limits.total_memory > 0)
.map(|limits| limits.free_memory);
if let Some(cgroup_free_memory) = cgroup_free_memory {
usable = Some(usable.unwrap_or(u64::MAX).min(cgroup_free_memory));
}
debug!(
available_memory,
free_memory,
used_memory,
total_memory,
cgroup_free_memory,
usable_memory = ?usable,
"Detected usable memory for stream decrypt batch sizing"
);
usable
}
fn stream_decrypt_batch_memory_cap(usable_memory_bytes: u64) -> usize {
let budget = usable_memory_bytes / DOWNLOAD_STREAM_BATCH_MEMORY_BUDGET_DIVISOR;
let estimated_bytes_per_chunk = (self_encryption::MAX_CHUNK_SIZE as u64)
.saturating_mul(DOWNLOAD_STREAM_BATCH_BYTES_PER_CHUNK_MULTIPLIER)
.max(1);
let cap = (budget / estimated_bytes_per_chunk).max(1);
usize::try_from(cap).unwrap_or(usize::MAX)
}
fn adaptive_stream_decrypt_batch_size(
total_chunks: usize,
fetch_cap: usize,
configured_batch_floor: usize,
usable_memory_bytes: Option<u64>,
) -> usize {
let fetch_target = fetch_cap
.max(1)
.saturating_mul(DOWNLOAD_STREAM_BATCH_FETCH_MULTIPLIER);
let requested = match usable_memory_bytes {
Some(bytes) => {
let memory_cap = stream_decrypt_batch_memory_cap(bytes);
configured_batch_floor
.max(fetch_target)
.max(1)
.min(memory_cap)
}
None => configured_batch_floor.max(1),
};
requested.min(total_chunks.max(1)).max(1)
}
#[allow(clippy::large_enum_variant)]
#[derive(Debug)]
pub enum ExternalPaymentInfo {
WaveBatch {
prepared_chunks: Vec<PreparedChunk>,
payment_intent: PaymentIntent,
},
Merkle {
prepared_batches: Vec<PreparedMerkleBatch>,
chunk_store: ExternalChunkStore,
chunk_addresses: Vec<[u8; 32]>,
},
}
pub struct ExternalChunkStore(ChunkSpill);
impl ExternalChunkStore {
fn from_spill(spill: ChunkSpill) -> Self {
Self(spill)
}
fn spill(&self) -> &ChunkSpill {
&self.0
}
}
impl std::fmt::Debug for ExternalChunkStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ExternalChunkStore")
.field("chunks", &self.0.len())
.field("bytes", &self.0.total_bytes())
.finish()
}
}
#[derive(Debug)]
#[non_exhaustive]
pub struct PreparedUpload {
pub data_map: DataMap,
pub payment_info: ExternalPaymentInfo,
pub data_map_address: Option<[u8; 32]>,
pub already_stored_addresses: Vec<[u8; 32]>,
pub total_chunks: usize,
}
#[derive(Debug)]
pub enum FinalizeOutcome {
Complete(FileUploadResult),
Partial {
result: FileUploadResult,
resume: FinalizeResume,
},
}
#[derive(Debug)]
#[non_exhaustive]
pub enum FinalizeResume {
Wave(Box<WaveFinalizeResume>),
Merkle(Box<MerkleFinalizeResume>),
}
#[non_exhaustive]
pub struct WaveFinalizeResume {
data_map: DataMap,
data_map_address: Option<[u8; 32]>,
total_chunks: usize,
stored_count: usize,
failed_paid_chunks: Vec<PaidChunk>,
storage_cost_atto: String,
}
impl std::fmt::Debug for WaveFinalizeResume {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WaveFinalizeResume")
.field("total_chunks", &self.total_chunks)
.field("stored", &self.stored_count)
.field("unstored", &self.failed_paid_chunks.len())
.field("public", &self.data_map_address.is_some())
.finish_non_exhaustive()
}
}
#[non_exhaustive]
pub struct MerkleFinalizeResume {
data_map: DataMap,
data_map_address: Option<[u8; 32]>,
total_chunks: usize,
chunk_store: ExternalChunkStore,
unstored_addresses: Vec<[u8; 32]>,
batch_result: MerkleBatchPaymentResult,
stored_addresses: Vec<[u8; 32]>,
}
impl std::fmt::Debug for MerkleFinalizeResume {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MerkleFinalizeResume")
.field("total_chunks", &self.total_chunks)
.field("stored", &self.stored_addresses.len())
.field("unstored", &self.unstored_addresses.len())
.field("public", &self.data_map_address.is_some())
.finish_non_exhaustive()
}
}
type EncryptionChannels = (
tokio::sync::mpsc::Receiver<Bytes>,
tokio::sync::oneshot::Receiver<DataMap>,
tokio::task::JoinHandle<Result<()>>,
);
fn spawn_file_encryption(path: PathBuf) -> Result<EncryptionChannels> {
let metadata = std::fs::metadata(&path)?;
let data_size = usize::try_from(metadata.len())
.map_err(|e| Error::Encryption(format!("file size exceeds platform usize: {e}")))?;
let (chunk_tx, chunk_rx) = tokio::sync::mpsc::channel(2);
let (datamap_tx, datamap_rx) = tokio::sync::oneshot::channel();
let handle = tokio::task::spawn_blocking(move || {
let file = std::fs::File::open(&path)?;
let mut reader = std::io::BufReader::new(file);
let read_error: Arc<Mutex<Option<std::io::Error>>> = Arc::new(Mutex::new(None));
let read_error_clone = Arc::clone(&read_error);
let data_iter = std::iter::from_fn(move || {
let mut buffer = vec![0u8; 8192];
match std::io::Read::read(&mut reader, &mut buffer) {
Ok(0) => None,
Ok(n) => {
buffer.truncate(n);
Some(Bytes::from(buffer))
}
Err(e) => {
let mut guard = read_error_clone
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
*guard = Some(e);
None
}
}
});
let mut stream = stream_encrypt(data_size, data_iter)
.map_err(|e| Error::Encryption(format!("stream_encrypt failed: {e}")))?;
for chunk_result in stream.chunks() {
{
let guard = read_error
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if let Some(ref e) = *guard {
return Err(Error::Io(std::io::Error::new(e.kind(), e.to_string())));
}
}
let (_hash, content) = chunk_result
.map_err(|e| Error::Encryption(format!("chunk encryption failed: {e}")))?;
if chunk_tx.blocking_send(content).is_err() {
return Err(Error::Encryption("upload receiver dropped".to_string()));
}
}
{
let guard = read_error
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if let Some(ref e) = *guard {
return Err(Error::Io(std::io::Error::new(e.kind(), e.to_string())));
}
}
let datamap = stream
.into_datamap()
.ok_or_else(|| Error::Encryption("no DataMap after encryption".to_string()))?;
if datamap_tx.send(datamap).is_err() {
warn!("DataMap receiver dropped — upload may have been cancelled");
}
Ok(())
});
Ok((chunk_rx, datamap_rx, handle))
}
struct TempDownload {
path: Option<PathBuf>,
}
impl TempDownload {
fn new(path: PathBuf) -> Self {
Self { path: Some(path) }
}
fn path(&self) -> &Path {
self.path
.as_deref()
.expect("TempDownload::path called after commit")
}
fn commit(mut self, dest: &Path) -> std::io::Result<()> {
std::fs::rename(self.path(), dest)?; self.path = None; Ok(())
}
}
impl Drop for TempDownload {
fn drop(&mut self) {
if let Some(path) = self.path.take() {
if let Err(e) = std::fs::remove_file(&path) {
if e.kind() != std::io::ErrorKind::NotFound {
warn!(
"Failed to remove temp download file {}: {e}",
path.display()
);
}
}
}
}
}
struct SpillUploadAdapter<'a> {
client: &'a Client,
spill: &'a ChunkSpill,
progress: Option<&'a mpsc::Sender<UploadEvent>>,
checkpoint: &'a Path,
}
#[async_trait::async_trait]
impl super::super::upload::UploadAdapter for SpillUploadAdapter<'_> {
#[cfg(feature = "native")]
fn initialize_payment_attempt(&self, attempt: &mut super::super::upload_state::PaymentAttempt) {
super::super::native_payment::initialize(attempt);
}
#[cfg(feature = "native")]
async fn submit_payment(
&self,
plans: &[crate::data::client::batch::ChunkPaymentPlan],
state: &mut crate::data::client::upload_state::UploadState,
) -> Result<super::super::upload::UploadPayment> {
super::super::native_payment::pay(self.client, self, plans, state).await
}
#[cfg(feature = "native")]
async fn reconcile_payment(
&self,
plans: &[crate::data::client::batch::ChunkPaymentPlan],
state: &mut crate::data::client::upload_state::UploadState,
) -> Result<super::super::upload::UploadPayment> {
super::super::native_payment::pay(self.client, self, plans, state).await
}
#[cfg(feature = "native")]
async fn submit_merkle_payment(
&self,
batch: &super::super::merkle::PreparedMerkleBatch,
state: &mut crate::data::client::upload_state::UploadState,
) -> Result<super::super::upload::MerkleUploadPayment> {
super::super::native_payment::pay_merkle(self.client, self, batch, state).await
}
#[cfg(feature = "native")]
async fn reconcile_merkle_payment(
&self,
batch: &super::super::merkle::PreparedMerkleBatch,
state: &mut crate::data::client::upload_state::UploadState,
) -> Result<super::super::upload::MerkleUploadPayment> {
super::super::native_payment::pay_merkle(self.client, self, batch, state).await
}
async fn load(&self, record: super::super::upload::UploadRecord) -> Result<Bytes> {
self.spill.read_chunk(&record.address)
}
async fn pay(
&self,
plans: &[crate::data::client::batch::ChunkPaymentPlan],
) -> Result<super::super::upload::UploadPayment> {
let adapter = super::super::upload::MemoryUploadAdapter {
client: self.client,
chunks: &[],
progress: self.progress,
stored_offset: 0,
file_total: self.spill.len(),
resume_key: None,
};
adapter.pay(plans).await
}
async fn pay_merkle(
&self,
batch: &PreparedMerkleBatch,
) -> Result<super::super::upload::MerkleUploadPayment> {
let adapter = super::super::upload::MemoryUploadAdapter {
client: self.client,
chunks: &[],
progress: self.progress,
stored_offset: 0,
file_total: self.spill.len(),
resume_key: None,
};
adapter.pay_merkle(batch).await
}
async fn checkpoint(
&self,
state: &super::super::upload_state::UploadState,
_: Option<&super::super::upload::UploadPayment>,
) -> Result<()> {
use std::io::Write;
let bytes = state.checkpoint()?;
let directory = self
.checkpoint
.parent()
.ok_or_else(|| Error::Config("missing checkpoint directory".into()))?;
let mut file = tempfile::NamedTempFile::new_in(directory)?;
file.write_all(&bytes)?;
file.as_file().sync_all()?;
file.persist(self.checkpoint)
.map_err(|e| Error::Io(e.error))?;
Ok(())
}
fn stored(&self, stored: usize, total: usize) {
if let Some(progress) = self.progress {
let _ = progress.try_send(UploadEvent::ChunkStored { stored, total });
}
}
fn quoted(&self, quoted: usize, total: usize) {
if let Some(progress) = self.progress {
let _ = progress.try_send(UploadEvent::ChunkQuoted { quoted, total });
}
}
}
impl Client {
pub async fn file_upload(&self, path: &Path) -> Result<FileUploadResult> {
self.file_upload_with_mode(path, PaymentMode::Auto).await
}
pub async fn estimate_upload_cost(
&self,
path: &Path,
mode: PaymentMode,
progress: Option<mpsc::Sender<UploadEvent>>,
) -> Result<UploadCostEstimate> {
let file_size = std::fs::metadata(path).map_err(Error::Io)?.len();
if file_size < 3 {
return Err(Error::InvalidData(
"File too small: self-encryption requires at least 3 bytes".into(),
));
}
check_disk_space_for_spill(file_size)?;
info!(
"Estimating upload cost for {} ({file_size} bytes)",
path.display()
);
let (spill, _data_map) = self.encrypt_file_to_spill(path, progress.as_ref()).await?;
let chunk_count = spill.len();
if let Some(ref tx) = progress {
let _ = tx
.send(UploadEvent::Encrypted {
total_chunks: chunk_count,
})
.await;
}
info!("Encrypted into {chunk_count} chunks, requesting quote");
let uses_merkle = should_use_merkle(chunk_count, mode);
let sample_indices = distributed_sample_indices(spill.addresses.len(), ESTIMATE_SAMPLE_CAP);
let mut sampled = 0usize;
let mut all_already_stored = true;
let mut quotes_opt: Option<Vec<QuoteEntry>> = None;
for &idx in &sample_indices {
let addr = &spill.addresses[idx];
sampled += 1;
let chunk_bytes = spill.read_chunk(addr)?;
let data_size = u64::try_from(chunk_bytes.len())
.map_err(|e| Error::InvalidData(format!("chunk size too large: {e}")))?;
let result = if uses_merkle {
self.get_store_quotes_with_fault_tolerance(addr, data_size, DATA_TYPE_CHUNK)
.await
} else {
self.get_store_quotes(addr, data_size, DATA_TYPE_CHUNK)
.await
};
match result {
Ok(q) => {
quotes_opt = Some(q);
all_already_stored = false;
break;
}
Err(Error::AlreadyStored) => {
debug!(
"Sample chunk {} already stored; trying next address ({sampled}/{})",
hex::encode(addr),
sample_indices.len()
);
continue;
}
Err(e) => return Err(e),
}
}
let quotes = match quotes_opt {
Some(q) => q,
None if all_already_stored && sampled == chunk_count => {
info!("All {chunk_count} chunks already stored; returning zero-cost estimate");
return Ok(UploadCostEstimate {
file_size,
chunk_count,
storage_cost_atto: "0".into(),
estimated_gas_cost_wei: "0".into(),
payment_mode: if uses_merkle {
PaymentMode::Merkle
} else {
PaymentMode::Single
},
confidence: CostEstimateConfidence::VerifiedAllAlreadyStored,
});
}
None => {
info!(
"All {sampled}/{chunk_count} sampled chunks already stored; \
returning incomplete zero-cost estimate"
);
return Ok(UploadCostEstimate {
file_size,
chunk_count,
storage_cost_atto: "0".into(),
estimated_gas_cost_wei: "0".into(),
payment_mode: if uses_merkle {
PaymentMode::Merkle
} else {
PaymentMode::Single
},
confidence: CostEstimateConfidence::AllSamplesAlreadyStoredIncomplete,
});
}
};
let prices: Vec<Amount> = quotes.iter().map(|(_, _, _, price, _)| *price).collect();
let median_price = crate::payment_policy::median_quote_index(&prices)
.map_or(Amount::ZERO, |index| prices[index]);
let per_chunk_cost = crate::payment_policy::enhanced_payment_amount(median_price)
.map_err(|error| Error::Payment(error.to_string()))?;
let chunk_count_u64 = u64::try_from(chunk_count).unwrap_or(u64::MAX);
let billable_units = if uses_merkle {
merkle_billable_leaves(chunk_count_u64)
} else {
chunk_count_u64
};
let total_storage = per_chunk_cost * Amount::from(billable_units);
let waves = u128::try_from(chunk_count.div_ceil(UPLOAD_WAVE_SIZE)).unwrap_or(u128::MAX);
let merkle_batches =
u128::try_from(merkle_batch_sizes(chunk_count).len()).unwrap_or(u128::MAX);
let estimated_gas: u128 = if uses_merkle {
merkle_batches
.saturating_mul(GAS_PER_MERKLE_TX)
.saturating_mul(ARBITRUM_GAS_PRICE_WEI)
} else {
waves
.saturating_mul(GAS_PER_WAVE_TX)
.saturating_mul(ARBITRUM_GAS_PRICE_WEI)
};
info!(
"Estimate: {chunk_count} chunks, storage={total_storage} atto, gas~={estimated_gas} wei"
);
Ok(UploadCostEstimate {
file_size,
chunk_count,
storage_cost_atto: total_storage.to_string(),
estimated_gas_cost_wei: estimated_gas.to_string(),
payment_mode: if uses_merkle {
PaymentMode::Merkle
} else {
PaymentMode::Single
},
confidence: CostEstimateConfidence::PricedSample,
})
}
pub async fn file_prepare_upload(&self, path: &Path) -> Result<PreparedUpload> {
self.file_prepare_upload_with_progress(path, Visibility::Private, None)
.await
}
pub async fn file_prepare_upload_with_visibility(
&self,
path: &Path,
visibility: Visibility,
) -> Result<PreparedUpload> {
self.file_prepare_upload_with_progress(path, visibility, None)
.await
}
pub async fn file_prepare_upload_with_progress(
&self,
path: &Path,
visibility: Visibility,
progress: Option<mpsc::Sender<UploadEvent>>,
) -> Result<PreparedUpload> {
self.file_prepare_upload_with_mode(path, visibility, PaymentMode::Auto, progress)
.await
}
pub async fn file_prepare_upload_with_mode(
&self,
path: &Path,
visibility: Visibility,
mode: PaymentMode,
progress: Option<mpsc::Sender<UploadEvent>>,
) -> Result<PreparedUpload> {
debug!(
"Preparing file upload for external signing (visibility={visibility:?}, mode={mode:?}): {}",
path.display()
);
let file_size = std::fs::metadata(path)?.len();
check_disk_space_for_spill(file_size)?;
let (mut spill, data_map) = self.encrypt_file_to_spill(path, progress.as_ref()).await?;
info!(
"Encrypted {} into {} chunks for external signing (spilled to disk)",
path.display(),
spill.len()
);
let data_map_address = match visibility {
Visibility::Private => None,
Visibility::Public => {
let (address, serialized) =
crate::client_engine::files::public_map_record(&data_map)
.map_err(Error::Serialization)?;
info!(
"Public upload: bundling DataMap chunk ({} bytes) at address {}",
serialized.len(),
hex::encode(address)
);
spill.push(&serialized)?;
Some(address)
}
};
let chunk_count = spill.len();
if let Some(ref tx) = progress {
let _ = tx
.send(UploadEvent::Encrypted {
total_chunks: chunk_count,
})
.await;
}
let (payment_info, already_stored_addresses) = if should_use_merkle(chunk_count, mode) {
info!("Using merkle batch preparation for {chunk_count} file chunks");
let chunk_entries = spill.chunk_entries()?;
let merkle_plan = self
.plan_merkle_upload(chunk_entries, DATA_TYPE_CHUNK, progress.as_ref())
.await?;
if merkle_plan.to_upload.is_empty() {
info!("All {chunk_count} file chunks already stored; no external payment needed");
(
ExternalPaymentInfo::WaveBatch {
prepared_chunks: Vec::new(),
payment_intent: PaymentIntent::from_prepared_chunks(&[]),
},
merkle_plan.already_stored,
)
} else if !should_use_merkle(merkle_plan.to_upload.len(), mode) {
info!(
"{} file chunks need upload after merkle preflight; preparing wave-batch payment",
merkle_plan.to_upload.len()
);
let chunk_data = spill.read_chunks(&merkle_plan.to_upload)?;
let (payment_info, mut wave_already_stored) = self
.prepare_wave_batch_external_chunks(chunk_data, progress.as_ref(), chunk_count)
.await?;
let mut already_stored = merkle_plan.already_stored;
already_stored.append(&mut wave_already_stored);
(payment_info, already_stored)
} else {
match self
.prepare_merkle_batches_external(
&merkle_plan.to_upload,
DATA_TYPE_CHUNK,
merkle_plan.to_upload_avg_size(),
self.merkle_external_batch_cap(),
)
.await
{
Ok(prepared_batches) => {
info!(
"File prepared for external merkle signing: {} chunks in {} sub-batch(es) ({})",
merkle_plan.to_upload.len(),
prepared_batches.len(),
path.display()
);
(
ExternalPaymentInfo::Merkle {
prepared_batches,
chunk_store: ExternalChunkStore::from_spill(spill),
chunk_addresses: merkle_plan.to_upload,
},
merkle_plan.already_stored,
)
}
Err(Error::InsufficientPeers(ref msg)) => {
info!(
"External merkle preparation needs more peers ({msg}); preparing wave-batch payment"
);
let chunk_data = spill.read_chunks(&merkle_plan.to_upload)?;
let (payment_info, mut wave_already_stored) = self
.prepare_wave_batch_external_chunks(
chunk_data,
progress.as_ref(),
chunk_count,
)
.await?;
let mut already_stored = merkle_plan.already_stored;
already_stored.append(&mut wave_already_stored);
(payment_info, already_stored)
}
Err(e) => return Err(e),
}
}
} else {
let chunk_data = spill.read_all_chunks()?;
self.prepare_wave_batch_external_chunks(chunk_data, progress.as_ref(), chunk_count)
.await?
};
if let Some(addr) = data_map_address {
let data_map_needs_payment = match &payment_info {
ExternalPaymentInfo::WaveBatch {
prepared_chunks, ..
} => prepared_chunks.iter().any(|c| c.address == addr),
ExternalPaymentInfo::Merkle {
chunk_addresses, ..
} => chunk_addresses.contains(&addr),
};
if !data_map_needs_payment {
info!(
"Public upload: DataMap chunk {} was already stored \
on the network — address is retrievable without a \
new payment",
hex::encode(addr)
);
}
}
Ok(PreparedUpload {
data_map,
payment_info,
data_map_address,
already_stored_addresses,
total_chunks: chunk_count,
})
}
async fn prepare_wave_batch_external_chunks(
&self,
chunk_data: Vec<Bytes>,
progress: Option<&mpsc::Sender<UploadEvent>>,
progress_total: usize,
) -> Result<(ExternalPaymentInfo, Vec<[u8; 32]>)> {
let chunk_count = chunk_data.len();
let chunks_with_addr: Vec<(Bytes, [u8; 32])> = chunk_data
.into_iter()
.map(|content| {
let address = compute_address(&content);
(content, address)
})
.collect();
let quote_limiter = self.controller().quote.clone();
let quote_concurrency = quote_limiter.current().min(chunk_count.max(1));
let mut quote_stream = crate::client_engine::bounded_unordered(
chunks_with_addr.into_iter().map(|(content, address)| {
let limiter = quote_limiter.clone();
async move {
let result = observe_op(
&limiter,
|| async move { self.prepare_chunk_payment(content).await },
classify_error,
)
.await;
(address, result)
}
}),
quote_concurrency,
);
let mut prepared_chunks = Vec::with_capacity(chunk_count);
let mut already_stored = Vec::new();
let mut quoted = 0usize;
while let Some((address, result)) = quote_stream.next().await {
match result? {
Some(prepared) => prepared_chunks.push(prepared),
None => already_stored.push(address),
}
quoted += 1;
if let Some(tx) = progress {
let _ = tx.try_send(UploadEvent::ChunkQuoted {
quoted,
total: progress_total,
});
}
}
let payment_intent = PaymentIntent::from_prepared_chunks(&prepared_chunks);
info!(
"Prepared external wave-batch payment: {} chunks, {} already stored, total {} atto",
prepared_chunks.len(),
already_stored.len(),
payment_intent.total_amount,
);
Ok((
ExternalPaymentInfo::WaveBatch {
prepared_chunks,
payment_intent,
},
already_stored,
))
}
pub async fn finalize_upload(
&self,
prepared: PreparedUpload,
tx_hash_map: &HashMap<QuoteHash, TxHash>,
) -> Result<FileUploadResult> {
self.finalize_upload_with_progress(prepared, tx_hash_map, None)
.await
}
pub async fn finalize_upload_with_progress(
&self,
prepared: PreparedUpload,
tx_hash_map: &HashMap<QuoteHash, TxHash>,
progress: Option<mpsc::Sender<UploadEvent>>,
) -> Result<FileUploadResult> {
let data_map_address = prepared.data_map_address;
let already_stored_addresses = prepared.already_stored_addresses;
let already_stored_count = already_stored_addresses.len();
let total_chunks = prepared.total_chunks;
match prepared.payment_info {
ExternalPaymentInfo::WaveBatch {
prepared_chunks,
payment_intent,
} => {
let paid_chunks = finalize_batch_payment(prepared_chunks, tx_hash_map)?;
let wave_result = self
.store_paid_chunks_with_events(
paid_chunks,
progress.as_ref(),
already_stored_count,
total_chunks,
)
.await;
if !wave_result.failed.is_empty() {
let failed_count = wave_result.failed.len();
let stored_count = already_stored_count + wave_result.stored.len();
let mut stored = already_stored_addresses;
stored.extend(wave_result.stored);
return Err(Error::PartialUpload {
stored,
stored_count,
failed: wave_result.failed,
failed_count,
total_chunks,
spend: Box::new(PartialUploadSpend {
storage_cost_atto: payment_intent.total_amount.to_string(),
gas_cost_wei: 0,
}),
reason: "finalize_upload: chunk storage failed after retries".into(),
});
}
let chunks_stored = already_stored_count + wave_result.stored.len();
info!("External-signer upload finalized: {chunks_stored} chunks stored");
let mut stats = WaveAggregateStats::default();
stats.absorb(&wave_result);
Ok(FileUploadResult {
data_map: prepared.data_map,
chunks_stored,
chunks_failed: 0,
total_chunks,
payment_mode_used: PaymentMode::Single,
storage_cost_atto: payment_intent.total_amount.to_string(),
gas_cost_wei: 0,
data_map_address,
chunk_attempts_total: stats.chunk_attempts_total,
store_durations_ms: stats.store_durations_ms,
retries_histogram: stats.retries_histogram,
})
}
ExternalPaymentInfo::Merkle { .. } => Err(Error::Payment(
"Cannot finalize merkle upload with wave-batch tx hashes. \
Use finalize_upload_merkle() instead."
.to_string(),
)),
}
}
fn merkle_external_batch_cap(&self) -> usize {
self.config()
.merkle_external_batch_cap
.map_or(MAX_LEAVES, |cap| cap.clamp(3, MAX_LEAVES))
}
pub async fn finalize_upload_merkle(
&self,
prepared: PreparedUpload,
winner_pool_hash: [u8; 32],
) -> Result<FileUploadResult> {
self.finalize_upload_merkle_with_progress(prepared, winner_pool_hash, None)
.await
}
pub async fn finalize_upload_merkle_with_progress(
&self,
prepared: PreparedUpload,
winner_pool_hash: [u8; 32],
progress: Option<mpsc::Sender<UploadEvent>>,
) -> Result<FileUploadResult> {
if let ExternalPaymentInfo::Merkle {
prepared_batches, ..
} = &prepared.payment_info
{
let batches = prepared_batches.len();
if batches != 1 {
return Err(Error::Payment(format!(
"This upload was prepared as {batches} merkle sub-batches; \
pay each and call finalize_upload_merkle_multi() with one \
winner hash per batch."
)));
}
}
self.finalize_upload_merkle_multi_with_progress(
prepared,
vec![Some(winner_pool_hash)],
progress,
)
.await
}
pub async fn finalize_upload_merkle_multi(
&self,
prepared: PreparedUpload,
winner_pool_hashes: Vec<Option<[u8; 32]>>,
) -> Result<FileUploadResult> {
self.finalize_upload_merkle_multi_with_progress(prepared, winner_pool_hashes, None)
.await
}
pub async fn finalize_upload_merkle_multi_with_progress(
&self,
prepared: PreparedUpload,
winner_pool_hashes: Vec<Option<[u8; 32]>>,
progress: Option<mpsc::Sender<UploadEvent>>,
) -> Result<FileUploadResult> {
let data_map_address = prepared.data_map_address;
let already_stored_addresses = prepared.already_stored_addresses;
let total_chunks = prepared.total_chunks;
match prepared.payment_info {
ExternalPaymentInfo::Merkle {
prepared_batches,
chunk_store,
chunk_addresses,
} => {
let batch_result =
fold_external_merkle_payments(prepared_batches, winner_pool_hashes)?;
let (chunks_stored, _storage_cost, _gas_cost, stats) = self
.upload_merkle_from_spill(
chunk_store.spill(),
&chunk_addresses,
&batch_result,
&already_stored_addresses,
progress.as_ref(),
None,
)
.await?;
info!("External-signer merkle upload finalized: {chunks_stored} chunks stored");
Ok(FileUploadResult {
data_map: prepared.data_map,
chunks_stored,
chunks_failed: 0,
total_chunks,
payment_mode_used: PaymentMode::Merkle,
storage_cost_atto: "0".into(),
gas_cost_wei: 0,
data_map_address,
chunk_attempts_total: stats.chunk_attempts_total,
store_durations_ms: stats.store_durations_ms,
retries_histogram: stats.retries_histogram,
})
}
ExternalPaymentInfo::WaveBatch { .. } => Err(Error::Payment(
"Cannot finalize wave-batch upload with merkle winner hashes. \
Use finalize_upload() instead."
.to_string(),
)),
}
}
pub async fn finalize_upload_merkle_multi_resumable(
&self,
prepared: PreparedUpload,
winner_pool_hashes: Vec<Option<[u8; 32]>>,
) -> Result<FinalizeOutcome> {
self.finalize_upload_merkle_multi_resumable_with_progress(
prepared,
winner_pool_hashes,
None,
)
.await
}
pub async fn finalize_upload_merkle_multi_resumable_with_progress(
&self,
prepared: PreparedUpload,
winner_pool_hashes: Vec<Option<[u8; 32]>>,
progress: Option<mpsc::Sender<UploadEvent>>,
) -> Result<FinalizeOutcome> {
let data_map_address = prepared.data_map_address;
let already_stored_addresses = prepared.already_stored_addresses;
let total_chunks = prepared.total_chunks;
let data_map = prepared.data_map;
match prepared.payment_info {
ExternalPaymentInfo::Merkle {
prepared_batches,
chunk_store,
chunk_addresses,
} => {
require_fully_paid_for_resumable(&winner_pool_hashes)?;
let batch_result =
fold_external_merkle_payments(prepared_batches, winner_pool_hashes)?;
self.drive_merkle_finalize(
data_map,
data_map_address,
total_chunks,
chunk_store,
chunk_addresses,
batch_result,
already_stored_addresses,
progress.as_ref(),
)
.await
}
ExternalPaymentInfo::WaveBatch { .. } => Err(Error::Payment(
"Cannot finalize wave-batch upload with merkle winner hashes. \
Use finalize_upload_resumable() instead."
.to_string(),
)),
}
}
pub async fn finalize_upload_resumable(
&self,
prepared: PreparedUpload,
tx_hash_map: &HashMap<QuoteHash, TxHash>,
) -> Result<FinalizeOutcome> {
self.finalize_upload_resumable_with_progress(prepared, tx_hash_map, None)
.await
}
pub async fn finalize_upload_resumable_with_progress(
&self,
prepared: PreparedUpload,
tx_hash_map: &HashMap<QuoteHash, TxHash>,
progress: Option<mpsc::Sender<UploadEvent>>,
) -> Result<FinalizeOutcome> {
let data_map_address = prepared.data_map_address;
let already_stored_count = prepared.already_stored_addresses.len();
let total_chunks = prepared.total_chunks;
let data_map = prepared.data_map;
match prepared.payment_info {
ExternalPaymentInfo::WaveBatch {
prepared_chunks,
payment_intent,
} => {
let paid_chunks = finalize_batch_payment(prepared_chunks, tx_hash_map)?;
let storage_cost_atto = payment_intent.total_amount.to_string();
Ok(self
.drive_wave_finalize(
data_map,
data_map_address,
total_chunks,
already_stored_count,
paid_chunks,
storage_cost_atto,
progress.as_ref(),
)
.await)
}
ExternalPaymentInfo::Merkle { .. } => Err(Error::Payment(
"Cannot finalize merkle upload with wave-batch tx hashes. \
Use finalize_upload_merkle_multi_resumable() instead."
.to_string(),
)),
}
}
pub async fn finalize_resume(&self, resume: FinalizeResume) -> Result<FinalizeOutcome> {
self.finalize_resume_with_progress(resume, None).await
}
pub async fn finalize_resume_with_progress(
&self,
resume: FinalizeResume,
progress: Option<mpsc::Sender<UploadEvent>>,
) -> Result<FinalizeOutcome> {
match resume {
FinalizeResume::Wave(w) => {
let WaveFinalizeResume {
data_map,
data_map_address,
total_chunks,
stored_count,
failed_paid_chunks,
storage_cost_atto,
} = *w;
Ok(self
.drive_wave_finalize(
data_map,
data_map_address,
total_chunks,
stored_count,
failed_paid_chunks,
storage_cost_atto,
progress.as_ref(),
)
.await)
}
FinalizeResume::Merkle(m) => {
let MerkleFinalizeResume {
data_map,
data_map_address,
total_chunks,
chunk_store,
unstored_addresses,
batch_result,
stored_addresses,
} = *m;
self.drive_merkle_finalize(
data_map,
data_map_address,
total_chunks,
chunk_store,
unstored_addresses,
batch_result,
stored_addresses,
progress.as_ref(),
)
.await
}
}
}
#[allow(clippy::too_many_arguments)]
async fn drive_merkle_finalize(
&self,
data_map: DataMap,
data_map_address: Option<[u8; 32]>,
total_chunks: usize,
chunk_store: ExternalChunkStore,
to_store: Vec<[u8; 32]>,
batch_result: MerkleBatchPaymentResult,
stored_addresses: Vec<[u8; 32]>,
progress: Option<&mpsc::Sender<UploadEvent>>,
) -> Result<FinalizeOutcome> {
let store_result = self
.upload_merkle_from_spill(
chunk_store.spill(),
&to_store,
&batch_result,
&stored_addresses,
progress,
None,
)
.await;
assemble_merkle_finalize_outcome(
store_result,
data_map,
data_map_address,
total_chunks,
chunk_store,
batch_result,
)
}
#[allow(clippy::too_many_arguments)]
async fn drive_wave_finalize(
&self,
data_map: DataMap,
data_map_address: Option<[u8; 32]>,
total_chunks: usize,
already_stored_count: usize,
paid_chunks: Vec<PaidChunk>,
storage_cost_atto: String,
progress: Option<&mpsc::Sender<UploadEvent>>,
) -> FinalizeOutcome {
let retained: HashMap<[u8; 32], PaidChunk> =
paid_chunks.iter().map(|c| (c.address, c.clone())).collect();
let wave_result = self
.store_paid_chunks_with_events(
paid_chunks,
progress,
already_stored_count,
total_chunks,
)
.await;
assemble_wave_finalize_outcome(
wave_result,
retained,
data_map,
data_map_address,
total_chunks,
already_stored_count,
storage_cost_atto,
)
}
#[allow(clippy::too_many_lines)]
pub async fn file_upload_with_mode(
&self,
path: &Path,
mode: PaymentMode,
) -> Result<FileUploadResult> {
self.file_upload_with_progress(path, mode, None).await
}
#[allow(clippy::too_many_lines)]
pub async fn file_upload_public_with_mode(
&self,
path: &Path,
mode: PaymentMode,
) -> Result<FileUploadResult> {
self.file_upload_with_visibility_and_progress(path, mode, Visibility::Public, None)
.await
}
#[allow(clippy::too_many_lines)]
pub async fn file_upload_with_progress(
&self,
path: &Path,
mode: PaymentMode,
progress: Option<mpsc::Sender<UploadEvent>>,
) -> Result<FileUploadResult> {
self.file_upload_with_visibility_and_progress(path, mode, Visibility::Private, progress)
.await
}
#[allow(clippy::too_many_lines)]
pub async fn file_upload_public_with_progress(
&self,
path: &Path,
mode: PaymentMode,
progress: Option<mpsc::Sender<UploadEvent>>,
) -> Result<FileUploadResult> {
self.file_upload_with_visibility_and_progress(path, mode, Visibility::Public, progress)
.await
}
#[allow(clippy::too_many_lines)]
async fn file_upload_with_visibility_and_progress(
&self,
path: &Path,
mode: PaymentMode,
visibility: Visibility,
progress: Option<mpsc::Sender<UploadEvent>>,
) -> Result<FileUploadResult> {
debug!(
"Streaming file upload with mode {mode:?}, visibility {visibility:?}: {}",
path.display()
);
let file_size = std::fs::metadata(path)?.len();
check_disk_space_for_spill(file_size)?;
let (mut spill, data_map) = self.encrypt_file_to_spill(path, progress.as_ref()).await?;
let data_map_address = match visibility {
Visibility::Private => None,
Visibility::Public => {
let (address, serialized) =
crate::client_engine::files::public_map_record(&data_map)
.map_err(Error::Serialization)?;
info!(
"Public upload: adding DataMap chunk ({} bytes) at address {} to payment batch",
serialized.len(),
hex::encode(address)
);
spill.push(&serialized)?;
Some(address)
}
};
let chunk_count = spill.len();
info!(
"Encrypted {} into {chunk_count} chunks (spilled to disk)",
path.display()
);
if let Some(ref tx) = progress {
let _ = tx
.send(UploadEvent::Encrypted {
total_chunks: chunk_count,
})
.await;
}
let file_path_key = std::fs::canonicalize(path)
.map(|p| p.display().to_string())
.unwrap_or_else(|_| path.display().to_string());
let records = spill
.chunk_entries()?
.into_iter()
.enumerate()
.map(
|(index, (address, size))| super::super::upload::UploadRecord {
address,
size,
index,
},
)
.collect::<Vec<_>>();
let wallet = self.require_wallet()?;
let scope = rmp_serde::to_vec(&(
wallet.network(),
records
.iter()
.map(|r| (r.address, r.size))
.collect::<Vec<_>>(),
))
.map_err(|e| Error::Serialization(e.to_string()))?;
let cache_dir = crate::config::data_dir()
.map_err(|e| Error::Config(e.to_string()))?
.join("payments/upload");
std::fs::create_dir_all(&cache_dir)?;
let cache_path = cache_dir.join(format!(
"{}.msgpack",
hex::encode(blake3::hash(&scope).as_bytes())
));
let lock_path = cache_path.with_extension("lock");
let _lock = tokio::task::spawn_blocking(move || -> std::io::Result<std::fs::File> {
let file = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(lock_path)?;
fs2::FileExt::lock_exclusive(&file)?;
Ok(file)
})
.await
.map_err(|e| Error::Io(std::io::Error::other(e.to_string())))??;
let mut state = match std::fs::read(&cache_path) {
Ok(bytes) => super::super::upload_state::UploadState::restore(&bytes)?,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
let mut proofs =
crate::data::client::cached_single::try_load_for_file(&file_path_key)
.map(|(_, receipt)| receipt.proofs)
.unwrap_or_default();
if let Some((_, receipt)) =
crate::data::client::cached_merkle::try_load_for_file(&file_path_key)
{
proofs.extend(receipt.proofs);
}
super::super::upload_state::UploadState::from_proofs(proofs)
}
Err(error) => return Err(Error::Io(error)),
};
let adapter = SpillUploadAdapter {
client: self,
spill: &spill,
progress: progress.as_ref(),
checkpoint: &cache_path,
};
let result = self
.upload_records(records, &mut state, &adapter, mode)
.await?;
std::fs::remove_file(&cache_path).or_else(|error| {
if error.kind() == std::io::ErrorKind::NotFound {
Ok(())
} else {
Err(error)
}
})?;
crate::data::client::cached_single::try_delete_for_file(&file_path_key);
crate::data::client::cached_merkle::try_delete_for_file(&file_path_key);
Ok(FileUploadResult {
data_map,
chunks_stored: result.addresses.len(),
chunks_failed: 0,
total_chunks: chunk_count,
payment_mode_used: result.mode,
storage_cost_atto: result.amount.to_string(),
gas_cost_wei: result.gas,
data_map_address,
chunk_attempts_total: result.stats.chunk_attempts_total,
store_durations_ms: result.stats.store_durations_ms,
retries_histogram: result.stats.retries_histogram,
})
}
async fn encrypt_file_to_spill(
&self,
path: &Path,
progress: Option<&mpsc::Sender<UploadEvent>>,
) -> Result<(ChunkSpill, DataMap)> {
let (mut chunk_rx, datamap_rx, handle) = spawn_file_encryption(path.to_path_buf())?;
let mut spill = ChunkSpill::new()?;
while let Some(content) = chunk_rx.recv().await {
spill.push(&content)?;
let chunks_done = spill.len();
if let Some(tx) = progress {
if chunks_done.is_multiple_of(10) {
let _ = tx.send(UploadEvent::Encrypting { chunks_done }).await;
}
}
if chunks_done % 100 == 0 {
let mb = spill.total_bytes() / (1024 * 1024);
info!(
"Encryption progress: {chunks_done} chunks spilled ({mb} MB) — {}",
path.display()
);
}
}
handle
.await
.map_err(|e| Error::Encryption(format!("encryption task panicked: {e}")))?
.map_err(|e| Error::Encryption(format!("encryption failed: {e}")))?;
let data_map = datamap_rx
.await
.map_err(|_| Error::Encryption("no DataMap from encryption thread".to_string()))?;
Ok((spill, data_map))
}
pub async fn file_download(&self, data_map: &DataMap, output: &Path) -> Result<u64> {
self.file_download_with_progress(data_map, output, None)
.await
}
pub async fn file_download_from_closest_peers(
&self,
data_map: &DataMap,
output: &Path,
peer_count: NonZeroUsize,
) -> Result<u64> {
self.file_download_with_progress_from_closest_peers(data_map, output, None, peer_count)
.await
}
pub async fn file_download_with_progress_from_closest_peers(
&self,
data_map: &DataMap,
output: &Path,
progress: Option<mpsc::Sender<DownloadEvent>>,
peer_count: NonZeroUsize,
) -> Result<u64> {
self.file_download_with_progress_using_peer_count(
data_map,
output,
progress,
peer_count.get(),
None,
)
.await
}
pub async fn file_download_with_progress_and_diagnostics_from_closest_peers(
&self,
data_map: &DataMap,
output: &Path,
progress: Option<mpsc::Sender<DownloadEvent>>,
peer_count: NonZeroUsize,
diagnostics: Option<DownloadDiagnosticsSender>,
) -> Result<u64> {
self.file_download_with_progress_using_peer_count(
data_map,
output,
progress,
peer_count.get(),
diagnostics,
)
.await
}
pub async fn file_download_with_peer_report_from_closest_peers(
&self,
data_map: &DataMap,
output: &Path,
progress: Option<mpsc::Sender<DownloadEvent>>,
peer_count: NonZeroUsize,
) -> Result<FileDownloadWithPeerReport> {
let chunk_reports = Arc::new(Mutex::new(Vec::new()));
let bytes_written = self
.file_download_with_progress_using_peer_count_and_reports(
data_map,
output,
progress,
peer_count.get(),
Some(chunk_reports.clone()),
None,
)
.await?;
let chunk_reports = chunk_reports
.lock()
.map_err(|_| Error::Storage("file chunk peer report lock poisoned".to_string()))?
.clone();
let chunk_reports = file_chunk_reports_from_recorded_sweeps(chunk_reports);
Ok(FileDownloadWithPeerReport {
bytes_written,
chunk_reports,
})
}
async fn download_fetch_file_chunk(
&self,
idx: usize,
hash: XorName,
context: FileDownloadFetchContext,
is_deferred_retry: bool,
attempt: usize,
) -> std::result::Result<DownloadBatchEntry, self_encryption::Error> {
let addr = hash.0;
let addr_hex = hex::encode(addr);
let chunk_content = if let Some(peer_reports) = context.peer_reports {
match self
.chunk_get_from_closest_peer_group(&addr, context.peer_count)
.await
{
Ok(results) => {
let (content, sweep) = file_chunk_sweep_report_from_peer_results(
attempt,
is_deferred_retry,
&results,
);
peer_reports
.lock()
.map_err(|_| {
self_encryption::Error::Generic(
"file chunk peer report lock poisoned".to_string(),
)
})?
.push(RecordedFileChunkPeerSweep {
index: idx + 1,
address: addr,
sweep,
});
content
}
Err(e) => {
if is_deferred_retry {
info!(
"Deferred all-peer retry for {addr_hex} hit transient error: {e}; re-deferring"
);
} else {
info!("First-pass all-peer fetch error for {addr_hex}: {e}; deferring");
}
peer_reports
.lock()
.map_err(|_| {
self_encryption::Error::Generic(
"file chunk peer report lock poisoned".to_string(),
)
})?
.push(RecordedFileChunkPeerSweep {
index: idx + 1,
address: addr,
sweep: file_chunk_sweep_report_from_error(
attempt,
is_deferred_retry,
&e,
),
});
None
}
}
} else {
let diag = context.diagnostics.as_ref().map(|sender| {
ChunkFetchDiagnostics::new(
sender,
attempt,
idx + 1,
addr,
self.controller().fetch.current(),
)
});
match self
.chunk_get_observed_from_closest_peers(&addr, context.peer_count, diag.as_ref())
.await
{
Ok(Some(chunk)) => Some(chunk.content),
Ok(None) => None,
Err(e) => {
if is_deferred_retry {
info!(
"Deferred retry for {addr_hex} hit transient error: {e}; re-deferring"
);
} else {
info!("First-pass fetch error for {addr_hex}: {e}; deferring");
}
None
}
}
};
let Some(content) = chunk_content else {
return Ok((idx, Err(hash)));
};
let fetched = context
.fetched_ref
.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
+ 1;
if is_deferred_retry {
info!(
"Downloaded {fetched}/{} (deferred retry)",
context.total_chunks
);
} else {
let total_chunks = context.total_chunks;
info!("Downloaded {fetched}/{total_chunks}");
}
if let Some(ref tx) = context.progress_ref {
let _ = tx.try_send(DownloadEvent::ChunksFetched {
fetched,
total: context.total_chunks,
});
}
Ok((idx, Ok(content)))
}
async fn download_decrypted_chunks<F, Fut>(
&self,
data_map: &DataMap,
progress: Option<mpsc::Sender<DownloadEvent>>,
peer_count: usize,
peer_reports: Option<Arc<Mutex<Vec<RecordedFileChunkPeerSweep>>>>,
diagnostics: Option<DownloadDiagnosticsSender>,
mut on_chunk: F,
) -> Result<u64>
where
F: FnMut(Bytes) -> Fut,
Fut: std::future::Future<Output = Result<()>>,
{
let handle = Handle::current();
let root_map = if data_map.is_child() {
let dm_chunks = data_map.len();
if let Some(ref tx) = progress {
let _ = tx.try_send(DownloadEvent::ResolvingDataMap {
total_map_chunks: dm_chunks,
});
}
let resolve_counter = std::sync::atomic::AtomicUsize::new(0);
let resolved = crate::client_engine::files::resolve(
data_map,
&|address| {
let resolve_counter = &resolve_counter;
let progress = &progress;
let diagnostics = &diagnostics;
async move {
let diag = diagnostics.as_ref().map(|sender| {
ChunkFetchDiagnostics::new(
sender,
FIRST_DIAGNOSTIC_FETCH_ATTEMPT,
0,
address,
self.controller().fetch.current(),
)
});
let chunk = self
.chunk_get_observed_from_closest_peers(
&address,
peer_count,
diag.as_ref(),
)
.await?
.ok_or_else(|| {
Error::NotFound(format!(
"DataMap chunk not found: {}",
hex::encode(address)
))
})?;
let fetched =
resolve_counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
if let Some(ref tx) = progress {
let _ = tx.try_send(DownloadEvent::MapChunkFetched { fetched });
}
Ok(chunk.content)
}
},
&|| self.controller().fetch.current(),
)
.await
.map_err(super::super::data::map_read_error)?;
info!(
"Resolved hierarchical DataMap: {} data chunks",
resolved.len()
);
resolved
} else {
data_map.clone()
};
let total_chunks = root_map.len();
if let Some(ref tx) = progress {
let _ = tx.try_send(DownloadEvent::DataMapResolved { total_chunks });
}
let fetched_counter = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let fetched_for_closure = fetched_counter.clone();
let progress_for_closure = progress.clone();
let peer_reports_for_closure = peer_reports.clone();
let diagnostics_for_closure = diagnostics.clone();
let fetch_limiter_outer = self.controller().fetch.clone();
let usable_memory = usable_memory_bytes();
let configured_batch_floor = stream_decrypt_batch_size();
let fetch_cap = fetch_limiter_outer.current();
let decrypt_batch_size = adaptive_stream_decrypt_batch_size(
total_chunks,
fetch_cap,
configured_batch_floor,
usable_memory,
);
info!(
total_chunks,
fetch_cap,
configured_batch_floor,
?usable_memory,
decrypt_batch_size,
"Selected adaptive stream decrypt batch size"
);
let stream = streaming_decrypt_with_batch_size(
&root_map,
|batch: &[(usize, XorName)]| {
let batch_owned: Vec<(usize, XorName)> = batch.to_vec();
let fetch_context = FileDownloadFetchContext {
total_chunks,
peer_count,
fetched_ref: fetched_for_closure.clone(),
progress_ref: progress_for_closure.clone(),
peer_reports: peer_reports_for_closure.clone(),
diagnostics: diagnostics_for_closure.clone(),
};
let fetch_limiter = fetch_limiter_outer.clone();
tokio::task::block_in_place(|| {
handle.block_on(async {
crate::client_engine::files::deferred_batch(
batch_owned,
|idx, hash, attempt| {
self.download_fetch_file_chunk(
idx,
hash,
fetch_context.clone(),
attempt > 1,
attempt,
)
},
|| fetch_limiter.current(),
tokio::time::sleep,
|hash: XorName| {
self_encryption::Error::Generic(format!(
"Chunk not found after 3 deferred retry rounds: {}",
hex::encode(hash.0),
))
},
)
.await
})
})
},
decrypt_batch_size,
)
.map_err(|e| Error::Encryption(format!("streaming decrypt failed: {e}")))?;
let mut bytes_total = 0u64;
for chunk_result in stream {
let chunk: Bytes =
chunk_result.map_err(|e| Error::Encryption(format!("decryption failed: {e}")))?;
bytes_total += chunk.len() as u64;
on_chunk(chunk).await?;
}
Ok(bytes_total)
}
pub async fn file_download_with_progress(
&self,
data_map: &DataMap,
output: &Path,
progress: Option<mpsc::Sender<DownloadEvent>>,
) -> Result<u64> {
self.file_download_with_progress_using_peer_count(
data_map,
output,
progress,
self.config().close_group_size,
None,
)
.await
}
async fn file_download_with_progress_using_peer_count(
&self,
data_map: &DataMap,
output: &Path,
progress: Option<mpsc::Sender<DownloadEvent>>,
peer_count: usize,
diagnostics: Option<DownloadDiagnosticsSender>,
) -> Result<u64> {
self.file_download_with_progress_using_peer_count_and_reports(
data_map,
output,
progress,
peer_count,
None,
diagnostics,
)
.await
}
async fn file_download_with_progress_using_peer_count_and_reports(
&self,
data_map: &DataMap,
output: &Path,
progress: Option<mpsc::Sender<DownloadEvent>>,
peer_count: usize,
peer_reports: Option<Arc<Mutex<Vec<RecordedFileChunkPeerSweep>>>>,
diagnostics: Option<DownloadDiagnosticsSender>,
) -> Result<u64> {
debug!("Downloading file to {}", output.display());
let parent = output.parent().unwrap_or_else(|| Path::new("."));
let unique: u64 = rand::random();
let tmp_path = parent.join(format!(".ant_download_{}_{unique}.tmp", std::process::id()));
let tmp = TempDownload::new(tmp_path);
let mut file = std::fs::File::create(tmp.path())?;
let bytes_written = self
.download_decrypted_chunks(
data_map,
progress,
peer_count,
peer_reports,
diagnostics,
|bytes| {
let r = file.write_all(&bytes).map_err(Error::from);
std::future::ready(r)
},
)
.await?;
file.flush()?;
drop(file);
tmp.commit(output)?;
info!(
"File downloaded: {bytes_written} bytes written to {}",
output.display()
);
Ok(bytes_written)
}
pub async fn file_download_to_sender(
&self,
data_map: &DataMap,
sink: mpsc::Sender<std::result::Result<Bytes, Error>>,
progress: Option<mpsc::Sender<DownloadEvent>>,
) -> Result<u64> {
let peer_count = self.config().close_group_size;
self.download_decrypted_chunks(data_map, progress, peer_count, None, None, |bytes| {
let sink = sink.clone();
async move {
sink.send(Ok(bytes))
.await
.map_err(|_| Error::Cancelled("download stream receiver dropped".into()))
}
})
.await
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
fn dummy_batch_result() -> MerkleBatchPaymentResult {
MerkleBatchPaymentResult {
proofs: HashMap::new(),
chunk_count: 0,
storage_cost_atto: "0".into(),
gas_cost_wei: 0,
merkle_payment_timestamp: 0,
}
}
fn empty_chunk_store() -> ExternalChunkStore {
ExternalChunkStore::from_spill(ChunkSpill::new().unwrap())
}
fn paid_chunk(address: [u8; 32]) -> PaidChunk {
PaidChunk {
content: Bytes::from_static(b"x"),
address,
quoted_peers: Vec::new(),
proof_bytes: Vec::new(),
}
}
#[test]
fn assemble_complete_on_full_store() {
let outcome = assemble_merkle_finalize_outcome(
Ok((3, "0".into(), 0, WaveAggregateStats::default())),
DataMap::new(vec![]),
Some([9u8; 32]),
3,
empty_chunk_store(),
dummy_batch_result(),
)
.expect("a fully-stored pass is not an error");
match outcome {
FinalizeOutcome::Complete(result) => {
assert_eq!(result.chunks_stored, 3);
assert_eq!(result.chunks_failed, 0);
assert_eq!(result.total_chunks, 3);
assert_eq!(result.data_map_address, Some([9u8; 32]));
assert!(matches!(result.payment_mode_used, PaymentMode::Merkle));
}
FinalizeOutcome::Partial { .. } => panic!("expected Complete"),
}
}
#[test]
fn assemble_partial_retains_resume_for_unstored() {
let a = [1u8; 32];
let b = [2u8; 32];
let c = [3u8; 32];
let store_result = Err(Error::PartialUpload {
stored: vec![a],
stored_count: 1,
failed: vec![(b, "quorum".into()), (c, "quorum".into())],
failed_count: 2,
total_chunks: 3,
spend: Box::new(PartialUploadSpend {
storage_cost_atto: "777".into(),
gas_cost_wei: 0,
}),
reason: "merkle chunk store aborted".into(),
});
let outcome = assemble_merkle_finalize_outcome(
store_result,
DataMap::new(vec![]),
Some([9u8; 32]),
3,
empty_chunk_store(),
dummy_batch_result(),
)
.expect("a quorum shortfall is Ok(Partial), never Err");
match outcome {
FinalizeOutcome::Partial { result, resume } => {
assert_eq!(result.chunks_stored, 1);
assert_eq!(result.chunks_failed, 2);
assert_eq!(result.total_chunks, 3);
assert_eq!(result.storage_cost_atto, "777");
let FinalizeResume::Merkle(m) = resume else {
panic!("expected a merkle resume handle");
};
assert_eq!(m.unstored_addresses, vec![b, c]);
assert_eq!(m.stored_addresses, vec![a]);
assert_eq!(m.total_chunks, 3);
assert_eq!(m.data_map_address, Some([9u8; 32]));
}
FinalizeOutcome::Complete(_) => panic!("expected Partial"),
}
}
#[test]
fn resumable_guard_rejects_partial_payment() {
let err = require_fully_paid_for_resumable(&[Some([1u8; 32]), None, Some([2u8; 32])])
.expect_err("a mix of paid and unpaid sub-batches must be rejected");
match err {
Error::Payment(msg) => {
assert!(msg.contains("1/3"), "counts unpaid batches: {msg}");
assert!(
msg.contains("finalize_upload_merkle_multi()"),
"points at the non-resumable path: {msg}"
);
}
other => panic!("expected Error::Payment, got {other:?}"),
}
}
#[test]
fn resumable_guard_accepts_fully_paid() {
require_fully_paid_for_resumable(&[Some([1u8; 32]), Some([2u8; 32])])
.expect("fully-paid winner hashes pass the guard");
require_fully_paid_for_resumable(&[]).expect(
"an empty set has no unpaid batch — fold_external_merkle_payments \
rejects it as nothing-to-finalize",
);
}
#[test]
fn merkle_resume_handle_drains_to_complete() {
let a = [1u8; 32];
let b = [2u8; 32];
let c = [3u8; 32];
let first_pass = Err(Error::PartialUpload {
stored: vec![a],
stored_count: 1,
failed: vec![(b, "quorum".into()), (c, "quorum".into())],
failed_count: 2,
total_chunks: 3,
spend: Box::new(PartialUploadSpend {
storage_cost_atto: "777".into(),
gas_cost_wei: 0,
}),
reason: "quorum shortfall".into(),
});
let outcome = assemble_merkle_finalize_outcome(
first_pass,
DataMap::new(vec![]),
Some([9u8; 32]),
3,
empty_chunk_store(),
dummy_batch_result(),
)
.expect("a quorum shortfall is Ok(Partial), never Err");
let FinalizeOutcome::Partial { resume, .. } = outcome else {
panic!("expected Partial after a shortfall pass");
};
let FinalizeResume::Merkle(m) = resume else {
panic!("expected a merkle resume handle");
};
assert_eq!(m.unstored_addresses, vec![b, c]);
let second_pass = Ok((3, "0".into(), 0, WaveAggregateStats::default()));
let outcome = assemble_merkle_finalize_outcome(
second_pass,
m.data_map,
m.data_map_address,
m.total_chunks,
m.chunk_store,
m.batch_result,
)
.expect("a fully-stored resume pass is not an error");
match outcome {
FinalizeOutcome::Complete(result) => {
assert_eq!(result.chunks_stored, 3);
assert_eq!(result.chunks_failed, 0);
assert_eq!(result.total_chunks, 3);
assert_eq!(result.data_map_address, Some([9u8; 32]));
}
FinalizeOutcome::Partial { .. } => panic!("expected Complete after the drain pass"),
}
}
#[test]
fn assemble_propagates_fatal_error() {
let outcome = assemble_merkle_finalize_outcome(
Err(Error::Payment("on-chain call reverted".into())),
DataMap::new(vec![]),
None,
3,
empty_chunk_store(),
dummy_batch_result(),
);
assert!(matches!(outcome, Err(Error::Payment(_))));
}
#[test]
fn assemble_wave_complete_when_all_stored() {
let a = [1u8; 32];
let wave_result = WaveResult {
stored: vec![a],
failed: Vec::new(),
chunk_attempts_total: 1,
store_durations_ms: vec![5],
retries_per_chunk: vec![0],
};
let mut retained = HashMap::new();
retained.insert(a, paid_chunk(a));
let outcome = assemble_wave_finalize_outcome(
wave_result,
retained,
DataMap::new(vec![]),
Some([9u8; 32]),
1,
0,
"500".into(),
);
match outcome {
FinalizeOutcome::Complete(result) => {
assert_eq!(result.chunks_stored, 1);
assert_eq!(result.chunks_failed, 0);
assert_eq!(result.storage_cost_atto, "500");
assert!(matches!(result.payment_mode_used, PaymentMode::Single));
}
FinalizeOutcome::Partial { .. } => panic!("expected Complete"),
}
}
#[test]
fn assemble_wave_partial_retains_failed_paid_chunks() {
let a = [1u8; 32]; let b = [2u8; 32]; let c = [3u8; 32]; let wave_result = WaveResult {
stored: vec![a],
failed: vec![(b, "quorum".into()), (c, "quorum".into())],
chunk_attempts_total: 3,
store_durations_ms: vec![5],
retries_per_chunk: vec![0],
};
let mut retained = HashMap::new();
for addr in [a, b, c] {
retained.insert(addr, paid_chunk(addr));
}
let outcome = assemble_wave_finalize_outcome(
wave_result,
retained,
DataMap::new(vec![]),
Some([9u8; 32]),
3,
0,
"500".into(),
);
match outcome {
FinalizeOutcome::Partial { result, resume } => {
assert_eq!(result.chunks_stored, 1);
assert_eq!(result.chunks_failed, 2);
assert_eq!(result.storage_cost_atto, "500");
let FinalizeResume::Wave(w) = resume else {
panic!("expected a wave resume handle");
};
let mut got: Vec<[u8; 32]> =
w.failed_paid_chunks.iter().map(|pc| pc.address).collect();
got.sort();
assert_eq!(got, vec![b, c]);
assert_eq!(w.stored_count, 1);
assert_eq!(w.total_chunks, 3);
}
FinalizeOutcome::Complete(_) => panic!("expected Partial"),
}
}
#[test]
fn merkle_store_cap_clamps_to_memory_bound() {
assert_eq!(merkle_store_cap(8), 8);
assert_eq!(merkle_store_cap(64), 64);
assert_eq!(merkle_store_cap(512), MERKLE_STORE_MAX_IN_FLIGHT);
assert_eq!(merkle_store_cap(usize::MAX), MERKLE_STORE_MAX_IN_FLIGHT);
assert_eq!(merkle_store_cap(0), 1);
}
#[test]
fn distributed_sample_indices_spreads_across_large_file() {
assert_eq!(distributed_sample_indices(100, 5), vec![0, 24, 49, 74, 99]);
}
#[test]
fn distributed_sample_indices_covers_whole_small_file() {
assert_eq!(distributed_sample_indices(3, 5), vec![0, 1, 2]);
assert_eq!(distributed_sample_indices(5, 5), vec![0, 1, 2, 3, 4]);
}
#[test]
fn estimator_leaf_total_is_the_padded_payment_partition() {
for chunks in [2u64, 64, 65, 100, 129, 255, 256, 257, 300, 512, 513, 769] {
let from_partition: u64 = merkle_batch_sizes(chunks as usize)
.into_iter()
.map(|size| size.next_power_of_two() as u64)
.sum();
assert_eq!(
merkle_billable_leaves(chunks),
from_partition,
"{chunks} chunks must be billed for the partition the payment path pays"
);
}
}
#[test]
fn distributed_sample_indices_is_in_range_and_increasing() {
assert!(distributed_sample_indices(0, 5).is_empty());
assert_eq!(distributed_sample_indices(1, 5), vec![0]);
for total in 1..200usize {
let idx = distributed_sample_indices(total, 5);
assert_eq!(*idx.first().unwrap(), 0);
assert_eq!(*idx.last().unwrap(), total - 1);
assert!(idx.iter().all(|&i| i < total));
assert!(idx.windows(2).all(|w| w[0] < w[1]));
}
}
#[test]
fn disk_space_check_passes_for_small_file() {
check_disk_space_for_spill(1024).unwrap();
}
#[test]
fn disk_space_check_fails_for_absurd_size() {
let result = check_disk_space_for_spill(u64::MAX / 2);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
matches!(err, Error::InsufficientDiskSpace(_)),
"expected InsufficientDiskSpace, got: {err}"
);
}
mod external_merkle_fold {
use super::*;
use crate::data::client::merkle::test_support::{
make_prepared_merkle_batch, winner_hash_for,
};
#[test]
fn hash_count_mismatch_is_rejected() {
let batches = vec![make_prepared_merkle_batch(2), make_prepared_merkle_batch(3)];
let err = fold_external_merkle_payments(batches, vec![None]).unwrap_err();
assert!(
err.to_string().contains("winner pool hash entries"),
"unexpected error: {err}"
);
}
#[test]
fn all_unpaid_is_rejected() {
let batches = vec![make_prepared_merkle_batch(2)];
let err = fold_external_merkle_payments(batches, vec![None]).unwrap_err();
assert!(
err.to_string().contains("No merkle sub-batch was paid"),
"unexpected error: {err}"
);
}
#[test]
fn paid_batches_fold_and_unpaid_contribute_no_proofs() {
let paid = make_prepared_merkle_batch(2);
let unpaid = make_prepared_merkle_batch(3);
let winner = winner_hash_for(&paid);
let merged =
fold_external_merkle_payments(vec![paid, unpaid], vec![Some(winner), None])
.unwrap();
assert_eq!(merged.proofs.len(), 2, "proofs cover only the paid batch");
assert_eq!(merged.chunk_count, 2);
}
}
#[test]
fn adaptive_stream_decrypt_batch_size_tracks_fetch_headroom() {
let batch_size = adaptive_stream_decrypt_batch_size(1_000, 64, 10, Some(u64::MAX));
assert_eq!(batch_size, 64 * DOWNLOAD_STREAM_BATCH_FETCH_MULTIPLIER);
}
#[test]
fn adaptive_stream_decrypt_batch_size_caps_to_total_chunks() {
let batch_size = adaptive_stream_decrypt_batch_size(12, 64, 10, Some(u64::MAX));
assert_eq!(batch_size, 12);
}
#[test]
fn adaptive_stream_decrypt_batch_size_honours_configured_floor() {
let batch_size = adaptive_stream_decrypt_batch_size(1_000, 1, 32, None);
assert_eq!(batch_size, 32);
}
#[test]
fn adaptive_stream_decrypt_batch_size_does_not_expand_without_memory_reading() {
let batch_size = adaptive_stream_decrypt_batch_size(1_000, 64, 10, None);
assert_eq!(batch_size, 10);
}
#[test]
fn adaptive_stream_decrypt_batch_size_caps_to_memory_budget() {
let estimated_bytes_per_chunk = (self_encryption::MAX_CHUNK_SIZE as u64)
.saturating_mul(DOWNLOAD_STREAM_BATCH_BYTES_PER_CHUNK_MULTIPLIER)
.max(1);
let usable_memory = estimated_bytes_per_chunk
.saturating_mul(16)
.saturating_mul(DOWNLOAD_STREAM_BATCH_MEMORY_BUDGET_DIVISOR);
let batch_size = adaptive_stream_decrypt_batch_size(1_000, 256, 10, Some(usable_memory));
assert_eq!(batch_size, 16);
}
#[test]
fn adaptive_stream_decrypt_batch_size_keeps_one_chunk_when_memory_is_tight() {
let batch_size = adaptive_stream_decrypt_batch_size(1_000, 64, 10, Some(1));
assert_eq!(batch_size, 1);
}
#[test]
fn cached_merkle_covers_only_when_all_addresses_have_proofs() {
let covered = compute_address(&Bytes::from_static(b"covered"));
let extra = compute_address(&Bytes::from_static(b"extra"));
let missing = compute_address(&Bytes::from_static(b"missing"));
let cached = MerkleBatchPaymentResult {
proofs: HashMap::from([(covered, vec![1]), (extra, vec![2])]),
chunk_count: 2,
storage_cost_atto: "0".to_string(),
gas_cost_wei: 0,
merkle_payment_timestamp: 0,
};
assert!(cached_merkle_covers_addresses(&cached, &[covered]));
assert!(cached_merkle_covers_addresses(&cached, &[covered, extra]));
assert!(!cached_merkle_covers_addresses(
&cached,
&[covered, missing]
));
}
#[test]
fn partition_addresses_by_proof_splits_paid_and_unpaid() {
let paid_a = [1u8; 32];
let unpaid_b = [2u8; 32];
let paid_c = [3u8; 32];
let unpaid_d = [4u8; 32];
let proofs: HashMap<[u8; 32], Vec<u8>> =
HashMap::from([(paid_a, vec![0xaa]), (paid_c, vec![0xcc])]);
let (to_store, missing) =
partition_addresses_by_proof(&[paid_a, unpaid_b, paid_c, unpaid_d], &proofs);
assert_eq!(to_store, vec![paid_a, paid_c]);
assert_eq!(missing, vec![unpaid_b, unpaid_d]);
}
fn real_refusal() -> String {
ant_protocol::client_update_required_message(1, 2)
}
#[test]
fn the_partial_reason_carries_a_refusal_instead_of_a_bogus_shortfall() {
let refusal = real_refusal();
assert!(
refusal.contains("ant update"),
"the storer wording must carry the instruction: {refusal}"
);
let all_proofless = merkle_partial_reason(3, 3, 4, Some(&refusal));
assert!(all_proofless.contains("ant update"), "{all_proofless}");
assert!(
!all_proofless.contains("short of quorum"),
"{all_proofless}"
);
let mixed = merkle_partial_reason(3, 2, 4, Some(&refusal));
assert!(mixed.contains("ant update"), "{mixed}");
assert!(mixed.contains("2 chunk(s) have no merkle proof"), "{mixed}");
assert!(
mixed.contains("1 chunk(s) short of quorum after 4 attempts"),
"{mixed}"
);
let silent = merkle_partial_reason(2, 2, 4, None);
assert!(!silent.contains("short of quorum"), "{silent}");
assert!(
silent.contains("2 chunk(s) have no merkle proof"),
"{silent}"
);
assert!(!silent.contains("refused"), "{silent}");
}
#[test]
fn the_refusal_is_scoped_before_its_nothing_was_charged_clause_is_quoted() {
let refusal = real_refusal();
assert!(
refusal.contains("nothing was charged"),
"precondition: {refusal}"
);
let reason = merkle_partial_reason(2, 2, 4, Some(&refusal));
let scope = reason
.find("settled for earlier sub-batches")
.expect("the reason must scope the refusal");
let charged = reason
.find("nothing was charged")
.expect("the storer wording must still be quoted in full");
assert!(scope < charged, "scope must precede the claim: {reason}");
}
#[test]
fn the_partial_reason_is_unchanged_when_every_chunk_had_a_proof() {
assert_eq!(
merkle_partial_reason(2, 0, 4, None),
"2 chunk(s) short of quorum after 4 attempts"
);
assert_eq!(
merkle_partial_reason(2, 0, 4, Some(&real_refusal())),
"2 chunk(s) short of quorum after 4 attempts"
);
}
#[test]
fn the_reason_never_claims_a_proofless_chunk_went_unpaid() {
let ours = proofless_clause(2, None).expect("two proofless chunks produce a clause");
assert_eq!(ours, "2 chunk(s) have no merkle proof");
assert!(!ours.contains("paid"), "{ours}");
assert!(!ours.contains("charged"), "{ours}");
assert!(proofless_clause(0, None).is_none());
assert!(
proofless_clause(0, Some(&real_refusal())).is_none(),
"no proofless chunks means no clause, refusal or not"
);
}
#[test]
fn a_fatal_store_abort_still_reports_the_refusal() {
let abort = "merkle chunk store aborted: connection reset";
let refusal = real_refusal();
let both = merkle_fatal_reason(abort, 5, Some(&refusal));
assert!(both.starts_with(abort), "the abort leads: {both}");
assert!(both.contains("ant update"), "{both}");
assert!(both.contains("5 chunk(s) have no merkle proof"), "{both}");
assert_eq!(merkle_fatal_reason(abort, 0, Some(&refusal)), abort);
assert_eq!(merkle_fatal_reason(abort, 0, None), abort);
}
#[test]
fn fold_single_wave_keeps_ok_wave() {
let stored = vec![[1u8; 32], [2u8; 32]];
let stats = WaveAggregateStats {
chunk_attempts_total: 7,
..Default::default()
};
let outcome = fold_single_wave(Ok((stored.clone(), "100".to_string(), 9, stats))).unwrap();
assert_eq!(outcome.stored, stored);
assert!(outcome.failed.is_empty());
assert_eq!(outcome.storage_atto.to_string(), "100");
assert_eq!(outcome.gas_wei, 9);
assert_eq!(outcome.stats.chunk_attempts_total, 7);
}
#[test]
fn fold_single_wave_folds_partial_upload() {
let stored = vec![[3u8; 32]];
let failed = vec![([4u8; 32], "short of quorum".to_string())];
let err = Error::PartialUpload {
stored: stored.clone(),
stored_count: 1,
failed: failed.clone(),
failed_count: 1,
total_chunks: 2,
spend: Box::new(PartialUploadSpend {
storage_cost_atto: "250".to_string(),
gas_cost_wei: 11,
}),
reason: "wave store failed after retries".to_string(),
};
let outcome = fold_single_wave(Err(err)).unwrap();
assert_eq!(outcome.stored, stored);
assert_eq!(outcome.failed, failed);
assert_eq!(outcome.storage_atto.to_string(), "250");
assert_eq!(outcome.gas_wei, 11);
assert_eq!(outcome.stats.chunk_attempts_total, 0);
}
#[test]
fn fold_single_wave_propagates_fatal_error() {
let result = fold_single_wave(Err(Error::Payment("wallet unavailable".to_string())));
assert!(
matches!(result, Err(Error::Payment(_))),
"fatal payment error must propagate, got: {result:?}"
);
}
#[test]
fn settlement_refusal_after_paid_waves_carries_spend_and_upgrade_instruction() {
let refusal = "your client is too old to pay the current storage rate. Run `ant update`";
let stored = vec![[1u8; 32], [2u8; 32]];
let remaining = [[3u8; 32], [4u8; 32], [5u8; 32]];
let err = settlement_refusal_after_paid_waves(
refusal,
2,
3,
stored.clone(),
stored.len(),
&remaining,
5,
Amount::from(700u64),
13,
);
let Error::PartialUpload {
stored: got_stored,
stored_count,
failed,
failed_count,
total_chunks,
spend,
reason,
} = err
else {
panic!("expected PartialUpload, got: {err:?}");
};
assert_eq!(got_stored, stored);
assert_eq!(stored_count, 2);
assert_eq!(failed_count, 3);
assert_eq!(total_chunks, 5);
let failed_addrs: Vec<[u8; 32]> = failed.iter().map(|(a, _)| *a).collect();
assert_eq!(failed_addrs, remaining.to_vec());
assert!(failed.iter().all(|(_, why)| why.contains("not quoted")));
assert_eq!(spend.storage_cost_atto, "700");
assert_eq!(spend.gas_cost_wei, 13);
assert!(reason.contains("wave 2/3"), "reason: {reason}");
assert!(
reason.contains("2 chunk(s) in earlier wave(s) were already paid"),
"reason: {reason}"
);
assert!(
reason.contains("3 chunk(s) were neither quoted nor paid"),
"reason: {reason}"
);
assert!(reason.contains(refusal), "reason: {reason}");
}
#[test]
fn partition_addresses_by_proof_handles_all_or_nothing() {
let a = [5u8; 32];
let b = [6u8; 32];
let empty: HashMap<[u8; 32], Vec<u8>> = HashMap::new();
let (to_store, missing) = partition_addresses_by_proof(&[a, b], &empty);
assert!(to_store.is_empty());
assert_eq!(missing, vec![a, b]);
let full: HashMap<[u8; 32], Vec<u8>> = HashMap::from([(a, vec![1]), (b, vec![2])]);
let (to_store, missing) = partition_addresses_by_proof(&[a, b], &full);
assert_eq!(to_store, vec![a, b]);
assert!(missing.is_empty());
}
#[test]
fn chunk_spill_round_trip() {
let mut spill = ChunkSpill::new().unwrap();
let data1 = vec![0xAA; 1024];
let data2 = vec![0xBB; 2048];
spill.push(&data1).unwrap();
spill.push(&data2).unwrap();
assert_eq!(spill.len(), 2);
assert_eq!(spill.total_bytes(), 1024 + 2048);
let chunk_entries = spill.chunk_entries().unwrap();
let entry_total: u64 = chunk_entries.iter().map(|(_, size)| *size).sum();
assert_eq!(entry_total, 1024 + 2048);
let chunk1 = spill.read_chunk(spill.addresses.first().unwrap()).unwrap();
assert_eq!(&chunk1[..], &data1[..]);
let chunk2 = spill.read_chunk(spill.addresses.get(1).unwrap()).unwrap();
assert_eq!(&chunk2[..], &data2[..]);
let waves: Vec<_> = spill.addresses.chunks(1).collect();
assert_eq!(waves.len(), 2);
}
#[test]
fn chunk_spill_cleanup_on_drop() {
let dir;
{
let spill = ChunkSpill::new().unwrap();
dir = spill.dir.clone();
assert!(dir.exists());
}
assert!(!dir.exists(), "spill dir should be removed on drop");
}
#[test]
fn chunk_spill_deduplicates_identical_content() {
let mut spill = ChunkSpill::new().unwrap();
let data = vec![0xCC; 512];
spill.push(&data).unwrap();
spill.push(&data).unwrap(); spill.push(&data).unwrap();
assert_eq!(spill.len(), 1, "duplicate chunks should be deduplicated");
assert_eq!(
spill.total_bytes(),
512,
"total_bytes should count unique only"
);
let data2 = vec![0xDD; 256];
spill.push(&data2).unwrap();
assert_eq!(spill.len(), 2);
assert_eq!(spill.total_bytes(), 512 + 256);
}
}
#[cfg(test)]
mod send_assertions {
use super::*;
fn _assert_send<T: Send>(_: &T) {}
#[allow(dead_code, unreachable_code, clippy::diverging_sub_expression)]
async fn _file_upload_is_send(client: &Client) {
let fut = client.file_upload(Path::new("/dev/null"));
_assert_send(&fut);
}
#[allow(dead_code, unreachable_code, clippy::diverging_sub_expression)]
async fn _file_upload_with_mode_is_send(client: &Client) {
let fut = client.file_upload_with_mode(Path::new("/dev/null"), PaymentMode::Auto);
_assert_send(&fut);
}
#[allow(
dead_code,
unreachable_code,
unused_variables,
clippy::diverging_sub_expression
)]
async fn _file_download_is_send(client: &Client) {
let dm: DataMap = todo!();
let fut = client.file_download(&dm, Path::new("/dev/null"));
_assert_send(&fut);
}
}
impl Client {
async fn upload_merkle_from_spill(
&self,
spill: &ChunkSpill,
addresses: &[[u8; 32]],
batch_result: &MerkleBatchPaymentResult,
already_stored_addresses: &[[u8; 32]],
progress: Option<&mpsc::Sender<UploadEvent>>,
payment_refusal: Option<&str>,
) -> Result<(usize, String, u128, WaveAggregateStats)> {
let mut total_stored = already_stored_addresses.len();
let total_chunks = total_stored + addresses.len();
let mut stored_addresses: Vec<[u8; 32]> = already_stored_addresses.to_vec();
let mut failed: Vec<([u8; 32], String)> = Vec::new();
let mut agg_stats = WaveAggregateStats::default();
let (to_store, missing_proof) =
partition_addresses_by_proof(addresses, &batch_result.proofs);
if !missing_proof.is_empty() {
match payment_refusal {
Some(reason) => warn!(
"{} chunk(s) lack a merkle proof ({reason}); reporting them as failed",
missing_proof.len()
),
None => warn!(
"{} chunk(s) lack a merkle proof (partial payment); reporting them as failed",
missing_proof.len()
),
}
for addr in &missing_proof {
let hex_addr = hex::encode(addr);
failed.push((
*addr,
match payment_refusal {
Some(reason) => format!("No merkle proof for chunk {hex_addr}: {reason}"),
None => format!("Missing merkle proof for chunk {hex_addr}"),
},
));
}
}
let store_limiter = self.controller().store.clone();
let store_one = |addr: [u8; 32]| {
let limiter = store_limiter.clone();
let proof_bytes = batch_result.proofs.get(&addr).cloned();
async move {
let started = std::time::Instant::now();
let proof = proof_bytes.ok_or_else(|| {
Error::Payment(format!(
"Missing merkle proof for chunk {}",
hex::encode(addr)
))
})?;
let content = spill.read_chunk(&addr)?;
let peers = self.put_target_peers(&addr).await?;
observe_op(
&limiter,
|| async move { self.chunk_put_to_close_group(content, proof, &peers).await },
classify_error,
)
.await
.map(|_| started)
}
};
info!(
"Storing {} chunks (merkle) as a single cap-bounded pass — {total_stored}/{total_chunks} stored so far",
to_store.len()
);
let cap = || merkle_store_cap(store_limiter.current());
let outcome = merkle_store_with_retry(
to_store.clone(),
cap,
1,
std::time::Duration::ZERO,
progress,
total_stored,
total_chunks,
&store_one,
)
.await?;
stored_addresses.extend(&outcome.stored_addresses);
total_stored = outcome.stored;
agg_stats.chunk_attempts_total = agg_stats
.chunk_attempts_total
.saturating_add(outcome.stats.chunk_attempts_total);
agg_stats
.store_durations_ms
.extend(outcome.stats.store_durations_ms);
for (slot, count) in agg_stats
.retries_histogram
.iter_mut()
.zip(outcome.stats.retries_histogram.iter())
{
*slot = slot.saturating_add(*count);
}
if let Some(e) = outcome.fatal {
warn!("merkle store aborted: {e}");
let mut known_failed = failed;
known_failed.extend(outcome.failed_addresses);
return Err(partial_upload_after_fatal(
addresses,
stored_addresses,
total_stored,
total_chunks,
known_failed,
PartialUploadSpend {
storage_cost_atto: batch_result.storage_cost_atto.clone(),
gas_cost_wei: batch_result.gas_cost_wei,
},
merkle_fatal_reason(
&format!("merkle chunk store aborted: {e}"),
missing_proof.len(),
payment_refusal,
),
));
}
let deferred: Vec<([u8; 32], String)> = outcome.failed_addresses;
if !deferred.is_empty() {
info!(
"Deferring {} merkle chunk(s) short of quorum for concurrent retry after the store pass",
deferred.len()
);
let dr = merkle_deferred_retry(
deferred,
&DEFERRED_ROUND_DELAYS_SECS,
|n: usize| merkle_store_cap(store_limiter.current()).min(n.max(1)),
progress,
total_stored,
total_chunks,
&store_one,
)
.await?;
stored_addresses.extend(dr.stored_addresses);
total_stored = dr.stored;
agg_stats.chunk_attempts_total = agg_stats
.chunk_attempts_total
.saturating_add(dr.stats.chunk_attempts_total);
agg_stats
.store_durations_ms
.extend(dr.stats.store_durations_ms);
for (slot, count) in agg_stats
.retries_histogram
.iter_mut()
.zip(dr.stats.retries_histogram.iter())
{
*slot = slot.saturating_add(*count);
}
if let Some(reason) = dr.fatal {
warn!("merkle deferred retry aborted: {reason}");
let mut known_failed = failed;
known_failed.extend(dr.failed_addresses);
return Err(partial_upload_after_fatal(
addresses,
stored_addresses,
total_stored,
total_chunks,
known_failed,
PartialUploadSpend {
storage_cost_atto: batch_result.storage_cost_atto.clone(),
gas_cost_wei: batch_result.gas_cost_wei,
},
merkle_fatal_reason(
&format!("merkle chunk store aborted: {reason}"),
missing_proof.len(),
payment_refusal,
),
));
}
failed.extend(dr.failed_addresses);
}
if !failed.is_empty() {
let failed_count = failed.len();
let total_attempts = 1 + DEFERRED_ROUND_DELAYS_SECS.len();
let reason = merkle_partial_reason(
failed_count,
missing_proof.len(),
total_attempts,
payment_refusal,
);
warn!(
"merkle upload incomplete: {failed_count}/{total_chunks} chunks failed — {reason}"
);
return Err(Error::PartialUpload {
stored: stored_addresses,
stored_count: total_stored,
failed,
failed_count,
total_chunks,
spend: Box::new(PartialUploadSpend {
storage_cost_atto: batch_result.storage_cost_atto.clone(),
gas_cost_wei: batch_result.gas_cost_wei,
}),
reason,
});
}
Ok((
total_stored,
batch_result.storage_cost_atto.clone(),
batch_result.gas_cost_wei,
agg_stats,
))
}
}