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