1use crate::data::client::adaptive::observe_op;
8use crate::data::client::classify_error;
9use crate::data::client::file::UploadEvent;
10use crate::data::client::payment::peer_id_to_encoded;
11use crate::data::client::Client;
12use crate::data::error::{Error, PartialUploadSpend, Result};
13use ant_protocol::evm::{
14 Amount, EncodedPeerId, PayForQuotesError, PaymentQuote, ProofOfPayment, QuoteHash,
15 RewardsAddress, TxHash,
16};
17use ant_protocol::payment::{
18 deserialize_proof, serialize_single_node_proof, PaymentProof, SingleNodePayment,
19};
20use ant_protocol::transport::{MultiAddr, PeerId};
21use ant_protocol::{compute_address, XorName, DATA_TYPE_CHUNK};
22use bytes::Bytes;
23use futures::stream::{self, FuturesUnordered, StreamExt};
24use std::collections::{HashMap, HashSet};
25use std::time::{Duration, Instant};
26use tokio::sync::mpsc;
27use tracing::{debug, info, warn};
28
29const PAYMENT_WAVE_SIZE: usize = 64;
31
32const STORE_INFLIGHT_BYTE_BUDGET: usize = 64 * 1024 * 1024;
38
39#[derive(Debug)]
41pub struct PreparedChunk {
42 pub content: Bytes,
44 pub address: XorName,
46 pub quoted_peers: Vec<(PeerId, Vec<MultiAddr>)>,
52 pub payment: SingleNodePayment,
54 pub peer_quotes: Vec<(EncodedPeerId, PaymentQuote)>,
56 pub commitment_sidecars: Vec<Vec<u8>>,
60}
61
62#[derive(Debug, Clone)]
64pub struct PaidChunk {
65 pub content: Bytes,
67 pub address: XorName,
69 pub quoted_peers: Vec<(PeerId, Vec<MultiAddr>)>,
75 pub proof_bytes: Vec<u8>,
77}
78
79#[derive(Debug)]
81pub struct WaveResult {
82 pub stored: Vec<XorName>,
84 pub failed: Vec<(XorName, String)>,
86 pub chunk_attempts_total: usize,
88 pub store_durations_ms: Vec<u64>,
90 pub retries_per_chunk: Vec<u32>,
92}
93
94#[derive(Debug, Default, Clone)]
101pub struct WaveAggregateStats {
102 pub chunk_attempts_total: usize,
104 pub store_durations_ms: Vec<u64>,
107 pub retries_histogram: [usize; 4],
112}
113
114impl WaveAggregateStats {
115 pub fn absorb(&mut self, wave: &WaveResult) {
117 self.chunk_attempts_total = self
118 .chunk_attempts_total
119 .saturating_add(wave.chunk_attempts_total);
120 self.store_durations_ms.extend(&wave.store_durations_ms);
121 for &r in &wave.retries_per_chunk {
122 let idx = (r as usize).min(self.retries_histogram.len() - 1);
123 self.retries_histogram[idx] = self.retries_histogram[idx].saturating_add(1);
124 }
125 }
126}
127
128fn percentile(values: &[u64], p: f64) -> u64 {
134 if values.is_empty() {
135 return 0;
136 }
137 let mut sorted = values.to_vec();
138 sorted.sort_unstable();
139 let p = p.clamp(0.0, 1.0);
140 let n = sorted.len();
142 #[allow(
143 clippy::cast_possible_truncation,
144 clippy::cast_sign_loss,
145 clippy::cast_precision_loss
146 )]
147 let rank = ((p * n as f64).ceil() as usize)
148 .saturating_sub(1)
149 .min(n - 1);
150 sorted[rank]
151}
152
153#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
158pub struct PaymentIntent {
159 pub payments: Vec<(QuoteHash, RewardsAddress, Amount)>,
161 pub total_amount: Amount,
163}
164
165impl PaymentIntent {
166 pub fn from_prepared_chunks(prepared: &[PreparedChunk]) -> Self {
170 let mut payments = Vec::new();
171 let mut total = Amount::ZERO;
172 for chunk in prepared {
173 for info in &chunk.payment.quotes {
174 if !info.amount.is_zero() {
175 payments.push((info.quote_hash, info.rewards_address, info.amount));
176 total += info.amount;
177 }
178 }
179 }
180 Self {
181 payments,
182 total_amount: total,
183 }
184 }
185}
186
187fn build_paid_chunks(
194 prepared: Vec<PreparedChunk>,
195 tx_hash_map: &HashMap<QuoteHash, TxHash>,
196) -> Result<Vec<PaidChunk>> {
197 let mut paid_chunks = Vec::with_capacity(prepared.len());
198 for chunk in prepared {
199 let mut tx_hashes = Vec::new();
200 for info in &chunk.payment.quotes {
201 if !info.amount.is_zero() {
202 let tx_hash = tx_hash_map.get(&info.quote_hash).copied().ok_or_else(|| {
203 Error::Payment(format!(
204 "Missing tx hash for quote {} — external signer did not return a receipt for this payment",
205 hex::encode(info.quote_hash)
206 ))
207 })?;
208 tx_hashes.push(tx_hash);
209 }
210 }
211
212 let proof = PaymentProof {
213 proof_of_payment: ProofOfPayment {
214 peer_quotes: chunk.peer_quotes,
215 },
216 tx_hashes,
217 commitment_sidecars: chunk.commitment_sidecars,
220 };
221
222 let proof_bytes = serialize_single_node_proof(&proof)
223 .map_err(|e| Error::Serialization(format!("Failed to serialize payment proof: {e}")))?;
224
225 paid_chunks.push(PaidChunk {
226 content: chunk.content,
227 address: chunk.address,
228 quoted_peers: chunk.quoted_peers,
229 proof_bytes,
230 });
231 }
232 Ok(paid_chunks)
233}
234
235pub fn finalize_batch_payment(
240 prepared: Vec<PreparedChunk>,
241 tx_hash_map: &HashMap<QuoteHash, TxHash>,
242) -> Result<Vec<PaidChunk>> {
243 build_paid_chunks(prepared, tx_hash_map)
244}
245
246impl Client {
247 pub async fn prepare_chunk_payment(&self, content: Bytes) -> Result<Option<PreparedChunk>> {
257 let address = compute_address(&content);
258 let data_size = u64::try_from(content.len())
259 .map_err(|e| Error::InvalidData(format!("content size too large: {e}")))?;
260
261 let quote_plan = match self
262 .get_store_quote_plan(&address, data_size, DATA_TYPE_CHUNK)
263 .await
264 {
265 Ok(plan) => plan,
266 Err(Error::AlreadyStored) => {
267 debug!("Chunk {} already stored, skipping", hex::encode(address));
268 return Ok(None);
269 }
270 Err(e) => return Err(e),
271 };
272 let quotes_with_peers = quote_plan.quotes;
273
274 let quoted_peers = quote_plan.put_peers;
277
278 let mut peer_quotes = Vec::with_capacity(quotes_with_peers.len());
281 let mut quotes_for_payment = Vec::with_capacity(quotes_with_peers.len());
282 let mut commitment_sidecars = Vec::new();
285
286 for (peer_id, _addrs, quote, price, commitment) in quotes_with_peers {
287 let encoded = peer_id_to_encoded(&peer_id)?;
288 peer_quotes.push((encoded, quote.clone()));
289 quotes_for_payment.push((quote, price));
290 if let Some(sidecar) = commitment {
291 commitment_sidecars.push(sidecar);
292 }
293 }
294
295 let payment = SingleNodePayment::from_quotes(quotes_for_payment)
296 .map_err(|e| Error::Payment(format!("Failed to create payment: {e}")))?;
297
298 Ok(Some(PreparedChunk {
299 content,
300 address,
301 quoted_peers,
302 payment,
303 peer_quotes,
304 commitment_sidecars,
305 }))
306 }
307
308 pub async fn batch_pay(
320 &self,
321 prepared: Vec<PreparedChunk>,
322 ) -> Result<(Vec<PaidChunk>, String, u128)> {
323 if prepared.is_empty() {
324 return Ok((Vec::new(), "0".to_string(), 0));
325 }
326
327 let wallet = self.require_wallet()?;
328
329 let intent = PaymentIntent::from_prepared_chunks(&prepared);
331 let storage_cost_atto = intent.total_amount.to_string();
332
333 let total_quotes: usize = prepared.iter().map(|c| c.payment.quotes.len()).sum();
335 let mut all_payments = Vec::with_capacity(total_quotes);
336 for chunk in &prepared {
337 for info in &chunk.payment.quotes {
338 all_payments.push((info.quote_hash, info.rewards_address, info.amount));
339 }
340 }
341
342 debug!(
343 "Batch payment for {} chunks ({} quote entries)",
344 prepared.len(),
345 all_payments.len()
346 );
347
348 let (tx_hash_map, gas_info) =
349 wallet
350 .pay_for_quotes(all_payments)
351 .await
352 .map_err(|PayForQuotesError(err, _)| {
353 Error::Payment(format!("Batch payment failed: {err}"))
354 })?;
355
356 info!(
357 "Batch payment succeeded: {} transactions",
358 tx_hash_map.len()
359 );
360
361 let tx_hash_map: HashMap<QuoteHash, TxHash> = tx_hash_map.into_iter().collect();
362 let paid_chunks = build_paid_chunks(prepared, &tx_hash_map)?;
363 Ok((paid_chunks, storage_cost_atto, gas_info.gas_cost_wei))
364 }
365
366 pub async fn batch_upload_chunks(
381 &self,
382 chunks: Vec<Bytes>,
383 ) -> Result<(Vec<XorName>, String, u128)> {
384 let (addresses, storage, gas, _stats) = self
385 .batch_upload_chunks_with_events(chunks, None, 0, 0, None)
386 .await?;
387 Ok((addresses, storage, gas))
388 }
389
390 pub async fn batch_upload_chunks_with_events(
404 &self,
405 chunks: Vec<Bytes>,
406 progress: Option<&mpsc::Sender<UploadEvent>>,
407 stored_offset: usize,
408 file_total: usize,
409 resume_key: Option<&str>,
410 ) -> Result<(Vec<XorName>, String, u128, WaveAggregateStats)> {
411 if chunks.is_empty() {
412 return Ok((
413 Vec::new(),
414 "0".to_string(),
415 0,
416 WaveAggregateStats::default(),
417 ));
418 }
419
420 let total_chunks = chunks.len();
421 let quote_cap = self.controller().quote.current();
422 let store_cap = self.controller().store.current();
423 debug!(
424 "Batch uploading {total_chunks} chunks in waves of {PAYMENT_WAVE_SIZE} \
425 (current adaptive caps — quote: {quote_cap}, store: {store_cap})"
426 );
427
428 let cached_proofs: HashMap<XorName, Vec<u8>> = match resume_key {
452 Some(key) => match crate::data::client::cached_single::try_load_for_file(key) {
453 Some((_, receipt)) => prune_locally_expired_proofs(key, receipt.proofs),
454 None => HashMap::new(),
455 },
456 None => HashMap::new(),
457 };
458
459 let mut all_addresses = Vec::with_capacity(total_chunks);
460 let mut seen_addresses: HashSet<XorName> = HashSet::new();
461
462 let mut total_storage = Amount::ZERO;
465 let mut total_gas: u128 = 0;
466 let mut agg_stats = WaveAggregateStats::default();
467
468 let mut unique_chunks = Vec::with_capacity(total_chunks);
470 for chunk in chunks {
471 let address = compute_address(&chunk);
472 if seen_addresses.insert(address) {
473 unique_chunks.push(chunk);
474 } else {
475 debug!("Skipping duplicate chunk {}", hex::encode(address));
476 all_addresses.push(address);
477 if let Some(tx) = progress {
478 let _ = tx.try_send(UploadEvent::ChunkStored {
479 stored: stored_offset + all_addresses.len(),
480 total: file_total,
481 });
482 }
483 }
484 }
485
486 let waves: Vec<Vec<Bytes>> = unique_chunks
488 .chunks(PAYMENT_WAVE_SIZE)
489 .map(<[Bytes]>::to_vec)
490 .collect();
491 let wave_count = waves.len();
492
493 debug!(
494 "{total_chunks} chunks -> {} unique -> {wave_count} waves",
495 seen_addresses.len()
496 );
497
498 let mut pending_store: Option<Vec<PaidChunk>> = None;
499 let mut total_quoted: usize = 0;
500
501 for (wave_idx, wave_chunks) in waves.into_iter().enumerate() {
502 let wave_num = wave_idx + 1;
503 let wave_size = wave_chunks.len();
504
505 let (prepare_result, store_result) = match pending_store.take() {
507 Some(paid_chunks) => {
508 let store_offset = stored_offset + all_addresses.len();
509 let quoted_offset = stored_offset + total_quoted;
510 let (prep, stored) = tokio::join!(
511 self.prepare_wave(wave_chunks, progress, quoted_offset, file_total),
512 self.store_paid_chunks_with_events(
513 paid_chunks,
514 progress,
515 store_offset,
516 file_total
517 )
518 );
519 (prep, Some(stored))
520 }
521 None => {
522 let quoted_offset = stored_offset + total_quoted;
523 let result = self
524 .prepare_wave(wave_chunks, progress, quoted_offset, file_total)
525 .await;
526 (result, None)
527 }
528 };
529 total_quoted += wave_size;
530
531 if let Some(wave_result) = store_result {
533 all_addresses.extend(&wave_result.stored);
534 agg_stats.absorb(&wave_result);
535 if !wave_result.failed.is_empty() {
536 let failed_count = wave_result.failed.len();
537 warn!("{failed_count} chunks failed to store after retries");
538 return Err(Error::PartialUpload {
539 stored: all_addresses.clone(),
540 stored_count: stored_offset + all_addresses.len(),
541 failed: wave_result.failed,
542 failed_count,
543 total_chunks: file_total,
544 spend: Box::new(PartialUploadSpend {
545 storage_cost_atto: total_storage.to_string(),
546 gas_cost_wei: total_gas,
547 }),
548 reason: "wave store failed after retries".into(),
549 });
550 }
551 }
552
553 let (prepared_chunks, already_stored) = prepare_result?;
554 all_addresses.extend(&already_stored);
555 if let Some(tx) = progress {
556 for _ in &already_stored {
557 let _ = tx.try_send(UploadEvent::ChunkStored {
558 stored: stored_offset + all_addresses.len(),
559 total: file_total,
560 });
561 }
562 }
563
564 if prepared_chunks.is_empty() {
565 info!("Wave {wave_num}/{wave_count}: all chunks already stored");
566 continue;
567 }
568
569 let mut needs_pay: Vec<PreparedChunk> = Vec::with_capacity(prepared_chunks.len());
574 let mut cached_paid: Vec<PaidChunk> = Vec::new();
575 for prep in prepared_chunks {
576 if let Some(proof_bytes) = cached_proofs.get(&prep.address).cloned() {
577 cached_paid.push(PaidChunk {
578 content: prep.content,
579 address: prep.address,
580 quoted_peers: prep.quoted_peers,
581 proof_bytes,
582 });
583 } else {
584 needs_pay.push(prep);
585 }
586 }
587 if !cached_paid.is_empty() {
588 info!(
589 "Wave {wave_num}/{wave_count}: reusing {} cached payment proofs",
590 cached_paid.len()
591 );
592 }
593
594 let (mut paid_chunks, wave_storage, wave_gas) = if needs_pay.is_empty() {
595 (Vec::new(), "0".to_string(), 0u128)
596 } else {
597 info!(
598 "Wave {wave_num}/{wave_count}: paying for {} chunks",
599 needs_pay.len()
600 );
601 self.batch_pay(needs_pay).await?
602 };
603 if let Ok(cost) = wave_storage.parse::<Amount>() {
604 total_storage += cost;
605 }
606 total_gas = total_gas.saturating_add(wave_gas);
607
608 if let Some(key) = resume_key {
611 if !paid_chunks.is_empty() {
612 let new_proofs: HashMap<[u8; 32], Vec<u8>> = paid_chunks
613 .iter()
614 .map(|pc| (pc.address, pc.proof_bytes.clone()))
615 .collect();
616 crate::data::client::cached_single::try_append_wave(
617 key,
618 new_proofs,
619 &wave_storage,
620 wave_gas,
621 );
622 }
623 }
624
625 paid_chunks.extend(cached_paid);
626 pending_store = Some(paid_chunks);
627 }
628
629 if let Some(paid_chunks) = pending_store {
631 let store_offset = stored_offset + all_addresses.len();
632 let wave_result = self
633 .store_paid_chunks_with_events(paid_chunks, progress, store_offset, file_total)
634 .await;
635 all_addresses.extend(&wave_result.stored);
636 agg_stats.absorb(&wave_result);
637 if !wave_result.failed.is_empty() {
638 let failed_count = wave_result.failed.len();
639 warn!("{failed_count} chunks failed to store after retries (final wave)");
640 return Err(Error::PartialUpload {
641 stored: all_addresses.clone(),
642 stored_count: stored_offset + all_addresses.len(),
643 failed: wave_result.failed,
644 failed_count,
645 total_chunks: file_total,
646 spend: Box::new(PartialUploadSpend {
647 storage_cost_atto: total_storage.to_string(),
648 gas_cost_wei: total_gas,
649 }),
650 reason: "final wave store failed after retries".into(),
651 });
652 }
653 }
654
655 debug!("Batch upload complete: {} addresses", all_addresses.len());
656 Ok((
657 all_addresses,
658 total_storage.to_string(),
659 total_gas,
660 agg_stats,
661 ))
662 }
663
664 async fn prepare_wave(
669 &self,
670 chunks: Vec<Bytes>,
671 progress: Option<&mpsc::Sender<UploadEvent>>,
672 quoted_offset: usize,
673 file_total: usize,
674 ) -> Result<(Vec<PreparedChunk>, Vec<XorName>)> {
675 let chunk_count = chunks.len();
676 let chunks_with_addr: Vec<(Bytes, XorName)> = chunks
677 .into_iter()
678 .map(|c| {
679 let addr = compute_address(&c);
680 (c, addr)
681 })
682 .collect();
683
684 let quote_limiter = self.controller().quote.clone();
685 let quote_concurrency = quote_limiter.current().min(chunk_count.max(1));
690 let mut quote_stream = stream::iter(chunks_with_addr)
691 .map(|(content, address)| {
692 let limiter = quote_limiter.clone();
693 async move {
694 let result = observe_op(
695 &limiter,
696 || async move { self.prepare_chunk_payment(content).await },
697 classify_error,
698 )
699 .await;
700 (address, result)
701 }
702 })
703 .buffer_unordered(quote_concurrency);
704
705 let mut prepared = Vec::with_capacity(chunk_count);
706 let mut already_stored = Vec::new();
707 let mut quoted_count = 0usize;
708
709 while let Some((address, result)) = quote_stream.next().await {
710 let chunk_already_stored = result.as_ref().is_ok_and(|r| r.is_none());
711 match result? {
712 Some(chunk) => prepared.push(chunk),
713 None => already_stored.push(address),
714 }
715 quoted_count += 1;
716 let progress_num = quoted_offset + quoted_count;
717 if file_total > 0 {
718 if chunk_already_stored {
719 info!("Verified {progress_num}/{file_total} (already stored)");
720 } else {
721 info!("Quoted {progress_num}/{file_total}");
722 }
723 }
724 if let Some(tx) = progress {
725 let _ = tx.try_send(UploadEvent::ChunkQuoted {
726 quoted: progress_num,
727 total: file_total,
728 });
729 }
730 }
731
732 Ok((prepared, already_stored))
733 }
734
735 pub(crate) async fn store_paid_chunks_with_events(
747 &self,
748 paid_chunks: Vec<PaidChunk>,
749 progress: Option<&mpsc::Sender<UploadEvent>>,
750 stored_before: usize,
751 total_chunks: usize,
752 ) -> WaveResult {
753 const MAX_RETRIES: u32 = 3;
754 const BASE_DELAY_MS: u64 = 500;
755
756 let mut stored = Vec::new();
757 let mut to_retry = paid_chunks;
758
759 let mut first_seen: HashMap<XorName, Instant> = HashMap::with_capacity(to_retry.len());
763 for chunk in &to_retry {
764 first_seen.entry(chunk.address).or_insert_with(Instant::now);
765 }
766
767 let max_chunk_bytes = to_retry.iter().map(|c| c.content.len()).max().unwrap_or(0);
777 let byte_bound = STORE_INFLIGHT_BYTE_BUDGET
780 .checked_div(max_chunk_bytes)
781 .map_or(usize::MAX, |n| n.max(1));
782
783 let mut chunk_attempts_total: usize = 0;
784 let mut store_durations_ms: Vec<u64> = Vec::new();
785 let mut retries_per_chunk: Vec<u32> = Vec::new();
786
787 for attempt in 0..=MAX_RETRIES {
788 if attempt > 0 {
789 let delay = Duration::from_millis(BASE_DELAY_MS * 2u64.pow(attempt - 1));
790 tokio::time::sleep(delay).await;
791 info!(
792 "Retry attempt {attempt}/{MAX_RETRIES} for {} chunks",
793 to_retry.len()
794 );
795 }
796
797 chunk_attempts_total = chunk_attempts_total.saturating_add(to_retry.len());
799
800 let store_limiter = self.controller().store.clone();
801 let make_store = |chunk: PaidChunk| {
809 let chunk_clone = chunk.clone();
810 let limiter = store_limiter.clone();
811 async move {
812 let result = observe_op(
813 &limiter,
814 || async move {
815 self.chunk_put_to_close_group(
816 chunk.content,
817 chunk.proof_bytes,
818 &chunk.quoted_peers,
819 )
820 .await
821 },
822 classify_error,
823 )
824 .await;
825 (chunk_clone, result)
826 }
827 };
828 let mut chunk_iter = to_retry.into_iter();
829 let mut in_flight = FuturesUnordered::new();
830
831 let mut failed_this_round = Vec::new();
832 loop {
833 let slots = store_limiter.current().min(byte_bound).max(1);
834 while in_flight.len() < slots {
835 match chunk_iter.next() {
836 Some(chunk) => in_flight.push(make_store(chunk)),
837 None => break,
838 }
839 }
840 let Some((chunk, result)) = in_flight.next().await else {
841 break;
842 };
843 match result {
844 Ok(name) => {
845 let duration_ms = first_seen
846 .get(&chunk.address)
847 .map(|t| u64::try_from(t.elapsed().as_millis()).unwrap_or(u64::MAX))
848 .unwrap_or(0);
849 store_durations_ms.push(duration_ms);
850 retries_per_chunk.push(attempt);
851 stored.push(name);
852 let stored_num = stored_before + stored.len();
853 if total_chunks > 0 {
854 info!("Stored {stored_num}/{total_chunks}");
855 }
856 if let Some(tx) = progress {
857 let _ = tx.try_send(UploadEvent::ChunkStored {
858 stored: stored_num,
859 total: total_chunks,
860 });
861 }
862 }
863 Err(e) => failed_this_round.push((chunk, e.to_string())),
864 }
865 }
866
867 if failed_this_round.is_empty() {
868 let result = WaveResult {
869 stored,
870 failed: Vec::new(),
871 chunk_attempts_total,
872 store_durations_ms,
873 retries_per_chunk,
874 };
875 log_wave_summary(&result);
876 return result;
877 }
878
879 if attempt == MAX_RETRIES {
880 let failed = failed_this_round
881 .into_iter()
882 .map(|(c, e)| (c.address, e))
883 .collect();
884 let result = WaveResult {
885 stored,
886 failed,
887 chunk_attempts_total,
888 store_durations_ms,
889 retries_per_chunk,
890 };
891 log_wave_summary(&result);
892 return result;
893 }
894
895 warn!(
896 "{} chunks failed on attempt {}, will retry",
897 failed_this_round.len(),
898 attempt + 1
899 );
900 to_retry = failed_this_round.into_iter().map(|(c, _)| c).collect();
901 }
902
903 let result = WaveResult {
905 stored,
906 failed: Vec::new(),
907 chunk_attempts_total,
908 store_durations_ms,
909 retries_per_chunk,
910 };
911 log_wave_summary(&result);
912 result
913 }
914}
915
916fn log_wave_summary(result: &WaveResult) {
922 let retries_round_1 = result.retries_per_chunk.iter().filter(|&&r| r == 1).count();
923 let retries_round_2 = result.retries_per_chunk.iter().filter(|&&r| r == 2).count();
924 let retries_round_3 = result.retries_per_chunk.iter().filter(|&&r| r == 3).count();
925 let chunk_attempts_total = result.chunk_attempts_total;
926 info!(
927 chunks_stored = result.stored.len(),
928 chunks_failed = result.failed.len(),
929 chunk_attempts_total,
930 retries_round_1,
931 retries_round_2,
932 retries_round_3,
933 store_duration_p50_ms = percentile(&result.store_durations_ms, 0.50),
934 store_duration_p95_ms = percentile(&result.store_durations_ms, 0.95),
935 store_duration_max_ms = result.store_durations_ms.iter().max().copied().unwrap_or(0),
936 "chunk_store_wave_complete"
937 );
938}
939
940const CACHED_PROOF_SAFETY_MARGIN_SECS: u64 = 300;
952
953const CACHED_PROOF_MAX_AGE_SECS: u64 = 24 * 60 * 60;
960
961const CACHED_PROOF_FUTURE_SKEW_TOLERANCE_SECS: u64 = 300;
972
973fn prune_locally_expired_proofs(
997 resume_key: &str,
998 proofs: HashMap<[u8; 32], Vec<u8>>,
999) -> HashMap<XorName, Vec<u8>> {
1000 let now = std::time::SystemTime::now();
1001 let max_safe_age = Duration::from_secs(
1002 CACHED_PROOF_MAX_AGE_SECS.saturating_sub(CACHED_PROOF_SAFETY_MARGIN_SECS),
1003 );
1004 let max_future_skew = Duration::from_secs(CACHED_PROOF_FUTURE_SKEW_TOLERANCE_SECS);
1005 let mut kept: HashMap<XorName, Vec<u8>> = HashMap::with_capacity(proofs.len());
1006 let mut expired: Vec<([u8; 32], Vec<u8>)> = Vec::new();
1012 for (addr, bytes) in proofs {
1013 match deserialize_proof(&bytes) {
1014 Ok((proof, _tx_hashes)) => {
1015 if proof_is_safely_fresh(&proof, now, max_safe_age, max_future_skew) {
1016 kept.insert(addr, bytes);
1017 } else {
1018 expired.push((addr, bytes));
1019 }
1020 }
1021 Err(_) => {
1022 expired.push((addr, bytes));
1025 }
1026 }
1027 }
1028 if !expired.is_empty() {
1029 info!(
1030 "Pruning {} stale cached proofs (quote.timestamp past safe-reuse window) \
1031 before resume",
1032 expired.len()
1033 );
1034 crate::data::client::cached_single::try_drop_proofs_for_file(resume_key, &expired);
1035 }
1036 kept
1037}
1038
1039fn proof_is_safely_fresh(
1046 proof: &ProofOfPayment,
1047 now: std::time::SystemTime,
1048 max_safe_age: Duration,
1049 max_future_skew: Duration,
1050) -> bool {
1051 for (_peer, quote) in &proof.peer_quotes {
1052 match now.duration_since(quote.timestamp) {
1053 Ok(age) => {
1054 if age > max_safe_age {
1055 return false;
1056 }
1057 }
1058 Err(future) => {
1059 if future.duration() > max_future_skew {
1060 return false;
1061 }
1062 }
1063 }
1064 }
1065 true
1066}
1067
1068#[cfg(test)]
1070mod send_assertions {
1071 use super::*;
1072
1073 fn _assert_send<T: Send>(_: &T) {}
1074
1075 #[allow(dead_code)]
1076 async fn _batch_upload_is_send(client: &Client) {
1077 let fut = client.batch_upload_chunks(Vec::new());
1078 _assert_send(&fut);
1079 }
1080}
1081
1082#[cfg(test)]
1083#[allow(clippy::unwrap_used)]
1084mod tests {
1085 use super::*;
1086 use ant_protocol::payment::QuotePaymentInfo;
1087 use ant_protocol::CLOSE_GROUP_SIZE;
1088
1089 const MEDIAN_INDEX: usize = CLOSE_GROUP_SIZE / 2;
1091
1092 fn make_prepared_chunk(median_amount: u64) -> PreparedChunk {
1096 let quotes: [QuotePaymentInfo; CLOSE_GROUP_SIZE] = std::array::from_fn(|i| {
1097 let amount = if i == MEDIAN_INDEX { median_amount } else { 0 };
1098 QuotePaymentInfo {
1099 quote_hash: QuoteHash::from([i as u8 + 1; 32]),
1100 rewards_address: RewardsAddress::new([i as u8 + 10; 20]),
1101 amount: Amount::from(amount),
1102 price: Amount::from(amount),
1103 }
1104 });
1105
1106 PreparedChunk {
1107 content: Bytes::from(vec![0xAA; 32]),
1108 address: [0u8; 32],
1109 quoted_peers: Vec::new(),
1110 payment: SingleNodePayment { quotes },
1111 peer_quotes: Vec::new(),
1112 commitment_sidecars: Vec::new(),
1113 }
1114 }
1115
1116 #[test]
1117 fn payment_intent_from_single_chunk() {
1118 let chunk = make_prepared_chunk(300);
1119 let intent = PaymentIntent::from_prepared_chunks(&[chunk]);
1120
1121 assert_eq!(intent.payments.len(), 1, "only non-zero amounts");
1122 assert_eq!(intent.total_amount, Amount::from(300));
1123
1124 let (hash, addr, amt) = &intent.payments[0];
1125 assert_eq!(*hash, QuoteHash::from([MEDIAN_INDEX as u8 + 1; 32]));
1126 assert_eq!(*addr, RewardsAddress::new([MEDIAN_INDEX as u8 + 10; 20]));
1127 assert_eq!(*amt, Amount::from(300));
1128 }
1129
1130 #[test]
1131 fn payment_intent_from_multiple_chunks() {
1132 let c1 = make_prepared_chunk(100);
1133 let c2 = make_prepared_chunk(250);
1134 let intent = PaymentIntent::from_prepared_chunks(&[c1, c2]);
1135
1136 assert_eq!(intent.payments.len(), 2);
1137 assert_eq!(intent.total_amount, Amount::from(350));
1138 }
1139
1140 #[test]
1141 fn payment_intent_skips_all_zero_chunks() {
1142 let chunk = make_prepared_chunk(0);
1143 let intent = PaymentIntent::from_prepared_chunks(&[chunk]);
1144
1145 assert!(intent.payments.is_empty());
1146 assert_eq!(intent.total_amount, Amount::ZERO);
1147 }
1148
1149 #[test]
1150 fn payment_intent_empty_input() {
1151 let intent = PaymentIntent::from_prepared_chunks(&[]);
1152 assert!(intent.payments.is_empty());
1153 assert_eq!(intent.total_amount, Amount::ZERO);
1154 }
1155
1156 #[test]
1157 fn finalize_batch_payment_builds_proofs() {
1158 let chunk = make_prepared_chunk(500);
1159 let quote_hash = chunk.payment.quotes[MEDIAN_INDEX].quote_hash;
1160
1161 let mut tx_map = HashMap::new();
1162 tx_map.insert(quote_hash, TxHash::from([0xBB; 32]));
1163
1164 let paid = finalize_batch_payment(vec![chunk], &tx_map).unwrap();
1165
1166 assert_eq!(paid.len(), 1);
1167 assert!(!paid[0].proof_bytes.is_empty());
1168 assert_eq!(paid[0].address, [0u8; 32]);
1169 }
1170
1171 #[test]
1172 fn finalize_batch_payment_empty_input() {
1173 let paid = finalize_batch_payment(vec![], &HashMap::new()).unwrap();
1174 assert!(paid.is_empty());
1175 }
1176
1177 #[test]
1178 fn finalize_batch_payment_missing_tx_hash_errors() {
1179 let chunk = make_prepared_chunk(500);
1182
1183 let result = finalize_batch_payment(vec![chunk], &HashMap::new());
1184 assert!(result.is_err());
1185 let err = result.unwrap_err().to_string();
1186 assert!(err.contains("Missing tx hash"), "got: {err}");
1187 }
1188
1189 #[test]
1190 fn finalize_batch_payment_multiple_chunks() {
1191 let c1 = make_prepared_chunk(100);
1192 let c2 = make_prepared_chunk(200);
1193 let q1 = c1.payment.quotes[MEDIAN_INDEX].quote_hash;
1194 let mut tx_map = HashMap::new();
1195 tx_map.insert(q1, TxHash::from([0xCC; 32]));
1198
1199 let paid = finalize_batch_payment(vec![c1, c2], &tx_map).unwrap();
1200 assert_eq!(paid.len(), 2);
1201 }
1202
1203 fn make_proof_with_timestamps(timestamps: &[std::time::SystemTime]) -> ProofOfPayment {
1213 let peer_quotes = timestamps
1214 .iter()
1215 .enumerate()
1216 .map(|(i, ts)| {
1217 let quote = PaymentQuote {
1218 content: xor_name::XorName([0u8; 32]),
1219 timestamp: *ts,
1220 price: Amount::from(1u64),
1221 rewards_address: RewardsAddress::new([1u8; 20]),
1222 pub_key: vec![],
1223 signature: vec![],
1224 committed_key_count: 0,
1225 commitment_pin: None,
1226 };
1227 (EncodedPeerId::from([i as u8; 32]), quote)
1228 })
1229 .collect();
1230 ProofOfPayment { peer_quotes }
1231 }
1232
1233 fn default_max_future_skew() -> Duration {
1234 Duration::from_secs(CACHED_PROOF_FUTURE_SKEW_TOLERANCE_SECS)
1235 }
1236
1237 #[test]
1238 fn proof_is_safely_fresh_accepts_recent_quote() {
1239 let proof = make_proof_with_timestamps(&[std::time::SystemTime::now()]);
1240 assert!(proof_is_safely_fresh(
1241 &proof,
1242 std::time::SystemTime::now(),
1243 Duration::from_secs(CACHED_PROOF_MAX_AGE_SECS),
1244 default_max_future_skew(),
1245 ));
1246 }
1247
1248 #[test]
1249 fn proof_is_safely_fresh_rejects_quote_past_safe_window() {
1250 let too_old = std::time::SystemTime::now() - Duration::from_secs(23 * 60 * 60 + 57 * 60);
1255 let proof = make_proof_with_timestamps(&[too_old]);
1256 let max_safe = Duration::from_secs(
1257 CACHED_PROOF_MAX_AGE_SECS.saturating_sub(CACHED_PROOF_SAFETY_MARGIN_SECS),
1258 );
1259 assert!(
1260 !proof_is_safely_fresh(
1261 &proof,
1262 std::time::SystemTime::now(),
1263 max_safe,
1264 default_max_future_skew(),
1265 ),
1266 "23h57m-old quote must fail safe-reuse check (limit is 24h - 5min margin)"
1267 );
1268 }
1269
1270 #[test]
1271 fn proof_is_safely_fresh_rejects_if_any_quote_is_stale() {
1272 let now = std::time::SystemTime::now();
1275 let fresh = now;
1276 let stale = now - Duration::from_secs(CACHED_PROOF_MAX_AGE_SECS);
1277 let proof = make_proof_with_timestamps(&[fresh, fresh, stale, fresh]);
1278 let max_safe = Duration::from_secs(
1279 CACHED_PROOF_MAX_AGE_SECS.saturating_sub(CACHED_PROOF_SAFETY_MARGIN_SECS),
1280 );
1281 assert!(!proof_is_safely_fresh(
1282 &proof,
1283 now,
1284 max_safe,
1285 default_max_future_skew(),
1286 ));
1287 }
1288
1289 #[test]
1290 fn proof_is_safely_fresh_accepts_slight_future_skew_within_node_tolerance() {
1291 let now = std::time::SystemTime::now();
1296 let slight_future = now + Duration::from_secs(60);
1297 let proof = make_proof_with_timestamps(&[slight_future]);
1298 let max_safe = Duration::from_secs(CACHED_PROOF_MAX_AGE_SECS);
1299 assert!(
1300 proof_is_safely_fresh(&proof, now, max_safe, default_max_future_skew()),
1301 "60s-future quote must be accepted (within node's 300s skew tolerance)"
1302 );
1303 }
1304
1305 #[test]
1306 fn proof_is_safely_fresh_rejects_far_future_dated_quote() {
1307 let now = std::time::SystemTime::now();
1311 let far_future = now + Duration::from_secs(3600);
1312 let proof = make_proof_with_timestamps(&[far_future]);
1313 let max_safe = Duration::from_secs(CACHED_PROOF_MAX_AGE_SECS);
1314 assert!(!proof_is_safely_fresh(
1315 &proof,
1316 now,
1317 max_safe,
1318 default_max_future_skew(),
1319 ));
1320 }
1321
1322 #[test]
1323 fn proof_is_safely_fresh_empty_quotes_is_vacuously_safe() {
1324 let proof = make_proof_with_timestamps(&[]);
1329 assert!(proof_is_safely_fresh(
1330 &proof,
1331 std::time::SystemTime::now(),
1332 Duration::from_secs(CACHED_PROOF_MAX_AGE_SECS),
1333 default_max_future_skew(),
1334 ));
1335 }
1336}