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