Skip to main content

ant_core/data/client/
batch.rs

1//! Batch chunk upload with wave-based pipelined EVM payments.
2//!
3//! Groups chunks into waves of 64 and pays for each
4//! wave in a single EVM transaction. Stores from wave N are pipelined
5//! with quote collection for wave N+1 via `tokio::join!`.
6
7use crate::data::client::adaptive::observe_op;
8use crate::data::client::classify_error;
9use crate::data::client::file::UploadEvent;
10use crate::data::client::payment::peer_id_to_encoded;
11use crate::data::client::Client;
12use crate::data::error::{Error, PartialUploadSpend, Result};
13use ant_protocol::evm::{
14    Amount, EncodedPeerId, PayForQuotesError, PaymentQuote, ProofOfPayment, QuoteHash,
15    RewardsAddress, TxHash,
16};
17use ant_protocol::payment::{
18    deserialize_proof, serialize_single_node_proof, PaymentProof, SingleNodePayment,
19};
20use ant_protocol::transport::{MultiAddr, PeerId};
21use ant_protocol::{compute_address, XorName, DATA_TYPE_CHUNK};
22use bytes::Bytes;
23use futures::stream::{self, FuturesUnordered, StreamExt};
24use std::collections::{HashMap, HashSet};
25use std::time::{Duration, Instant};
26use tokio::sync::mpsc;
27use tracing::{debug, info, warn};
28
29/// Number of chunks per payment wave.
30const PAYMENT_WAVE_SIZE: usize = 64;
31
32/// Soft ceiling on the combined body size of chunks stored concurrently in a
33/// single wave. Caps store concurrency for large chunks so the send path's
34/// per-peer body buffers can't pin multiple GB at once (see V2-461). At ~4 MB
35/// chunks this permits ~16 concurrent stores; small chunks hit the chunk-count
36/// / adaptive limits instead and are unaffected.
37const STORE_INFLIGHT_BYTE_BUDGET: usize = 64 * 1024 * 1024;
38
39/// Chunk quoted but not yet paid. Produced by [`Client::prepare_chunk_payment`].
40#[derive(Debug)]
41pub struct PreparedChunk {
42    /// The chunk content bytes.
43    pub content: Bytes,
44    /// Content address (BLAKE3 hash).
45    pub address: XorName,
46    /// Ordered PUT targets from quote planning.
47    ///
48    /// Kept under the legacy `quoted_peers` name for API compatibility; the
49    /// list can include non-quoted fallback peers beyond the quoted close
50    /// group.
51    pub quoted_peers: Vec<(PeerId, Vec<MultiAddr>)>,
52    /// Payment structure (quotes sorted, median selected, not yet paid on-chain).
53    pub payment: SingleNodePayment,
54    /// Peer quotes for building `ProofOfPayment`.
55    pub peer_quotes: Vec<(EncodedPeerId, PaymentQuote)>,
56    /// ADR-0004: the signed commitments the bound quotes shipped, forwarded as
57    /// sidecars in the PUT bundle so storers cross-check synchronously. Empty
58    /// when every quote was baseline (no commitment to pin).
59    pub commitment_sidecars: Vec<Vec<u8>>,
60}
61
62/// Chunk paid but not yet stored. Produced by [`Client::batch_pay`].
63#[derive(Debug, Clone)]
64pub struct PaidChunk {
65    /// The chunk content bytes.
66    pub content: Bytes,
67    /// Content address (BLAKE3 hash).
68    pub address: XorName,
69    /// Ordered PUT targets from quote planning.
70    ///
71    /// Kept under the legacy `quoted_peers` name for API compatibility; the
72    /// list can include non-quoted fallback peers beyond the quoted close
73    /// group.
74    pub quoted_peers: Vec<(PeerId, Vec<MultiAddr>)>,
75    /// Serialized [`PaymentProof`] bytes.
76    pub proof_bytes: Vec<u8>,
77}
78
79/// Result of storing a wave of paid chunks, with retry tracking.
80#[derive(Debug)]
81pub struct WaveResult {
82    /// Successfully stored chunk addresses.
83    pub stored: Vec<XorName>,
84    /// Chunks that failed to store after all retries.
85    pub failed: Vec<(XorName, String)>,
86    /// Sum of store-RPC attempts across all chunks in this wave (>= stored.len() + failed.len()).
87    pub chunk_attempts_total: usize,
88    /// Per-chunk wall-clock (ms) from first attempt to successful store. Only populated for stored chunks.
89    pub store_durations_ms: Vec<u64>,
90    /// Histogram of which retry-round each stored chunk succeeded on (index 0 = first attempt).
91    pub retries_per_chunk: Vec<u32>,
92}
93
94/// Aggregated retry / wall-clock stats across one or more [`WaveResult`]s.
95///
96/// Used by [`Client::batch_upload_chunks_with_events`] (which may store
97/// multiple waves per call) and surfaced upward into `FileUploadResult` so
98/// downstream tooling can record per-upload retry pressure and per-chunk
99/// store wall-clock without needing log parsing.
100#[derive(Debug, Default, Clone)]
101pub struct WaveAggregateStats {
102    /// Sum of store-RPC attempts across all waves (>= chunks_stored).
103    pub chunk_attempts_total: usize,
104    /// Per-chunk wall-clock (ms) from first attempt to successful store,
105    /// concatenated across waves.
106    pub store_durations_ms: Vec<u64>,
107    /// Count of stored chunks that succeeded on each retry round
108    /// (index 0 = first attempt, 1 = first retry, etc.). Indices match
109    /// the retry rounds emitted by `Client::store_paid_chunks_with_events`
110    /// which caps at `MAX_RETRIES = 3`, so an array of 4 suffices.
111    pub retries_histogram: [usize; 4],
112}
113
114impl WaveAggregateStats {
115    /// Fold one [`WaveResult`]'s stats into the running aggregate.
116    pub fn absorb(&mut self, wave: &WaveResult) {
117        self.chunk_attempts_total = self
118            .chunk_attempts_total
119            .saturating_add(wave.chunk_attempts_total);
120        self.store_durations_ms.extend(&wave.store_durations_ms);
121        for &r in &wave.retries_per_chunk {
122            let idx = (r as usize).min(self.retries_histogram.len() - 1);
123            self.retries_histogram[idx] = self.retries_histogram[idx].saturating_add(1);
124        }
125    }
126}
127
128/// Compute a percentile from an unsorted slice of `u64` values.
129///
130/// `p` is in `[0.0, 1.0]`. Returns 0 for an empty slice. Uses nearest-rank;
131/// callers don't need numerical precision here — these are coarse log/metric
132/// summaries.
133fn percentile(values: &[u64], p: f64) -> u64 {
134    if values.is_empty() {
135        return 0;
136    }
137    let mut sorted = values.to_vec();
138    sorted.sort_unstable();
139    let p = p.clamp(0.0, 1.0);
140    // Nearest-rank: ceil(p * n) - 1, clamped to [0, n-1].
141    let n = sorted.len();
142    #[allow(
143        clippy::cast_possible_truncation,
144        clippy::cast_sign_loss,
145        clippy::cast_precision_loss
146    )]
147    let rank = ((p * n as f64).ceil() as usize)
148        .saturating_sub(1)
149        .min(n - 1);
150    sorted[rank]
151}
152
153/// Payment data for external signing.
154///
155/// Contains the information needed to construct and submit the on-chain
156/// payment transaction without requiring a local wallet or private key.
157#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
158pub struct PaymentIntent {
159    /// Individual payment entries: (quote_hash, rewards_address, amount).
160    pub payments: Vec<(QuoteHash, RewardsAddress, Amount)>,
161    /// Total amount across all payments.
162    pub total_amount: Amount,
163}
164
165impl PaymentIntent {
166    /// Build from a set of prepared chunks.
167    ///
168    /// Collects all non-zero payment entries and computes the total.
169    pub fn from_prepared_chunks(prepared: &[PreparedChunk]) -> Self {
170        let mut payments = Vec::new();
171        let mut total = Amount::ZERO;
172        for chunk in prepared {
173            for info in &chunk.payment.quotes {
174                if !info.amount.is_zero() {
175                    payments.push((info.quote_hash, info.rewards_address, info.amount));
176                    total += info.amount;
177                }
178            }
179        }
180        Self {
181            payments,
182            total_amount: total,
183        }
184    }
185}
186
187/// Build [`PaidChunk`]s from prepared chunks and externally-provided transaction hashes.
188///
189/// Shared by [`Client::batch_pay`] (wallet flow) and [`finalize_batch_payment`] (external signer).
190///
191/// Returns an error if any non-zero-amount quote hash is missing from `tx_hash_map`,
192/// since chunks uploaded without valid proofs would be rejected by the network.
193fn build_paid_chunks(
194    prepared: Vec<PreparedChunk>,
195    tx_hash_map: &HashMap<QuoteHash, TxHash>,
196) -> Result<Vec<PaidChunk>> {
197    let mut paid_chunks = Vec::with_capacity(prepared.len());
198    for chunk in prepared {
199        let mut tx_hashes = Vec::new();
200        for info in &chunk.payment.quotes {
201            if !info.amount.is_zero() {
202                let tx_hash = tx_hash_map.get(&info.quote_hash).copied().ok_or_else(|| {
203                    Error::Payment(format!(
204                        "Missing tx hash for quote {} — external signer did not return a receipt for this payment",
205                        hex::encode(info.quote_hash)
206                    ))
207                })?;
208                tx_hashes.push(tx_hash);
209            }
210        }
211
212        let proof = PaymentProof {
213            proof_of_payment: ProofOfPayment {
214                peer_quotes: chunk.peer_quotes,
215            },
216            tx_hashes,
217            // ADR-0004: forward the bound quotes' commitments so storers
218            // cross-check synchronously; stripped before persistence node-side.
219            commitment_sidecars: chunk.commitment_sidecars,
220        };
221
222        let proof_bytes = serialize_single_node_proof(&proof)
223            .map_err(|e| Error::Serialization(format!("Failed to serialize payment proof: {e}")))?;
224
225        paid_chunks.push(PaidChunk {
226            content: chunk.content,
227            address: chunk.address,
228            quoted_peers: chunk.quoted_peers,
229            proof_bytes,
230        });
231    }
232    Ok(paid_chunks)
233}
234
235/// Finalize a batch payment using externally-provided transaction hashes.
236///
237/// Takes prepared chunks and a map of `quote_hash -> tx_hash` from the
238/// external signer. Builds per-chunk `PaymentProof` bytes without needing a wallet.
239pub fn finalize_batch_payment(
240    prepared: Vec<PreparedChunk>,
241    tx_hash_map: &HashMap<QuoteHash, TxHash>,
242) -> Result<Vec<PaidChunk>> {
243    build_paid_chunks(prepared, tx_hash_map)
244}
245
246impl Client {
247    /// Prepare a single chunk for batch payment.
248    ///
249    /// Collects quotes and uses node-reported prices without making any
250    /// on-chain transaction. Returns `Ok(None)` if the chunk is already
251    /// stored on the network.
252    ///
253    /// # Errors
254    ///
255    /// Returns an error if quote collection or payment construction fails.
256    pub async fn prepare_chunk_payment(&self, content: Bytes) -> Result<Option<PreparedChunk>> {
257        let address = compute_address(&content);
258        let data_size = u64::try_from(content.len())
259            .map_err(|e| Error::InvalidData(format!("content size too large: {e}")))?;
260
261        let quote_plan = match self
262            .get_store_quote_plan(&address, data_size, DATA_TYPE_CHUNK)
263            .await
264        {
265            Ok(plan) => plan,
266            Err(Error::AlreadyStored) => {
267                debug!("Chunk {} already stored, skipping", hex::encode(address));
268                return Ok(None);
269            }
270            Err(e) => return Err(e),
271        };
272        let quotes_with_peers = quote_plan.quotes;
273
274        // Capture the ordered PUT target set for replication. This can be
275        // wider than the peers that supplied the paid quotes.
276        let quoted_peers = quote_plan.put_peers;
277
278        // Build peer_quotes for ProofOfPayment + quotes for SingleNodePayment.
279        // Use node-reported prices directly — no contract price fetch needed.
280        let mut peer_quotes = Vec::with_capacity(quotes_with_peers.len());
281        let mut quotes_for_payment = Vec::with_capacity(quotes_with_peers.len());
282        // ADR-0004: forward each bound quote's commitment sidecar (baseline
283        // quotes ship none); `get_store_quotes` already verified the binding.
284        let mut commitment_sidecars = Vec::new();
285
286        for (peer_id, _addrs, quote, price, commitment) in quotes_with_peers {
287            let encoded = peer_id_to_encoded(&peer_id)?;
288            peer_quotes.push((encoded, quote.clone()));
289            quotes_for_payment.push((quote, price));
290            if let Some(sidecar) = commitment {
291                commitment_sidecars.push(sidecar);
292            }
293        }
294
295        let payment = SingleNodePayment::from_quotes(quotes_for_payment)
296            .map_err(|e| Error::Payment(format!("Failed to create payment: {e}")))?;
297
298        Ok(Some(PreparedChunk {
299            content,
300            address,
301            quoted_peers,
302            payment,
303            peer_quotes,
304            commitment_sidecars,
305        }))
306    }
307
308    /// Pay for multiple chunks in a single EVM transaction.
309    ///
310    /// Flattens all quote payments from the prepared chunks into one
311    /// `wallet.pay_for_quotes()` call, then maps transaction hashes
312    /// back to per-chunk [`PaymentProof`] bytes.
313    ///
314    /// # Errors
315    ///
316    /// Returns an error if the wallet is not configured or the on-chain
317    /// payment fails.
318    /// Returns `(paid_chunks, storage_cost_atto, gas_cost_wei)`.
319    pub async fn batch_pay(
320        &self,
321        prepared: Vec<PreparedChunk>,
322    ) -> Result<(Vec<PaidChunk>, String, u128)> {
323        if prepared.is_empty() {
324            return Ok((Vec::new(), "0".to_string(), 0));
325        }
326
327        let wallet = self.require_wallet()?;
328
329        // Compute total storage cost from the prepared chunks before paying.
330        let intent = PaymentIntent::from_prepared_chunks(&prepared);
331        let storage_cost_atto = intent.total_amount.to_string();
332
333        // Flatten all quote payments from all chunks into a single batch.
334        let total_quotes: usize = prepared.iter().map(|c| c.payment.quotes.len()).sum();
335        let mut all_payments = Vec::with_capacity(total_quotes);
336        for chunk in &prepared {
337            for info in &chunk.payment.quotes {
338                all_payments.push((info.quote_hash, info.rewards_address, info.amount));
339            }
340        }
341
342        debug!(
343            "Batch payment for {} chunks ({} quote entries)",
344            prepared.len(),
345            all_payments.len()
346        );
347
348        let (tx_hash_map, gas_info) =
349            wallet
350                .pay_for_quotes(all_payments)
351                .await
352                .map_err(|PayForQuotesError(err, _)| {
353                    Error::Payment(format!("Batch payment failed: {err}"))
354                })?;
355
356        info!(
357            "Batch payment succeeded: {} transactions",
358            tx_hash_map.len()
359        );
360
361        let tx_hash_map: HashMap<QuoteHash, TxHash> = tx_hash_map.into_iter().collect();
362        let paid_chunks = build_paid_chunks(prepared, &tx_hash_map)?;
363        Ok((paid_chunks, storage_cost_atto, gas_info.gas_cost_wei))
364    }
365
366    /// Upload chunks in waves with pipelined EVM payments.
367    ///
368    /// Processes chunks in waves of `PAYMENT_WAVE_SIZE` (64). Within each wave:
369    /// 1. **Prepare**: collect quotes for all chunks concurrently
370    /// 2. **Pay**: single EVM transaction for the whole wave
371    /// 3. **Store**: concurrent chunk replication to close group
372    ///
373    /// Stores from wave N overlap with quote collection for wave N+1
374    /// via `tokio::join!`.
375    ///
376    /// # Errors
377    ///
378    /// Returns an error if any payment or store operation fails.
379    /// Returns `(addresses, total_storage_cost_atto, total_gas_cost_wei)`.
380    pub async fn batch_upload_chunks(
381        &self,
382        chunks: Vec<Bytes>,
383    ) -> Result<(Vec<XorName>, String, u128)> {
384        let (addresses, storage, gas, _stats) = self
385            .batch_upload_chunks_with_events(chunks, None, 0, 0, None)
386            .await?;
387        Ok((addresses, storage, gas))
388    }
389
390    /// Same as [`Client::batch_upload_chunks`] but sends [`UploadEvent::ChunkStored`]
391    /// events as each chunk is stored, enabling per-chunk progress bars.
392    ///
393    /// `stored_offset` is the number of chunks already stored in previous waves
394    /// (so events report cumulative progress). `file_total` is the total chunk
395    /// count across ALL waves (for the `total` field in events).
396    ///
397    /// When `resume_key` is `Some`, per-wave payment proofs are persisted
398    /// to `<data_dir>/payments/single/<ts>_<hash(resume_key)>` via
399    /// `crate::data::client::cached_single` so that a partial-upload
400    /// failure can be resumed on the next attempt without paying twice.
401    /// The caller is responsible for deleting the cache entry on full
402    /// success (typically `upload_with_options` in `file.rs`).
403    pub async fn batch_upload_chunks_with_events(
404        &self,
405        chunks: Vec<Bytes>,
406        progress: Option<&mpsc::Sender<UploadEvent>>,
407        stored_offset: usize,
408        file_total: usize,
409        resume_key: Option<&str>,
410    ) -> Result<(Vec<XorName>, String, u128, WaveAggregateStats)> {
411        if chunks.is_empty() {
412            return Ok((
413                Vec::new(),
414                "0".to_string(),
415                0,
416                WaveAggregateStats::default(),
417            ));
418        }
419
420        let total_chunks = chunks.len();
421        let quote_cap = self.controller().quote.current();
422        let store_cap = self.controller().store.current();
423        debug!(
424            "Batch uploading {total_chunks} chunks in waves of {PAYMENT_WAVE_SIZE} \
425             (current adaptive caps — quote: {quote_cap}, store: {store_cap})"
426        );
427
428        // Load any previously-cached single-node receipt for this
429        // upload. Each chunk whose address is in the cache will skip
430        // the quote + pay phases and have its `PaidChunk` constructed
431        // directly from the cached proof + fresh quoted peers. The
432        // caller is responsible for deleting the cache on full
433        // success; we only read here, never write the load result back.
434        //
435        // Before trusting any cached proof, decode it locally and drop
436        // any whose quote.timestamp is past the storer's per-quote age
437        // budget (`QUOTE_MAX_AGE_SECS`, mirrored here as
438        // `CACHED_PROOF_EXPIRY_SECS`). The previous design trusted a
439        // substring match on remote error text, which a Byzantine
440        // storer could spoof to force double-payment. Local pre-flight
441        // is decision-pure: we never hand a doomed proof to a storer,
442        // and the cache is updated under our own lock with no remote
443        // text involved.
444        // Load only the cached PROOFS (for reuse). The cost this function
445        // returns is a per-call DELTA — what was freshly paid in THIS call —
446        // not the cache's cumulative. The single-node wave driver
447        // (`upload_spill_addresses_single`) calls this once per wave and SUMS
448        // the per-call costs, so seeding the return with the cumulative cache
449        // (which grows as each wave appends to it) double-counts:
450        // A + (A+B) + (A+B+C) instead of A+B+C.
451        let cached_proofs: HashMap<XorName, Vec<u8>> = match resume_key {
452            Some(key) => match crate::data::client::cached_single::try_load_for_file(key) {
453                Some((_, receipt)) => prune_locally_expired_proofs(key, receipt.proofs),
454                None => HashMap::new(),
455            },
456            None => HashMap::new(),
457        };
458
459        let mut all_addresses = Vec::with_capacity(total_chunks);
460        let mut seen_addresses: HashSet<XorName> = HashSet::new();
461
462        // Accumulate only THIS call's freshly-paid cost (per-call delta; see
463        // the proof-load comment above for why this must not include the cache).
464        let mut total_storage = Amount::ZERO;
465        let mut total_gas: u128 = 0;
466        let mut agg_stats = WaveAggregateStats::default();
467
468        // Deduplicate chunks by content address.
469        let mut unique_chunks = Vec::with_capacity(total_chunks);
470        for chunk in chunks {
471            let address = compute_address(&chunk);
472            if seen_addresses.insert(address) {
473                unique_chunks.push(chunk);
474            } else {
475                debug!("Skipping duplicate chunk {}", hex::encode(address));
476                all_addresses.push(address);
477                if let Some(tx) = progress {
478                    let _ = tx.try_send(UploadEvent::ChunkStored {
479                        stored: stored_offset + all_addresses.len(),
480                        total: file_total,
481                    });
482                }
483            }
484        }
485
486        // Split into waves.
487        let waves: Vec<Vec<Bytes>> = unique_chunks
488            .chunks(PAYMENT_WAVE_SIZE)
489            .map(<[Bytes]>::to_vec)
490            .collect();
491        let wave_count = waves.len();
492
493        debug!(
494            "{total_chunks} chunks -> {} unique -> {wave_count} waves",
495            seen_addresses.len()
496        );
497
498        let mut pending_store: Option<Vec<PaidChunk>> = None;
499        let mut total_quoted: usize = 0;
500
501        for (wave_idx, wave_chunks) in waves.into_iter().enumerate() {
502            let wave_num = wave_idx + 1;
503            let wave_size = wave_chunks.len();
504
505            // Pipeline: store previous wave while preparing this one.
506            let (prepare_result, store_result) = match pending_store.take() {
507                Some(paid_chunks) => {
508                    let store_offset = stored_offset + all_addresses.len();
509                    let quoted_offset = stored_offset + total_quoted;
510                    let (prep, stored) = tokio::join!(
511                        self.prepare_wave(wave_chunks, progress, quoted_offset, file_total),
512                        self.store_paid_chunks_with_events(
513                            paid_chunks,
514                            progress,
515                            store_offset,
516                            file_total
517                        )
518                    );
519                    (prep, Some(stored))
520                }
521                None => {
522                    let quoted_offset = stored_offset + total_quoted;
523                    let result = self
524                        .prepare_wave(wave_chunks, progress, quoted_offset, file_total)
525                        .await;
526                    (result, None)
527                }
528            };
529            total_quoted += wave_size;
530
531            // Track partial progress from previous wave.
532            if let Some(wave_result) = store_result {
533                all_addresses.extend(&wave_result.stored);
534                agg_stats.absorb(&wave_result);
535                if !wave_result.failed.is_empty() {
536                    let failed_count = wave_result.failed.len();
537                    warn!("{failed_count} chunks failed to store after retries");
538                    return Err(Error::PartialUpload {
539                        stored: all_addresses.clone(),
540                        stored_count: stored_offset + all_addresses.len(),
541                        failed: wave_result.failed,
542                        failed_count,
543                        total_chunks: file_total,
544                        spend: Box::new(PartialUploadSpend {
545                            storage_cost_atto: total_storage.to_string(),
546                            gas_cost_wei: total_gas,
547                        }),
548                        reason: "wave store failed after retries".into(),
549                    });
550                }
551            }
552
553            let (prepared_chunks, already_stored) = prepare_result?;
554            all_addresses.extend(&already_stored);
555            if let Some(tx) = progress {
556                for _ in &already_stored {
557                    let _ = tx.try_send(UploadEvent::ChunkStored {
558                        stored: stored_offset + all_addresses.len(),
559                        total: file_total,
560                    });
561                }
562            }
563
564            if prepared_chunks.is_empty() {
565                info!("Wave {wave_num}/{wave_count}: all chunks already stored");
566                continue;
567            }
568
569            // Split prepared chunks into "already paid in a previous
570            // attempt" (cached) and "needs payment" (fresh). Cached
571            // chunks build a `PaidChunk` from the cached proof + the
572            // freshly-quoted peers, bypassing the EVM transaction.
573            let mut needs_pay: Vec<PreparedChunk> = Vec::with_capacity(prepared_chunks.len());
574            let mut cached_paid: Vec<PaidChunk> = Vec::new();
575            for prep in prepared_chunks {
576                if let Some(proof_bytes) = cached_proofs.get(&prep.address).cloned() {
577                    cached_paid.push(PaidChunk {
578                        content: prep.content,
579                        address: prep.address,
580                        quoted_peers: prep.quoted_peers,
581                        proof_bytes,
582                    });
583                } else {
584                    needs_pay.push(prep);
585                }
586            }
587            if !cached_paid.is_empty() {
588                info!(
589                    "Wave {wave_num}/{wave_count}: reusing {} cached payment proofs",
590                    cached_paid.len()
591                );
592            }
593
594            let (mut paid_chunks, wave_storage, wave_gas) = if needs_pay.is_empty() {
595                (Vec::new(), "0".to_string(), 0u128)
596            } else {
597                info!(
598                    "Wave {wave_num}/{wave_count}: paying for {} chunks",
599                    needs_pay.len()
600                );
601                self.batch_pay(needs_pay).await?
602            };
603            if let Ok(cost) = wave_storage.parse::<Amount>() {
604                total_storage += cost;
605            }
606            total_gas = total_gas.saturating_add(wave_gas);
607
608            // Persist the freshly-paid wave's proofs so a later
609            // failure can resume without re-paying.
610            if let Some(key) = resume_key {
611                if !paid_chunks.is_empty() {
612                    let new_proofs: HashMap<[u8; 32], Vec<u8>> = paid_chunks
613                        .iter()
614                        .map(|pc| (pc.address, pc.proof_bytes.clone()))
615                        .collect();
616                    crate::data::client::cached_single::try_append_wave(
617                        key,
618                        new_proofs,
619                        &wave_storage,
620                        wave_gas,
621                    );
622                }
623            }
624
625            paid_chunks.extend(cached_paid);
626            pending_store = Some(paid_chunks);
627        }
628
629        // Store the last wave.
630        if let Some(paid_chunks) = pending_store {
631            let store_offset = stored_offset + all_addresses.len();
632            let wave_result = self
633                .store_paid_chunks_with_events(paid_chunks, progress, store_offset, file_total)
634                .await;
635            all_addresses.extend(&wave_result.stored);
636            agg_stats.absorb(&wave_result);
637            if !wave_result.failed.is_empty() {
638                let failed_count = wave_result.failed.len();
639                warn!("{failed_count} chunks failed to store after retries (final wave)");
640                return Err(Error::PartialUpload {
641                    stored: all_addresses.clone(),
642                    stored_count: stored_offset + all_addresses.len(),
643                    failed: wave_result.failed,
644                    failed_count,
645                    total_chunks: file_total,
646                    spend: Box::new(PartialUploadSpend {
647                        storage_cost_atto: total_storage.to_string(),
648                        gas_cost_wei: total_gas,
649                    }),
650                    reason: "final wave store failed after retries".into(),
651                });
652            }
653        }
654
655        debug!("Batch upload complete: {} addresses", all_addresses.len());
656        Ok((
657            all_addresses,
658            total_storage.to_string(),
659            total_gas,
660            agg_stats,
661        ))
662    }
663
664    /// Prepare a wave of chunks by collecting quotes concurrently.
665    ///
666    /// Fires [`UploadEvent::ChunkQuoted`] as each chunk's quote completes.
667    /// Returns `(prepared_chunks, already_stored_addresses)`.
668    async fn prepare_wave(
669        &self,
670        chunks: Vec<Bytes>,
671        progress: Option<&mpsc::Sender<UploadEvent>>,
672        quoted_offset: usize,
673        file_total: usize,
674    ) -> Result<(Vec<PreparedChunk>, Vec<XorName>)> {
675        let chunk_count = chunks.len();
676        let chunks_with_addr: Vec<(Bytes, XorName)> = chunks
677            .into_iter()
678            .map(|c| {
679                let addr = compute_address(&c);
680                (c, addr)
681            })
682            .collect();
683
684        let quote_limiter = self.controller().quote.clone();
685        // Batch-aware fan-out: clamp to chunk_count so we never
686        // pay for fan-out slots we cannot fill on a partial wave.
687        // See PERF-RESULTS.md — measured ~30% slowdown when
688        // cap > batch size on quoting workloads (live mainnet).
689        let quote_concurrency = quote_limiter.current().min(chunk_count.max(1));
690        let mut quote_stream = stream::iter(chunks_with_addr)
691            .map(|(content, address)| {
692                let limiter = quote_limiter.clone();
693                async move {
694                    let result = observe_op(
695                        &limiter,
696                        || async move { self.prepare_chunk_payment(content).await },
697                        classify_error,
698                    )
699                    .await;
700                    (address, result)
701                }
702            })
703            .buffer_unordered(quote_concurrency);
704
705        let mut prepared = Vec::with_capacity(chunk_count);
706        let mut already_stored = Vec::new();
707        let mut quoted_count = 0usize;
708
709        while let Some((address, result)) = quote_stream.next().await {
710            let chunk_already_stored = result.as_ref().is_ok_and(|r| r.is_none());
711            match result? {
712                Some(chunk) => prepared.push(chunk),
713                None => already_stored.push(address),
714            }
715            quoted_count += 1;
716            let progress_num = quoted_offset + quoted_count;
717            if file_total > 0 {
718                if chunk_already_stored {
719                    info!("Verified {progress_num}/{file_total} (already stored)");
720                } else {
721                    info!("Quoted {progress_num}/{file_total}");
722                }
723            }
724            if let Some(tx) = progress {
725                let _ = tx.try_send(UploadEvent::ChunkQuoted {
726                    quoted: progress_num,
727                    total: file_total,
728                });
729            }
730        }
731
732        Ok((prepared, already_stored))
733    }
734
735    /// Store a batch of paid chunks concurrently to their close groups.
736    ///
737    /// Retries failed chunks up to 3 times with exponential backoff (500ms, 1s, 2s).
738    /// Returns a [`WaveResult`] with both successes and failures so callers can
739    /// track partial progress instead of losing information about stored chunks.
740    ///
741    /// When `progress` is `Some`, sends [`UploadEvent::ChunkStored`] as each
742    /// chunk is successfully stored. `stored_before` is the count of chunks
743    /// already stored in previous waves so the event reports an accurate
744    /// cumulative total; `total_chunks` is the total across all waves. Pass
745    /// `None`/0/0 when progress reporting is not needed.
746    pub(crate) async fn store_paid_chunks_with_events(
747        &self,
748        paid_chunks: Vec<PaidChunk>,
749        progress: Option<&mpsc::Sender<UploadEvent>>,
750        stored_before: usize,
751        total_chunks: usize,
752    ) -> WaveResult {
753        const MAX_RETRIES: u32 = 3;
754        const BASE_DELAY_MS: u64 = 500;
755
756        let mut stored = Vec::new();
757        let mut to_retry = paid_chunks;
758
759        // Per-chunk first-seen timestamps, keyed by chunk address.
760        // Inserted on first sight; never overwritten so wall-clock spans
761        // first attempt → eventual success across all retry rounds.
762        let mut first_seen: HashMap<XorName, Instant> = HashMap::with_capacity(to_retry.len());
763        for chunk in &to_retry {
764            first_seen.entry(chunk.address).or_insert_with(Instant::now);
765        }
766
767        // Bound concurrency by IN-FLIGHT BYTES, not just chunk count. Each
768        // concurrently-stored chunk is held in memory while it is sent to its
769        // close group, and the send path re-serializes the body once per peer,
770        // so a wave of large (~4 MB) chunks at full store concurrency can pin
771        // multiple GB and OOM a small host. Cap how many chunks store at once
772        // so their combined body size stays under the budget; small chunks are
773        // unaffected (the byte bound exceeds the chunk-count bound). The budget
774        // is deliberately conservative for the current per-peer send
775        // amplification and can be raised once that is reduced upstream.
776        let max_chunk_bytes = to_retry.iter().map(|c| c.content.len()).max().unwrap_or(0);
777        // `checked_div` yields `None` only when `max_chunk_bytes == 0` (an
778        // empty/zero-length wave), in which case there is no byte limit.
779        let byte_bound = STORE_INFLIGHT_BYTE_BUDGET
780            .checked_div(max_chunk_bytes)
781            .map_or(usize::MAX, |n| n.max(1));
782
783        let mut chunk_attempts_total: usize = 0;
784        let mut store_durations_ms: Vec<u64> = Vec::new();
785        let mut retries_per_chunk: Vec<u32> = Vec::new();
786
787        for attempt in 0..=MAX_RETRIES {
788            if attempt > 0 {
789                let delay = Duration::from_millis(BASE_DELAY_MS * 2u64.pow(attempt - 1));
790                tokio::time::sleep(delay).await;
791                info!(
792                    "Retry attempt {attempt}/{MAX_RETRIES} for {} chunks",
793                    to_retry.len()
794                );
795            }
796
797            // Each chunk in this round counts as one store-RPC attempt.
798            chunk_attempts_total = chunk_attempts_total.saturating_add(to_retry.len());
799
800            let store_limiter = self.controller().store.clone();
801            // Rolling scheduler: keep up to `cap()` stores in flight and re-read
802            // the cap as each slot frees, so mid-flight limiter growth reaches
803            // the rest of this wave instead of being frozen at a per-wave
804            // snapshot (V2-554). The in-flight BYTE budget (`byte_bound`) stays
805            // enforced so a wave of large chunks can't OOM a small host;
806            // iterator exhaustion bounds launches to the wave, so no explicit
807            // clamp to `to_retry.len()` is needed.
808            let make_store = |chunk: PaidChunk| {
809                let chunk_clone = chunk.clone();
810                let limiter = store_limiter.clone();
811                async move {
812                    let result = observe_op(
813                        &limiter,
814                        || async move {
815                            self.chunk_put_to_close_group(
816                                chunk.content,
817                                chunk.proof_bytes,
818                                &chunk.quoted_peers,
819                            )
820                            .await
821                        },
822                        classify_error,
823                    )
824                    .await;
825                    (chunk_clone, result)
826                }
827            };
828            let mut chunk_iter = to_retry.into_iter();
829            let mut in_flight = FuturesUnordered::new();
830
831            let mut failed_this_round = Vec::new();
832            loop {
833                let slots = store_limiter.current().min(byte_bound).max(1);
834                while in_flight.len() < slots {
835                    match chunk_iter.next() {
836                        Some(chunk) => in_flight.push(make_store(chunk)),
837                        None => break,
838                    }
839                }
840                let Some((chunk, result)) = in_flight.next().await else {
841                    break;
842                };
843                match result {
844                    Ok(name) => {
845                        let duration_ms = first_seen
846                            .get(&chunk.address)
847                            .map(|t| u64::try_from(t.elapsed().as_millis()).unwrap_or(u64::MAX))
848                            .unwrap_or(0);
849                        store_durations_ms.push(duration_ms);
850                        retries_per_chunk.push(attempt);
851                        stored.push(name);
852                        let stored_num = stored_before + stored.len();
853                        if total_chunks > 0 {
854                            info!("Stored {stored_num}/{total_chunks}");
855                        }
856                        if let Some(tx) = progress {
857                            let _ = tx.try_send(UploadEvent::ChunkStored {
858                                stored: stored_num,
859                                total: total_chunks,
860                            });
861                        }
862                    }
863                    Err(e) => failed_this_round.push((chunk, e.to_string())),
864                }
865            }
866
867            if failed_this_round.is_empty() {
868                let result = WaveResult {
869                    stored,
870                    failed: Vec::new(),
871                    chunk_attempts_total,
872                    store_durations_ms,
873                    retries_per_chunk,
874                };
875                log_wave_summary(&result);
876                return result;
877            }
878
879            if attempt == MAX_RETRIES {
880                let failed = failed_this_round
881                    .into_iter()
882                    .map(|(c, e)| (c.address, e))
883                    .collect();
884                let result = WaveResult {
885                    stored,
886                    failed,
887                    chunk_attempts_total,
888                    store_durations_ms,
889                    retries_per_chunk,
890                };
891                log_wave_summary(&result);
892                return result;
893            }
894
895            warn!(
896                "{} chunks failed on attempt {}, will retry",
897                failed_this_round.len(),
898                attempt + 1
899            );
900            to_retry = failed_this_round.into_iter().map(|(c, _)| c).collect();
901        }
902
903        // Unreachable due to loop structure, but satisfy the compiler.
904        let result = WaveResult {
905            stored,
906            failed: Vec::new(),
907            chunk_attempts_total,
908            store_durations_ms,
909            retries_per_chunk,
910        };
911        log_wave_summary(&result);
912        result
913    }
914}
915
916/// Emit one structured info line summarising a wave's store-side stats.
917///
918/// Surfaces p50/p95/max chunk wall-clock and per-round retry counts so
919/// log-based analysis tooling (Elasticsearch / Kibana) can identify
920/// client-side quorum or retry cost without needing the `--json` output.
921fn log_wave_summary(result: &WaveResult) {
922    let retries_round_1 = result.retries_per_chunk.iter().filter(|&&r| r == 1).count();
923    let retries_round_2 = result.retries_per_chunk.iter().filter(|&&r| r == 2).count();
924    let retries_round_3 = result.retries_per_chunk.iter().filter(|&&r| r == 3).count();
925    let chunk_attempts_total = result.chunk_attempts_total;
926    info!(
927        chunks_stored = result.stored.len(),
928        chunks_failed = result.failed.len(),
929        chunk_attempts_total,
930        retries_round_1,
931        retries_round_2,
932        retries_round_3,
933        store_duration_p50_ms = percentile(&result.store_durations_ms, 0.50),
934        store_duration_p95_ms = percentile(&result.store_durations_ms, 0.95),
935        store_duration_max_ms = result.store_durations_ms.iter().max().copied().unwrap_or(0),
936        "chunk_store_wave_complete"
937    );
938}
939
940/// Safety margin subtracted from the storer's `QUOTE_MAX_AGE_SECS` (24 h)
941/// when deciding to trust a cached proof.
942///
943/// A proof whose oldest `quote.timestamp` is closer than this to the
944/// storer's hard limit is treated as already-expired locally. The
945/// margin covers (a) clock skew between client and storer, (b) the
946/// in-flight time between the local check and the storer's
947/// `validate_quote_timestamps` call, and (c) the time spent uploading
948/// the chunk body. 5 minutes is generous for all three combined and
949/// cheap: a wrongly-kept proof costs an extra retry round trip, a
950/// wrongly-dropped proof costs one re-pay (cheap chunk).
951const CACHED_PROOF_SAFETY_MARGIN_SECS: u64 = 300;
952
953/// Storer-side budget for a quote's age. Mirrors `QUOTE_MAX_AGE_SECS`
954/// in `ant-node/src/payment/verifier.rs`. If this value drifts on the
955/// node side, the worst case is the client either keeps proofs slightly
956/// past the storer limit (forced re-pay on next retry, no money lost)
957/// or drops them slightly early (one extra re-pay, no money lost).
958/// Either way, no payment is double-spent or stranded.
959const CACHED_PROOF_MAX_AGE_SECS: u64 = 24 * 60 * 60;
960
961/// How far a cached quote's `timestamp` may be in the future before we
962/// classify it as too-skewed-to-trust and prune.
963///
964/// Mirrors `QUOTE_FUTURE_SKEW_TOLERANCE_SECS = 300` in
965/// `ant-node/src/payment/verifier.rs`. If the client's clock runs
966/// slow relative to the storer that issued the quote, a perfectly
967/// valid proof can appear future-dated to the client — rejecting any
968/// forward drift would re-pay those chunks on every retry. Allow the
969/// same 5-minute window the storer does so the client and node agree
970/// on which proofs are fresh.
971const CACHED_PROOF_FUTURE_SKEW_TOLERANCE_SECS: u64 = 300;
972
973/// Drop cached `proof_bytes` whose quote timestamps are too close to
974/// the storer's expiry window to safely reuse.
975///
976/// Why this exists
977/// ---------------
978/// The cache stores `(chunk_address, proof_bytes)` so a retried upload
979/// can skip re-paying. The proof bytes embed `quote.timestamp`s. Each
980/// storer evaluates each `quote.timestamp` independently against its
981/// 24 h `QUOTE_MAX_AGE_SECS` budget, so close to the 24 h boundary
982/// (or on a multi-day-old cache that survived past the receipt's outer
983/// expiry for some reason) the storer rejects what the client still
984/// believes is fresh.
985///
986/// The previous design trusted a substring match on the storer's
987/// returned error text to detect this and invalidate the cache after
988/// the fact. That allowed a Byzantine storer to spoof the marker and
989/// force the client to re-pay fresh proofs (double-payment). This
990/// implementation is decision-pure: we decode the proof locally and
991/// only re-use it if every embedded quote is comfortably within the
992/// budget. No remote text involved.
993///
994/// Side-effect: dropped entries are removed from the on-disk cache so
995/// they don't reappear on the next load.
996fn prune_locally_expired_proofs(
997    resume_key: &str,
998    proofs: HashMap<[u8; 32], Vec<u8>>,
999) -> HashMap<XorName, Vec<u8>> {
1000    let now = std::time::SystemTime::now();
1001    let max_safe_age = Duration::from_secs(
1002        CACHED_PROOF_MAX_AGE_SECS.saturating_sub(CACHED_PROOF_SAFETY_MARGIN_SECS),
1003    );
1004    let max_future_skew = Duration::from_secs(CACHED_PROOF_FUTURE_SKEW_TOLERANCE_SECS);
1005    let mut kept: HashMap<XorName, Vec<u8>> = HashMap::with_capacity(proofs.len());
1006    // Pair each expired address with the EXACT bytes we observed at
1007    // load time. The cache-side drop only removes the entry if those
1008    // bytes still match, so a concurrent re-pay that refreshed the
1009    // proof under its own lock is not clobbered (CAS semantics, fixes
1010    // the TOCTOU between unlocked-load and locked-drop).
1011    let mut expired: Vec<([u8; 32], Vec<u8>)> = Vec::new();
1012    for (addr, bytes) in proofs {
1013        match deserialize_proof(&bytes) {
1014            Ok((proof, _tx_hashes)) => {
1015                if proof_is_safely_fresh(&proof, now, max_safe_age, max_future_skew) {
1016                    kept.insert(addr, bytes);
1017                } else {
1018                    expired.push((addr, bytes));
1019                }
1020            }
1021            Err(_) => {
1022                // Unreadable cached entry: drop it so it doesn't sit
1023                // here forever. The chunk will re-quote+re-pay.
1024                expired.push((addr, bytes));
1025            }
1026        }
1027    }
1028    if !expired.is_empty() {
1029        info!(
1030            "Pruning {} stale cached proofs (quote.timestamp past safe-reuse window) \
1031             before resume",
1032            expired.len()
1033        );
1034        crate::data::client::cached_single::try_drop_proofs_for_file(resume_key, &expired);
1035    }
1036    kept
1037}
1038
1039/// True iff every quote in the proof has a timestamp not older than
1040/// `now - max_safe_age` AND not further in the future than
1041/// `max_future_skew`. The forward-skew check mirrors the storer's
1042/// `QUOTE_FUTURE_SKEW_TOLERANCE_SECS` (300s) so a slow-running client
1043/// clock doesn't cause us to wrongly prune perfectly fresh proofs
1044/// that the storer would still accept.
1045fn proof_is_safely_fresh(
1046    proof: &ProofOfPayment,
1047    now: std::time::SystemTime,
1048    max_safe_age: Duration,
1049    max_future_skew: Duration,
1050) -> bool {
1051    for (_peer, quote) in &proof.peer_quotes {
1052        match now.duration_since(quote.timestamp) {
1053            Ok(age) => {
1054                if age > max_safe_age {
1055                    return false;
1056                }
1057            }
1058            Err(future) => {
1059                if future.duration() > max_future_skew {
1060                    return false;
1061                }
1062            }
1063        }
1064    }
1065    true
1066}
1067
1068/// Compile-time assertions that batch method futures are Send.
1069#[cfg(test)]
1070mod send_assertions {
1071    use super::*;
1072
1073    fn _assert_send<T: Send>(_: &T) {}
1074
1075    #[allow(dead_code)]
1076    async fn _batch_upload_is_send(client: &Client) {
1077        let fut = client.batch_upload_chunks(Vec::new());
1078        _assert_send(&fut);
1079    }
1080}
1081
1082#[cfg(test)]
1083#[allow(clippy::unwrap_used)]
1084mod tests {
1085    use super::*;
1086    use ant_protocol::payment::QuotePaymentInfo;
1087    use ant_protocol::CLOSE_GROUP_SIZE;
1088
1089    /// Median index in the quotes array.
1090    const MEDIAN_INDEX: usize = CLOSE_GROUP_SIZE / 2;
1091
1092    /// Helper: build a `PreparedChunk` with `median_amount` at the median
1093    /// quote index and zero for all other quotes. Adapts automatically to
1094    /// `CLOSE_GROUP_SIZE` changes.
1095    fn make_prepared_chunk(median_amount: u64) -> PreparedChunk {
1096        let quotes: [QuotePaymentInfo; CLOSE_GROUP_SIZE] = std::array::from_fn(|i| {
1097            let amount = if i == MEDIAN_INDEX { median_amount } else { 0 };
1098            QuotePaymentInfo {
1099                quote_hash: QuoteHash::from([i as u8 + 1; 32]),
1100                rewards_address: RewardsAddress::new([i as u8 + 10; 20]),
1101                amount: Amount::from(amount),
1102                price: Amount::from(amount),
1103            }
1104        });
1105
1106        PreparedChunk {
1107            content: Bytes::from(vec![0xAA; 32]),
1108            address: [0u8; 32],
1109            quoted_peers: Vec::new(),
1110            payment: SingleNodePayment { quotes },
1111            peer_quotes: Vec::new(),
1112            commitment_sidecars: Vec::new(),
1113        }
1114    }
1115
1116    #[test]
1117    fn payment_intent_from_single_chunk() {
1118        let chunk = make_prepared_chunk(300);
1119        let intent = PaymentIntent::from_prepared_chunks(&[chunk]);
1120
1121        assert_eq!(intent.payments.len(), 1, "only non-zero amounts");
1122        assert_eq!(intent.total_amount, Amount::from(300));
1123
1124        let (hash, addr, amt) = &intent.payments[0];
1125        assert_eq!(*hash, QuoteHash::from([MEDIAN_INDEX as u8 + 1; 32]));
1126        assert_eq!(*addr, RewardsAddress::new([MEDIAN_INDEX as u8 + 10; 20]));
1127        assert_eq!(*amt, Amount::from(300));
1128    }
1129
1130    #[test]
1131    fn payment_intent_from_multiple_chunks() {
1132        let c1 = make_prepared_chunk(100);
1133        let c2 = make_prepared_chunk(250);
1134        let intent = PaymentIntent::from_prepared_chunks(&[c1, c2]);
1135
1136        assert_eq!(intent.payments.len(), 2);
1137        assert_eq!(intent.total_amount, Amount::from(350));
1138    }
1139
1140    #[test]
1141    fn payment_intent_skips_all_zero_chunks() {
1142        let chunk = make_prepared_chunk(0);
1143        let intent = PaymentIntent::from_prepared_chunks(&[chunk]);
1144
1145        assert!(intent.payments.is_empty());
1146        assert_eq!(intent.total_amount, Amount::ZERO);
1147    }
1148
1149    #[test]
1150    fn payment_intent_empty_input() {
1151        let intent = PaymentIntent::from_prepared_chunks(&[]);
1152        assert!(intent.payments.is_empty());
1153        assert_eq!(intent.total_amount, Amount::ZERO);
1154    }
1155
1156    #[test]
1157    fn finalize_batch_payment_builds_proofs() {
1158        let chunk = make_prepared_chunk(500);
1159        let quote_hash = chunk.payment.quotes[MEDIAN_INDEX].quote_hash;
1160
1161        let mut tx_map = HashMap::new();
1162        tx_map.insert(quote_hash, TxHash::from([0xBB; 32]));
1163
1164        let paid = finalize_batch_payment(vec![chunk], &tx_map).unwrap();
1165
1166        assert_eq!(paid.len(), 1);
1167        assert!(!paid[0].proof_bytes.is_empty());
1168        assert_eq!(paid[0].address, [0u8; 32]);
1169    }
1170
1171    #[test]
1172    fn finalize_batch_payment_empty_input() {
1173        let paid = finalize_batch_payment(vec![], &HashMap::new()).unwrap();
1174        assert!(paid.is_empty());
1175    }
1176
1177    #[test]
1178    fn finalize_batch_payment_missing_tx_hash_errors() {
1179        // Missing tx hash for a non-zero-amount quote should error,
1180        // since the chunk would be rejected by the network without a valid proof.
1181        let chunk = make_prepared_chunk(500);
1182
1183        let result = finalize_batch_payment(vec![chunk], &HashMap::new());
1184        assert!(result.is_err());
1185        let err = result.unwrap_err().to_string();
1186        assert!(err.contains("Missing tx hash"), "got: {err}");
1187    }
1188
1189    #[test]
1190    fn finalize_batch_payment_multiple_chunks() {
1191        let c1 = make_prepared_chunk(100);
1192        let c2 = make_prepared_chunk(200);
1193        let q1 = c1.payment.quotes[MEDIAN_INDEX].quote_hash;
1194        let mut tx_map = HashMap::new();
1195        // Both chunks have the same quote_hash (same index/byte pattern)
1196        // so one tx_hash covers both
1197        tx_map.insert(q1, TxHash::from([0xCC; 32]));
1198
1199        let paid = finalize_batch_payment(vec![c1, c2], &tx_map).unwrap();
1200        assert_eq!(paid.len(), 2);
1201    }
1202
1203    // ---- prune_locally_expired_proofs ----
1204    //
1205    // Build synthetic ProofOfPayment instances with controlled
1206    // timestamps to verify the local pre-flight stale-proof check.
1207    // This is the "no remote text trust" replacement for the prior
1208    // substring-matching invalidation path. A bug here is a direct
1209    // wallet leak (drop-too-eager = re-pay; keep-too-long = doomed
1210    // PUT round trip but no payment loss).
1211
1212    fn make_proof_with_timestamps(timestamps: &[std::time::SystemTime]) -> ProofOfPayment {
1213        let peer_quotes = timestamps
1214            .iter()
1215            .enumerate()
1216            .map(|(i, ts)| {
1217                let quote = PaymentQuote {
1218                    content: xor_name::XorName([0u8; 32]),
1219                    timestamp: *ts,
1220                    price: Amount::from(1u64),
1221                    rewards_address: RewardsAddress::new([1u8; 20]),
1222                    pub_key: vec![],
1223                    signature: vec![],
1224                    committed_key_count: 0,
1225                    commitment_pin: None,
1226                };
1227                (EncodedPeerId::from([i as u8; 32]), quote)
1228            })
1229            .collect();
1230        ProofOfPayment { peer_quotes }
1231    }
1232
1233    fn default_max_future_skew() -> Duration {
1234        Duration::from_secs(CACHED_PROOF_FUTURE_SKEW_TOLERANCE_SECS)
1235    }
1236
1237    #[test]
1238    fn proof_is_safely_fresh_accepts_recent_quote() {
1239        let proof = make_proof_with_timestamps(&[std::time::SystemTime::now()]);
1240        assert!(proof_is_safely_fresh(
1241            &proof,
1242            std::time::SystemTime::now(),
1243            Duration::from_secs(CACHED_PROOF_MAX_AGE_SECS),
1244            default_max_future_skew(),
1245        ));
1246    }
1247
1248    #[test]
1249    fn proof_is_safely_fresh_rejects_quote_past_safe_window() {
1250        // 23h57m old: past the 24h - 5min safe-reuse threshold but
1251        // still within the storer's hard 24h limit. The whole point
1252        // of the safety margin is to drop these locally before
1253        // burning a doomed PUT round trip.
1254        let too_old = std::time::SystemTime::now() - Duration::from_secs(23 * 60 * 60 + 57 * 60);
1255        let proof = make_proof_with_timestamps(&[too_old]);
1256        let max_safe = Duration::from_secs(
1257            CACHED_PROOF_MAX_AGE_SECS.saturating_sub(CACHED_PROOF_SAFETY_MARGIN_SECS),
1258        );
1259        assert!(
1260            !proof_is_safely_fresh(
1261                &proof,
1262                std::time::SystemTime::now(),
1263                max_safe,
1264                default_max_future_skew(),
1265            ),
1266            "23h57m-old quote must fail safe-reuse check (limit is 24h - 5min margin)"
1267        );
1268    }
1269
1270    #[test]
1271    fn proof_is_safely_fresh_rejects_if_any_quote_is_stale() {
1272        // The storer rejects on a per-quote basis: a proof with even
1273        // one stale quote will fail on every retry. We must drop it.
1274        let now = std::time::SystemTime::now();
1275        let fresh = now;
1276        let stale = now - Duration::from_secs(CACHED_PROOF_MAX_AGE_SECS);
1277        let proof = make_proof_with_timestamps(&[fresh, fresh, stale, fresh]);
1278        let max_safe = Duration::from_secs(
1279            CACHED_PROOF_MAX_AGE_SECS.saturating_sub(CACHED_PROOF_SAFETY_MARGIN_SECS),
1280        );
1281        assert!(!proof_is_safely_fresh(
1282            &proof,
1283            now,
1284            max_safe,
1285            default_max_future_skew(),
1286        ));
1287    }
1288
1289    #[test]
1290    fn proof_is_safely_fresh_accepts_slight_future_skew_within_node_tolerance() {
1291        // Client clock 60s slow. Quote claims 60s in the future of
1292        // our local view. Node tolerates 300s forward skew, so the
1293        // storer would accept this quote — we must too, or we'd
1294        // wrongly prune fresh proofs and force re-payment.
1295        let now = std::time::SystemTime::now();
1296        let slight_future = now + Duration::from_secs(60);
1297        let proof = make_proof_with_timestamps(&[slight_future]);
1298        let max_safe = Duration::from_secs(CACHED_PROOF_MAX_AGE_SECS);
1299        assert!(
1300            proof_is_safely_fresh(&proof, now, max_safe, default_max_future_skew()),
1301            "60s-future quote must be accepted (within node's 300s skew tolerance)"
1302        );
1303    }
1304
1305    #[test]
1306    fn proof_is_safely_fresh_rejects_far_future_dated_quote() {
1307        // 1 hour in the future of our local clock. Exceeds the
1308        // node's 300s forward-skew tolerance and the storer would
1309        // reject it — we drop it locally to avoid a round trip.
1310        let now = std::time::SystemTime::now();
1311        let far_future = now + Duration::from_secs(3600);
1312        let proof = make_proof_with_timestamps(&[far_future]);
1313        let max_safe = Duration::from_secs(CACHED_PROOF_MAX_AGE_SECS);
1314        assert!(!proof_is_safely_fresh(
1315            &proof,
1316            now,
1317            max_safe,
1318            default_max_future_skew(),
1319        ));
1320    }
1321
1322    #[test]
1323    fn proof_is_safely_fresh_empty_quotes_is_vacuously_safe() {
1324        // No quotes = no storer-side timestamp check to fail. The
1325        // proof is structurally invalid for other reasons, but
1326        // this function's contract is "no stale timestamp present",
1327        // which is trivially true for an empty list.
1328        let proof = make_proof_with_timestamps(&[]);
1329        assert!(proof_is_safely_fresh(
1330            &proof,
1331            std::time::SystemTime::now(),
1332            Duration::from_secs(CACHED_PROOF_MAX_AGE_SECS),
1333            default_max_future_skew(),
1334        ));
1335    }
1336}