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, 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
29const PAYMENT_WAVE_SIZE: usize = 64;
31
32const STORE_INFLIGHT_BYTE_BUDGET: usize = 64 * 1024 * 1024;
38
39#[derive(Debug, Clone)]
47pub struct SingleNodeQuotePayment {
48 pub quotes: Vec<QuotePaymentInfo>,
51}
52
53impl SingleNodeQuotePayment {
54 pub fn from_quotes(mut quotes: Vec<PaymentQuote>) -> Result<Self> {
60 let quote_count = quotes.len();
61 if !(1..=CLOSE_GROUP_SIZE).contains("e_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 #[must_use]
99 pub fn total_amount(&self) -> Amount {
100 self.quotes.iter().map(|q| q.amount).sum()
101 }
102
103 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("e_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#[derive(Debug)]
139pub struct PreparedChunk {
140 pub content: Bytes,
142 pub address: XorName,
144 pub quoted_peers: Vec<(PeerId, Vec<MultiAddr>)>,
150 pub payment: SingleNodeQuotePayment,
152 pub peer_quotes: Vec<(EncodedPeerId, PaymentQuote)>,
154 pub commitment_sidecars: Vec<Vec<u8>>,
158}
159
160#[derive(Debug, Clone)]
162pub struct PaidChunk {
163 pub content: Bytes,
165 pub address: XorName,
167 pub quoted_peers: Vec<(PeerId, Vec<MultiAddr>)>,
173 pub proof_bytes: Vec<u8>,
175}
176
177#[derive(Debug)]
179pub struct WaveResult {
180 pub stored: Vec<XorName>,
182 pub failed: Vec<(XorName, String)>,
184 pub chunk_attempts_total: usize,
186 pub store_durations_ms: Vec<u64>,
188 pub retries_per_chunk: Vec<u32>,
190}
191
192#[derive(Debug, Default, Clone)]
199pub struct WaveAggregateStats {
200 pub chunk_attempts_total: usize,
202 pub store_durations_ms: Vec<u64>,
205 pub retries_histogram: [usize; 4],
210}
211
212impl WaveAggregateStats {
213 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
226fn 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 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#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
256pub struct PaymentIntent {
257 pub payments: Vec<(QuoteHash, RewardsAddress, Amount)>,
259 pub total_amount: Amount,
261}
262
263impl PaymentIntent {
264 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
285fn 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 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
333pub 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 pub async fn prepare_chunk_payment(&self, content: Bytes) -> Result<Option<PreparedChunk>> {
355 let address = compute_address(&content);
356 let data_size = u64::try_from(content.len())
357 .map_err(|e| Error::InvalidData(format!("content size too large: {e}")))?;
358
359 let quote_plan = match self
360 .get_store_quote_plan(&address, data_size, DATA_TYPE_CHUNK)
361 .await
362 {
363 Ok(plan) => plan,
364 Err(Error::AlreadyStored) => {
365 debug!("Chunk {} already stored, skipping", hex::encode(address));
366 return Ok(None);
367 }
368 Err(e) => return Err(e),
369 };
370 let quotes_with_peers = quote_plan.quotes;
371
372 let quoted_peers = quote_plan.put_peers;
375
376 let mut peer_quotes = Vec::with_capacity(quotes_with_peers.len());
379 let mut quotes_for_payment = Vec::with_capacity(quotes_with_peers.len());
380 let mut commitment_sidecars = Vec::new();
383
384 for (peer_id, _addrs, quote, _price, commitment) in quotes_with_peers {
385 let encoded = peer_id_to_encoded(&peer_id)?;
386 peer_quotes.push((encoded, quote.clone()));
387 quotes_for_payment.push(quote);
388 if let Some(sidecar) = commitment {
389 commitment_sidecars.push(sidecar);
390 }
391 }
392
393 let payment = SingleNodeQuotePayment::from_quotes(quotes_for_payment)
394 .map_err(|e| Error::Payment(format!("Failed to create payment: {e}")))?;
395
396 Ok(Some(PreparedChunk {
397 content,
398 address,
399 quoted_peers,
400 payment,
401 peer_quotes,
402 commitment_sidecars,
403 }))
404 }
405
406 pub async fn batch_pay(
418 &self,
419 prepared: Vec<PreparedChunk>,
420 ) -> Result<(Vec<PaidChunk>, String, u128)> {
421 if prepared.is_empty() {
422 return Ok((Vec::new(), "0".to_string(), 0));
423 }
424
425 let wallet = self.require_wallet()?;
426
427 let intent = PaymentIntent::from_prepared_chunks(&prepared);
429 let storage_cost_atto = intent.total_amount.to_string();
430
431 let total_quotes: usize = prepared.iter().map(|c| c.payment.quotes.len()).sum();
433 let mut all_payments = Vec::with_capacity(total_quotes);
434 for chunk in &prepared {
435 for info in &chunk.payment.quotes {
436 all_payments.push((info.quote_hash, info.rewards_address, info.amount));
437 }
438 }
439
440 debug!(
441 "Batch payment for {} chunks ({} quote entries)",
442 prepared.len(),
443 all_payments.len()
444 );
445
446 let (tx_hash_map, gas_info) =
447 wallet
448 .pay_for_quotes(all_payments)
449 .await
450 .map_err(|PayForQuotesError(err, _)| {
451 Error::Payment(format!("Batch payment failed: {err}"))
452 })?;
453
454 info!(
455 "Batch payment succeeded: {} transactions",
456 tx_hash_map.len()
457 );
458
459 let tx_hash_map: HashMap<QuoteHash, TxHash> = tx_hash_map.into_iter().collect();
460 let paid_chunks = build_paid_chunks(prepared, &tx_hash_map)?;
461 Ok((paid_chunks, storage_cost_atto, gas_info.gas_cost_wei))
462 }
463
464 pub async fn batch_upload_chunks(
479 &self,
480 chunks: Vec<Bytes>,
481 ) -> Result<(Vec<XorName>, String, u128)> {
482 let (addresses, storage, gas, _stats) = self
483 .batch_upload_chunks_with_events(chunks, None, 0, 0, None)
484 .await?;
485 Ok((addresses, storage, gas))
486 }
487
488 pub async fn batch_upload_chunks_with_events(
502 &self,
503 chunks: Vec<Bytes>,
504 progress: Option<&mpsc::Sender<UploadEvent>>,
505 stored_offset: usize,
506 file_total: usize,
507 resume_key: Option<&str>,
508 ) -> Result<(Vec<XorName>, String, u128, WaveAggregateStats)> {
509 if chunks.is_empty() {
510 return Ok((
511 Vec::new(),
512 "0".to_string(),
513 0,
514 WaveAggregateStats::default(),
515 ));
516 }
517
518 let total_chunks = chunks.len();
519 let quote_cap = self.controller().quote.current();
520 let store_cap = self.controller().store.current();
521 debug!(
522 "Batch uploading {total_chunks} chunks in waves of {PAYMENT_WAVE_SIZE} \
523 (current adaptive caps — quote: {quote_cap}, store: {store_cap})"
524 );
525
526 let cached_proofs: HashMap<XorName, Vec<u8>> = match resume_key {
550 Some(key) => match crate::data::client::cached_single::try_load_for_file(key) {
551 Some((_, receipt)) => prune_locally_expired_proofs(key, receipt.proofs),
552 None => HashMap::new(),
553 },
554 None => HashMap::new(),
555 };
556
557 let mut all_addresses = Vec::with_capacity(total_chunks);
558 let mut seen_addresses: HashSet<XorName> = HashSet::new();
559
560 let mut total_storage = Amount::ZERO;
563 let mut total_gas: u128 = 0;
564 let mut agg_stats = WaveAggregateStats::default();
565
566 let mut unique_chunks = Vec::with_capacity(total_chunks);
568 for chunk in chunks {
569 let address = compute_address(&chunk);
570 if seen_addresses.insert(address) {
571 unique_chunks.push(chunk);
572 } else {
573 debug!("Skipping duplicate chunk {}", hex::encode(address));
574 all_addresses.push(address);
575 if let Some(tx) = progress {
576 let _ = tx.try_send(UploadEvent::ChunkStored {
577 stored: stored_offset + all_addresses.len(),
578 total: file_total,
579 });
580 }
581 }
582 }
583
584 let waves: Vec<Vec<Bytes>> = unique_chunks
586 .chunks(PAYMENT_WAVE_SIZE)
587 .map(<[Bytes]>::to_vec)
588 .collect();
589 let wave_count = waves.len();
590
591 debug!(
592 "{total_chunks} chunks -> {} unique -> {wave_count} waves",
593 seen_addresses.len()
594 );
595
596 let mut pending_store: Option<Vec<PaidChunk>> = None;
597 let mut total_quoted: usize = 0;
598
599 for (wave_idx, wave_chunks) in waves.into_iter().enumerate() {
600 let wave_num = wave_idx + 1;
601 let wave_size = wave_chunks.len();
602
603 let (prepare_result, store_result) = match pending_store.take() {
605 Some(paid_chunks) => {
606 let store_offset = stored_offset + all_addresses.len();
607 let quoted_offset = stored_offset + total_quoted;
608 let (prep, stored) = tokio::join!(
609 self.prepare_wave(wave_chunks, progress, quoted_offset, file_total),
610 self.store_paid_chunks_with_events(
611 paid_chunks,
612 progress,
613 store_offset,
614 file_total
615 )
616 );
617 (prep, Some(stored))
618 }
619 None => {
620 let quoted_offset = stored_offset + total_quoted;
621 let result = self
622 .prepare_wave(wave_chunks, progress, quoted_offset, file_total)
623 .await;
624 (result, None)
625 }
626 };
627 total_quoted += wave_size;
628
629 if let Some(wave_result) = store_result {
631 all_addresses.extend(&wave_result.stored);
632 agg_stats.absorb(&wave_result);
633 if !wave_result.failed.is_empty() {
634 let failed_count = wave_result.failed.len();
635 warn!("{failed_count} chunks failed to store after retries");
636 return Err(Error::PartialUpload {
637 stored: all_addresses.clone(),
638 stored_count: stored_offset + all_addresses.len(),
639 failed: wave_result.failed,
640 failed_count,
641 total_chunks: file_total,
642 spend: Box::new(PartialUploadSpend {
643 storage_cost_atto: total_storage.to_string(),
644 gas_cost_wei: total_gas,
645 }),
646 reason: "wave store failed after retries".into(),
647 });
648 }
649 }
650
651 let (prepared_chunks, already_stored) = prepare_result?;
652 all_addresses.extend(&already_stored);
653 if let Some(tx) = progress {
654 for _ in &already_stored {
655 let _ = tx.try_send(UploadEvent::ChunkStored {
656 stored: stored_offset + all_addresses.len(),
657 total: file_total,
658 });
659 }
660 }
661
662 if prepared_chunks.is_empty() {
663 info!("Wave {wave_num}/{wave_count}: all chunks already stored");
664 continue;
665 }
666
667 let mut needs_pay: Vec<PreparedChunk> = Vec::with_capacity(prepared_chunks.len());
672 let mut cached_paid: Vec<PaidChunk> = Vec::new();
673 for prep in prepared_chunks {
674 if let Some(proof_bytes) = cached_proofs.get(&prep.address).cloned() {
675 cached_paid.push(PaidChunk {
676 content: prep.content,
677 address: prep.address,
678 quoted_peers: prep.quoted_peers,
679 proof_bytes,
680 });
681 } else {
682 needs_pay.push(prep);
683 }
684 }
685 if !cached_paid.is_empty() {
686 info!(
687 "Wave {wave_num}/{wave_count}: reusing {} cached payment proofs",
688 cached_paid.len()
689 );
690 }
691
692 let (mut paid_chunks, wave_storage, wave_gas) = if needs_pay.is_empty() {
693 (Vec::new(), "0".to_string(), 0u128)
694 } else {
695 info!(
696 "Wave {wave_num}/{wave_count}: paying for {} chunks",
697 needs_pay.len()
698 );
699 self.batch_pay(needs_pay).await?
700 };
701 if let Ok(cost) = wave_storage.parse::<Amount>() {
702 total_storage += cost;
703 }
704 total_gas = total_gas.saturating_add(wave_gas);
705
706 if let Some(key) = resume_key {
709 if !paid_chunks.is_empty() {
710 let new_proofs: HashMap<[u8; 32], Vec<u8>> = paid_chunks
711 .iter()
712 .map(|pc| (pc.address, pc.proof_bytes.clone()))
713 .collect();
714 crate::data::client::cached_single::try_append_wave(
715 key,
716 new_proofs,
717 &wave_storage,
718 wave_gas,
719 );
720 }
721 }
722
723 paid_chunks.extend(cached_paid);
724 pending_store = Some(paid_chunks);
725 }
726
727 if let Some(paid_chunks) = pending_store {
729 let store_offset = stored_offset + all_addresses.len();
730 let wave_result = self
731 .store_paid_chunks_with_events(paid_chunks, progress, store_offset, file_total)
732 .await;
733 all_addresses.extend(&wave_result.stored);
734 agg_stats.absorb(&wave_result);
735 if !wave_result.failed.is_empty() {
736 let failed_count = wave_result.failed.len();
737 warn!("{failed_count} chunks failed to store after retries (final wave)");
738 return Err(Error::PartialUpload {
739 stored: all_addresses.clone(),
740 stored_count: stored_offset + all_addresses.len(),
741 failed: wave_result.failed,
742 failed_count,
743 total_chunks: file_total,
744 spend: Box::new(PartialUploadSpend {
745 storage_cost_atto: total_storage.to_string(),
746 gas_cost_wei: total_gas,
747 }),
748 reason: "final wave store failed after retries".into(),
749 });
750 }
751 }
752
753 debug!("Batch upload complete: {} addresses", all_addresses.len());
754 Ok((
755 all_addresses,
756 total_storage.to_string(),
757 total_gas,
758 agg_stats,
759 ))
760 }
761
762 async fn prepare_wave(
767 &self,
768 chunks: Vec<Bytes>,
769 progress: Option<&mpsc::Sender<UploadEvent>>,
770 quoted_offset: usize,
771 file_total: usize,
772 ) -> Result<(Vec<PreparedChunk>, Vec<XorName>)> {
773 let chunk_count = chunks.len();
774 let chunks_with_addr: Vec<(Bytes, XorName)> = chunks
775 .into_iter()
776 .map(|c| {
777 let addr = compute_address(&c);
778 (c, addr)
779 })
780 .collect();
781
782 let quote_limiter = self.controller().quote.clone();
783 let quote_concurrency = quote_limiter.current().min(chunk_count.max(1));
788 let mut quote_stream = stream::iter(chunks_with_addr)
789 .map(|(content, address)| {
790 let limiter = quote_limiter.clone();
791 async move {
792 let result = observe_op(
793 &limiter,
794 || async move { self.prepare_chunk_payment(content).await },
795 classify_error,
796 )
797 .await;
798 (address, result)
799 }
800 })
801 .buffer_unordered(quote_concurrency);
802
803 let mut prepared = Vec::with_capacity(chunk_count);
804 let mut already_stored = Vec::new();
805 let mut quoted_count = 0usize;
806
807 while let Some((address, result)) = quote_stream.next().await {
808 let chunk_already_stored = result.as_ref().is_ok_and(|r| r.is_none());
809 match result? {
810 Some(chunk) => prepared.push(chunk),
811 None => already_stored.push(address),
812 }
813 quoted_count += 1;
814 let progress_num = quoted_offset + quoted_count;
815 if file_total > 0 {
816 if chunk_already_stored {
817 info!("Verified {progress_num}/{file_total} (already stored)");
818 } else {
819 info!("Quoted {progress_num}/{file_total}");
820 }
821 }
822 if let Some(tx) = progress {
823 let _ = tx.try_send(UploadEvent::ChunkQuoted {
824 quoted: progress_num,
825 total: file_total,
826 });
827 }
828 }
829
830 Ok((prepared, already_stored))
831 }
832
833 pub(crate) async fn store_paid_chunks_with_events(
845 &self,
846 paid_chunks: Vec<PaidChunk>,
847 progress: Option<&mpsc::Sender<UploadEvent>>,
848 stored_before: usize,
849 total_chunks: usize,
850 ) -> WaveResult {
851 const MAX_RETRIES: u32 = 3;
852 const BASE_DELAY_MS: u64 = 500;
853
854 let mut stored = Vec::new();
855 let mut to_retry = paid_chunks;
856
857 let mut first_seen: HashMap<XorName, Instant> = HashMap::with_capacity(to_retry.len());
861 for chunk in &to_retry {
862 first_seen.entry(chunk.address).or_insert_with(Instant::now);
863 }
864
865 let max_chunk_bytes = to_retry.iter().map(|c| c.content.len()).max().unwrap_or(0);
875 let byte_bound = STORE_INFLIGHT_BYTE_BUDGET
878 .checked_div(max_chunk_bytes)
879 .map_or(usize::MAX, |n| n.max(1));
880
881 let mut chunk_attempts_total: usize = 0;
882 let mut store_durations_ms: Vec<u64> = Vec::new();
883 let mut retries_per_chunk: Vec<u32> = Vec::new();
884
885 for attempt in 0..=MAX_RETRIES {
886 if attempt > 0 {
887 let delay = Duration::from_millis(BASE_DELAY_MS * 2u64.pow(attempt - 1));
888 tokio::time::sleep(delay).await;
889 info!(
890 "Retry attempt {attempt}/{MAX_RETRIES} for {} chunks",
891 to_retry.len()
892 );
893 }
894
895 chunk_attempts_total = chunk_attempts_total.saturating_add(to_retry.len());
897
898 let store_limiter = self.controller().store.clone();
899 let make_store = |chunk: PaidChunk| {
907 let chunk_clone = chunk.clone();
908 let limiter = store_limiter.clone();
909 async move {
910 let result = observe_op(
911 &limiter,
912 || async move {
913 self.chunk_put_to_close_group(
914 chunk.content,
915 chunk.proof_bytes,
916 &chunk.quoted_peers,
917 )
918 .await
919 },
920 classify_error,
921 )
922 .await;
923 (chunk_clone, result)
924 }
925 };
926 let mut chunk_iter = to_retry.into_iter();
927 let mut in_flight = FuturesUnordered::new();
928
929 let mut failed_this_round = Vec::new();
930 loop {
931 let slots = store_limiter.current().min(byte_bound).max(1);
932 while in_flight.len() < slots {
933 match chunk_iter.next() {
934 Some(chunk) => in_flight.push(make_store(chunk)),
935 None => break,
936 }
937 }
938 let Some((chunk, result)) = in_flight.next().await else {
939 break;
940 };
941 match result {
942 Ok(name) => {
943 let duration_ms = first_seen
944 .get(&chunk.address)
945 .map(|t| u64::try_from(t.elapsed().as_millis()).unwrap_or(u64::MAX))
946 .unwrap_or(0);
947 store_durations_ms.push(duration_ms);
948 retries_per_chunk.push(attempt);
949 stored.push(name);
950 let stored_num = stored_before + stored.len();
951 if total_chunks > 0 {
952 info!("Stored {stored_num}/{total_chunks}");
953 }
954 if let Some(tx) = progress {
955 let _ = tx.try_send(UploadEvent::ChunkStored {
956 stored: stored_num,
957 total: total_chunks,
958 });
959 }
960 }
961 Err(e) => failed_this_round.push((chunk, e.to_string())),
962 }
963 }
964
965 if failed_this_round.is_empty() {
966 let result = WaveResult {
967 stored,
968 failed: Vec::new(),
969 chunk_attempts_total,
970 store_durations_ms,
971 retries_per_chunk,
972 };
973 log_wave_summary(&result);
974 return result;
975 }
976
977 if attempt == MAX_RETRIES {
978 let failed = failed_this_round
979 .into_iter()
980 .map(|(c, e)| (c.address, e))
981 .collect();
982 let result = WaveResult {
983 stored,
984 failed,
985 chunk_attempts_total,
986 store_durations_ms,
987 retries_per_chunk,
988 };
989 log_wave_summary(&result);
990 return result;
991 }
992
993 warn!(
994 "{} chunks failed on attempt {}, will retry",
995 failed_this_round.len(),
996 attempt + 1
997 );
998 to_retry = failed_this_round.into_iter().map(|(c, _)| c).collect();
999 }
1000
1001 let result = WaveResult {
1003 stored,
1004 failed: Vec::new(),
1005 chunk_attempts_total,
1006 store_durations_ms,
1007 retries_per_chunk,
1008 };
1009 log_wave_summary(&result);
1010 result
1011 }
1012}
1013
1014fn log_wave_summary(result: &WaveResult) {
1020 let retries_round_1 = result.retries_per_chunk.iter().filter(|&&r| r == 1).count();
1021 let retries_round_2 = result.retries_per_chunk.iter().filter(|&&r| r == 2).count();
1022 let retries_round_3 = result.retries_per_chunk.iter().filter(|&&r| r == 3).count();
1023 let chunk_attempts_total = result.chunk_attempts_total;
1024 info!(
1025 chunks_stored = result.stored.len(),
1026 chunks_failed = result.failed.len(),
1027 chunk_attempts_total,
1028 retries_round_1,
1029 retries_round_2,
1030 retries_round_3,
1031 store_duration_p50_ms = percentile(&result.store_durations_ms, 0.50),
1032 store_duration_p95_ms = percentile(&result.store_durations_ms, 0.95),
1033 store_duration_max_ms = result.store_durations_ms.iter().max().copied().unwrap_or(0),
1034 "chunk_store_wave_complete"
1035 );
1036}
1037
1038const CACHED_PROOF_SAFETY_MARGIN_SECS: u64 = 300;
1050
1051const CACHED_PROOF_MAX_AGE_SECS: u64 = 24 * 60 * 60;
1058
1059const CACHED_PROOF_FUTURE_SKEW_TOLERANCE_SECS: u64 = 300;
1070
1071fn prune_locally_expired_proofs(
1095 resume_key: &str,
1096 proofs: HashMap<[u8; 32], Vec<u8>>,
1097) -> HashMap<XorName, Vec<u8>> {
1098 let now = std::time::SystemTime::now();
1099 let max_safe_age = Duration::from_secs(
1100 CACHED_PROOF_MAX_AGE_SECS.saturating_sub(CACHED_PROOF_SAFETY_MARGIN_SECS),
1101 );
1102 let max_future_skew = Duration::from_secs(CACHED_PROOF_FUTURE_SKEW_TOLERANCE_SECS);
1103 let mut kept: HashMap<XorName, Vec<u8>> = HashMap::with_capacity(proofs.len());
1104 let mut expired: Vec<([u8; 32], Vec<u8>)> = Vec::new();
1110 for (addr, bytes) in proofs {
1111 match deserialize_proof(&bytes) {
1112 Ok((proof, _tx_hashes)) => {
1113 if proof_is_safely_fresh(&proof, now, max_safe_age, max_future_skew) {
1114 kept.insert(addr, bytes);
1115 } else {
1116 expired.push((addr, bytes));
1117 }
1118 }
1119 Err(_) => {
1120 expired.push((addr, bytes));
1123 }
1124 }
1125 }
1126 if !expired.is_empty() {
1127 info!(
1128 "Pruning {} stale cached proofs (quote.timestamp past safe-reuse window) \
1129 before resume",
1130 expired.len()
1131 );
1132 crate::data::client::cached_single::try_drop_proofs_for_file(resume_key, &expired);
1133 }
1134 kept
1135}
1136
1137fn proof_is_safely_fresh(
1144 proof: &ProofOfPayment,
1145 now: std::time::SystemTime,
1146 max_safe_age: Duration,
1147 max_future_skew: Duration,
1148) -> bool {
1149 for (_peer, quote) in &proof.peer_quotes {
1150 match now.duration_since(quote.timestamp) {
1151 Ok(age) => {
1152 if age > max_safe_age {
1153 return false;
1154 }
1155 }
1156 Err(future) => {
1157 if future.duration() > max_future_skew {
1158 return false;
1159 }
1160 }
1161 }
1162 }
1163 true
1164}
1165
1166#[cfg(test)]
1168mod send_assertions {
1169 use super::*;
1170
1171 fn _assert_send<T: Send>(_: &T) {}
1172
1173 #[allow(dead_code)]
1174 async fn _batch_upload_is_send(client: &Client) {
1175 let fut = client.batch_upload_chunks(Vec::new());
1176 _assert_send(&fut);
1177 }
1178}
1179
1180#[cfg(test)]
1181#[allow(clippy::unwrap_used)]
1182mod tests {
1183 use super::*;
1184 use ant_protocol::payment::SingleNodePayment;
1185
1186 const MEDIAN_INDEX: usize = CLOSE_GROUP_SIZE / 2;
1188
1189 fn make_prepared_chunk(median_amount: u64) -> PreparedChunk {
1193 let quotes: Vec<QuotePaymentInfo> = (0..CLOSE_GROUP_SIZE)
1194 .map(|i| {
1195 let amount = if i == MEDIAN_INDEX { median_amount } else { 0 };
1196 QuotePaymentInfo {
1197 quote_hash: QuoteHash::from([i as u8 + 1; 32]),
1198 rewards_address: RewardsAddress::new([i as u8 + 10; 20]),
1199 amount: Amount::from(amount),
1200 price: Amount::from(amount),
1201 }
1202 })
1203 .collect();
1204
1205 PreparedChunk {
1206 content: Bytes::from(vec![0xAA; 32]),
1207 address: [0u8; 32],
1208 quoted_peers: Vec::new(),
1209 payment: SingleNodeQuotePayment { quotes },
1210 peer_quotes: Vec::new(),
1211 commitment_sidecars: Vec::new(),
1212 }
1213 }
1214
1215 fn payment_quote(seed: u8, price: u64) -> PaymentQuote {
1216 PaymentQuote {
1217 content: xor_name::XorName([seed; 32]),
1218 timestamp: std::time::SystemTime::UNIX_EPOCH,
1219 price: Amount::from(price),
1220 rewards_address: RewardsAddress::new([seed; 20]),
1221 pub_key: Vec::new(),
1222 signature: Vec::new(),
1223 committed_key_count: 0,
1224 commitment_pin: None,
1225 }
1226 }
1227
1228 #[test]
1229 fn single_node_quote_payment_accepts_every_supported_quote_count() {
1230 for quote_count in 1..=CLOSE_GROUP_SIZE {
1231 let quotes = (0..quote_count)
1232 .rev()
1233 .map(|i| payment_quote(i as u8, i as u64 + 1))
1234 .collect();
1235
1236 let payment = SingleNodeQuotePayment::from_quotes(quotes)
1237 .expect("every supported quote count should produce an SNP payment");
1238 let median_index = quote_count / 2;
1239 let enhanced_price =
1240 payment.quotes[median_index].price * Amount::from(SINGLE_NODE_PAYMENT_MULTIPLIER);
1241
1242 assert_eq!(payment.quotes.len(), quote_count);
1243 assert!(
1244 payment
1245 .quotes
1246 .windows(2)
1247 .all(|pair| pair[0].price <= pair[1].price),
1248 "quotes should be sorted by price"
1249 );
1250 for (index, quote) in payment.quotes.iter().enumerate() {
1251 let expected = if index == median_index {
1252 enhanced_price
1253 } else {
1254 Amount::ZERO
1255 };
1256 assert_eq!(quote.amount, expected);
1257 }
1258 assert_eq!(payment.total_amount(), enhanced_price);
1259 }
1260 }
1261
1262 #[test]
1263 fn full_quote_payment_matches_protocol_implementation() {
1264 let quotes = (0..CLOSE_GROUP_SIZE)
1265 .rev()
1266 .map(|i| payment_quote(i as u8, i as u64 + 1))
1267 .collect::<Vec<_>>();
1268
1269 let local = SingleNodeQuotePayment::from_quotes(quotes.clone()).unwrap();
1270 let protocol = SingleNodePayment::from_quotes(
1271 quotes
1272 .into_iter()
1273 .map(|quote| {
1274 let price = quote.price;
1275 (quote, price)
1276 })
1277 .collect(),
1278 )
1279 .unwrap();
1280
1281 assert_eq!(local.total_amount(), protocol.total_amount());
1282 for (local, protocol) in local.quotes.iter().zip(&protocol.quotes) {
1283 assert_eq!(local.quote_hash, protocol.quote_hash);
1284 assert_eq!(local.rewards_address, protocol.rewards_address);
1285 assert_eq!(local.amount, protocol.amount);
1286 assert_eq!(local.price, protocol.price);
1287 }
1288 }
1289
1290 #[test]
1291 fn single_node_quote_payment_rejects_zero_quotes() {
1292 let err = SingleNodeQuotePayment::from_quotes(Vec::new())
1293 .expect_err("empty SNP quote sets must remain invalid");
1294 assert!(
1295 err.to_string().contains("requires 1..="),
1296 "unexpected error: {err}"
1297 );
1298 }
1299
1300 #[test]
1301 fn single_node_quote_payment_rejects_too_many_quotes() {
1302 let quotes = (0..=CLOSE_GROUP_SIZE)
1303 .map(|i| payment_quote(i as u8, i as u64 + 1))
1304 .collect();
1305
1306 let err = SingleNodeQuotePayment::from_quotes(quotes)
1307 .expect_err("quote sets larger than the close group must remain invalid");
1308 assert!(
1309 err.to_string()
1310 .contains(&format!("requires 1..={CLOSE_GROUP_SIZE}")),
1311 "unexpected error: {err}"
1312 );
1313 }
1314
1315 #[test]
1316 fn payment_intent_from_single_chunk() {
1317 let chunk = make_prepared_chunk(300);
1318 let intent = PaymentIntent::from_prepared_chunks(&[chunk]);
1319
1320 assert_eq!(intent.payments.len(), 1, "only non-zero amounts");
1321 assert_eq!(intent.total_amount, Amount::from(300));
1322
1323 let (hash, addr, amt) = &intent.payments[0];
1324 assert_eq!(*hash, QuoteHash::from([MEDIAN_INDEX as u8 + 1; 32]));
1325 assert_eq!(*addr, RewardsAddress::new([MEDIAN_INDEX as u8 + 10; 20]));
1326 assert_eq!(*amt, Amount::from(300));
1327 }
1328
1329 #[test]
1330 fn payment_intent_from_multiple_chunks() {
1331 let c1 = make_prepared_chunk(100);
1332 let c2 = make_prepared_chunk(250);
1333 let intent = PaymentIntent::from_prepared_chunks(&[c1, c2]);
1334
1335 assert_eq!(intent.payments.len(), 2);
1336 assert_eq!(intent.total_amount, Amount::from(350));
1337 }
1338
1339 #[test]
1340 fn payment_intent_skips_all_zero_chunks() {
1341 let chunk = make_prepared_chunk(0);
1342 let intent = PaymentIntent::from_prepared_chunks(&[chunk]);
1343
1344 assert!(intent.payments.is_empty());
1345 assert_eq!(intent.total_amount, Amount::ZERO);
1346 }
1347
1348 #[test]
1349 fn payment_intent_empty_input() {
1350 let intent = PaymentIntent::from_prepared_chunks(&[]);
1351 assert!(intent.payments.is_empty());
1352 assert_eq!(intent.total_amount, Amount::ZERO);
1353 }
1354
1355 #[test]
1356 fn finalize_batch_payment_builds_proofs() {
1357 let chunk = make_prepared_chunk(500);
1358 let quote_hash = chunk.payment.quotes[MEDIAN_INDEX].quote_hash;
1359
1360 let mut tx_map = HashMap::new();
1361 tx_map.insert(quote_hash, TxHash::from([0xBB; 32]));
1362
1363 let paid = finalize_batch_payment(vec![chunk], &tx_map).unwrap();
1364
1365 assert_eq!(paid.len(), 1);
1366 assert!(!paid[0].proof_bytes.is_empty());
1367 assert_eq!(paid[0].address, [0u8; 32]);
1368 }
1369
1370 #[test]
1371 fn finalize_batch_payment_empty_input() {
1372 let paid = finalize_batch_payment(vec![], &HashMap::new()).unwrap();
1373 assert!(paid.is_empty());
1374 }
1375
1376 #[test]
1377 fn finalize_batch_payment_missing_tx_hash_errors() {
1378 let chunk = make_prepared_chunk(500);
1381
1382 let result = finalize_batch_payment(vec![chunk], &HashMap::new());
1383 assert!(result.is_err());
1384 let err = result.unwrap_err().to_string();
1385 assert!(err.contains("Missing tx hash"), "got: {err}");
1386 }
1387
1388 #[test]
1389 fn finalize_batch_payment_multiple_chunks() {
1390 let c1 = make_prepared_chunk(100);
1391 let c2 = make_prepared_chunk(200);
1392 let q1 = c1.payment.quotes[MEDIAN_INDEX].quote_hash;
1393 let mut tx_map = HashMap::new();
1394 tx_map.insert(q1, TxHash::from([0xCC; 32]));
1397
1398 let paid = finalize_batch_payment(vec![c1, c2], &tx_map).unwrap();
1399 assert_eq!(paid.len(), 2);
1400 }
1401
1402 fn make_proof_with_timestamps(timestamps: &[std::time::SystemTime]) -> ProofOfPayment {
1412 let peer_quotes = timestamps
1413 .iter()
1414 .enumerate()
1415 .map(|(i, ts)| {
1416 let quote = PaymentQuote {
1417 content: xor_name::XorName([0u8; 32]),
1418 timestamp: *ts,
1419 price: Amount::from(1u64),
1420 rewards_address: RewardsAddress::new([1u8; 20]),
1421 pub_key: vec![],
1422 signature: vec![],
1423 committed_key_count: 0,
1424 commitment_pin: None,
1425 };
1426 (EncodedPeerId::from([i as u8; 32]), quote)
1427 })
1428 .collect();
1429 ProofOfPayment { peer_quotes }
1430 }
1431
1432 fn default_max_future_skew() -> Duration {
1433 Duration::from_secs(CACHED_PROOF_FUTURE_SKEW_TOLERANCE_SECS)
1434 }
1435
1436 #[test]
1437 fn proof_is_safely_fresh_accepts_recent_quote() {
1438 let proof = make_proof_with_timestamps(&[std::time::SystemTime::now()]);
1439 assert!(proof_is_safely_fresh(
1440 &proof,
1441 std::time::SystemTime::now(),
1442 Duration::from_secs(CACHED_PROOF_MAX_AGE_SECS),
1443 default_max_future_skew(),
1444 ));
1445 }
1446
1447 #[test]
1448 fn proof_is_safely_fresh_rejects_quote_past_safe_window() {
1449 let too_old = std::time::SystemTime::now() - Duration::from_secs(23 * 60 * 60 + 57 * 60);
1454 let proof = make_proof_with_timestamps(&[too_old]);
1455 let max_safe = Duration::from_secs(
1456 CACHED_PROOF_MAX_AGE_SECS.saturating_sub(CACHED_PROOF_SAFETY_MARGIN_SECS),
1457 );
1458 assert!(
1459 !proof_is_safely_fresh(
1460 &proof,
1461 std::time::SystemTime::now(),
1462 max_safe,
1463 default_max_future_skew(),
1464 ),
1465 "23h57m-old quote must fail safe-reuse check (limit is 24h - 5min margin)"
1466 );
1467 }
1468
1469 #[test]
1470 fn proof_is_safely_fresh_rejects_if_any_quote_is_stale() {
1471 let now = std::time::SystemTime::now();
1474 let fresh = now;
1475 let stale = now - Duration::from_secs(CACHED_PROOF_MAX_AGE_SECS);
1476 let proof = make_proof_with_timestamps(&[fresh, fresh, stale, fresh]);
1477 let max_safe = Duration::from_secs(
1478 CACHED_PROOF_MAX_AGE_SECS.saturating_sub(CACHED_PROOF_SAFETY_MARGIN_SECS),
1479 );
1480 assert!(!proof_is_safely_fresh(
1481 &proof,
1482 now,
1483 max_safe,
1484 default_max_future_skew(),
1485 ));
1486 }
1487
1488 #[test]
1489 fn proof_is_safely_fresh_accepts_slight_future_skew_within_node_tolerance() {
1490 let now = std::time::SystemTime::now();
1495 let slight_future = now + Duration::from_secs(60);
1496 let proof = make_proof_with_timestamps(&[slight_future]);
1497 let max_safe = Duration::from_secs(CACHED_PROOF_MAX_AGE_SECS);
1498 assert!(
1499 proof_is_safely_fresh(&proof, now, max_safe, default_max_future_skew()),
1500 "60s-future quote must be accepted (within node's 300s skew tolerance)"
1501 );
1502 }
1503
1504 #[test]
1505 fn proof_is_safely_fresh_rejects_far_future_dated_quote() {
1506 let now = std::time::SystemTime::now();
1510 let far_future = now + Duration::from_secs(3600);
1511 let proof = make_proof_with_timestamps(&[far_future]);
1512 let max_safe = Duration::from_secs(CACHED_PROOF_MAX_AGE_SECS);
1513 assert!(!proof_is_safely_fresh(
1514 &proof,
1515 now,
1516 max_safe,
1517 default_max_future_skew(),
1518 ));
1519 }
1520
1521 #[test]
1522 fn proof_is_safely_fresh_empty_quotes_is_vacuously_safe() {
1523 let proof = make_proof_with_timestamps(&[]);
1528 assert!(proof_is_safely_fresh(
1529 &proof,
1530 std::time::SystemTime::now(),
1531 Duration::from_secs(CACHED_PROOF_MAX_AGE_SECS),
1532 default_max_future_skew(),
1533 ));
1534 }
1535}