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 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 let quoted_peers = quote_plan.put_peers;
385
386 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 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 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 if let Some(refusal) = self.corroborated_settlement_refusal() {
439 return Err(Error::ClientUpdateRequired(refusal));
440 }
441
442 let wallet = self.require_wallet()?;
443
444 let intent = PaymentIntent::from_prepared_chunks(&prepared);
446 let storage_cost_atto = intent.total_amount.to_string();
447
448 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 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 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 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 let mut total_storage = Amount::ZERO;
580 let mut total_gas: u128 = 0;
581 let mut agg_stats = WaveAggregateStats::default();
582
583 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 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 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 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 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 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 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 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 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 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 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 let max_chunk_bytes = to_retry.iter().map(|c| c.content.len()).max().unwrap_or(0);
892 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 chunk_attempts_total = chunk_attempts_total.saturating_add(to_retry.len());
914
915 let store_limiter = self.controller().store.clone();
916 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 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
1031fn 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
1055const CACHED_PROOF_SAFETY_MARGIN_SECS: u64 = 300;
1067
1068const CACHED_PROOF_MAX_AGE_SECS: u64 = 24 * 60 * 60;
1075
1076const CACHED_PROOF_FUTURE_SKEW_TOLERANCE_SECS: u64 = 300;
1087
1088fn 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 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 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
1154fn 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#[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 const MEDIAN_INDEX: usize = CLOSE_GROUP_SIZE / 2;
1205
1206 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 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 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 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 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 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 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 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 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}