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;
11#[cfg(test)]
12use crate::data::client::payment::SINGLE_NODE_PAYMENT_MULTIPLIER;
13use crate::data::client::Client;
14use crate::data::error::{Error, Result};
15use ant_protocol::evm::{
16 Amount, EncodedPeerId, PayForQuotesError, PaymentQuote, ProofOfPayment, QuoteHash,
17 RewardsAddress, TxHash, Wallet,
18};
19#[cfg(any(feature = "native", test))]
20use ant_protocol::payment::deserialize_proof;
21use ant_protocol::payment::{serialize_single_node_proof, PaymentProof, QuotePaymentInfo};
22use ant_protocol::transport::{MultiAddr, PeerId};
23#[cfg(test)]
24use ant_protocol::CLOSE_GROUP_SIZE;
25use ant_protocol::{compute_address, XorName, DATA_TYPE_CHUNK};
26use bytes::Bytes;
27use futures::stream::StreamExt;
28use std::collections::HashMap;
29use tokio::sync::mpsc;
30use tracing::{debug, info, warn};
31use web_time::Duration;
32use web_time::Instant;
33
34pub(super) const PAYMENT_WAVE_SIZE: usize = 64;
36
37#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
45pub struct SingleNodeQuotePayment {
46 pub quotes: Vec<QuotePaymentInfo>,
49}
50
51impl SingleNodeQuotePayment {
52 pub fn from_quotes(quotes: Vec<PaymentQuote>) -> Result<Self> {
58 let prices = quotes.iter().map(|quote| quote.price).collect::<Vec<_>>();
59 let plan = crate::payment_policy::SingleNodePaymentPlan::from_prices(&prices)
60 .map_err(|error| Error::Payment(error.to_string()))?;
61 let quotes = plan
62 .quotes
63 .into_iter()
64 .map(|planned| {
65 let quote = "es[planned.quote_index];
66 let quote_hash = quote.hash();
67 QuotePaymentInfo {
68 quote_hash,
69 rewards_address: quote.rewards_address,
70 amount: planned.amount,
71 price: quote.price,
72 }
73 })
74 .collect();
75
76 Ok(Self { quotes })
77 }
78
79 #[must_use]
81 pub fn total_amount(&self) -> Amount {
82 self.quotes.iter().map(|q| q.amount).sum()
83 }
84
85 pub async fn pay(&self, wallet: &Wallet) -> Result<Vec<TxHash>> {
88 let quote_payments: Vec<_> = self
89 .quotes
90 .iter()
91 .map(|q| (q.quote_hash, q.rewards_address, q.amount))
92 .collect();
93
94 let (tx_hashes, _gas_info) =
95 wallet
96 .pay_for_quotes(quote_payments)
97 .await
98 .map_err(|PayForQuotesError(err, _)| {
99 Error::Payment(format!("Failed to pay for quotes: {err}"))
100 })?;
101
102 let mut result_hashes = Vec::new();
103 for quote_info in &self.quotes {
104 if !quote_info.amount.is_zero() {
105 let tx_hash = tx_hashes.get("e_info.quote_hash).ok_or_else(|| {
106 Error::Payment(format!(
107 "Missing transaction hash for non-zero quote {}",
108 quote_info.quote_hash
109 ))
110 })?;
111 result_hashes.push(*tx_hash);
112 }
113 }
114
115 Ok(result_hashes)
116 }
117}
118
119#[derive(Debug)]
121pub struct PreparedChunk {
122 pub content: Bytes,
124 pub address: XorName,
126 pub quoted_peers: Vec<(PeerId, Vec<MultiAddr>)>,
132 pub payment: SingleNodeQuotePayment,
134 pub peer_quotes: Vec<(EncodedPeerId, PaymentQuote)>,
136 pub commitment_sidecars: Vec<Vec<u8>>,
140}
141
142#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
144pub struct ChunkPaymentPlan {
145 pub address: XorName,
147 pub data_size: u64,
149 pub quoted_peers: Vec<(PeerId, Vec<MultiAddr>)>,
151 pub payment: SingleNodeQuotePayment,
153 pub peer_quotes: Vec<(EncodedPeerId, PaymentQuote)>,
155 pub commitment_sidecars: Vec<Vec<u8>>,
157}
158
159impl ChunkPaymentPlan {
160 pub fn with_content(self, content: Bytes) -> Result<PreparedChunk> {
162 if content.len() as u64 != self.data_size || compute_address(&content) != self.address {
163 return Err(Error::InvalidData(
164 "staged chunk differs from its payment plan".into(),
165 ));
166 }
167 Ok(PreparedChunk {
168 content,
169 address: self.address,
170 quoted_peers: self.quoted_peers,
171 payment: self.payment,
172 peer_quotes: self.peer_quotes,
173 commitment_sidecars: self.commitment_sidecars,
174 })
175 }
176}
177
178#[derive(Debug, Clone)]
180pub struct PaidChunk {
181 pub content: Bytes,
183 pub address: XorName,
185 pub quoted_peers: Vec<(PeerId, Vec<MultiAddr>)>,
191 pub proof_bytes: Vec<u8>,
193}
194
195#[derive(Debug)]
197pub struct WaveResult {
198 pub stored: Vec<XorName>,
200 pub failed: Vec<(XorName, String)>,
202 pub chunk_attempts_total: usize,
204 pub store_durations_ms: Vec<u64>,
206 pub retries_per_chunk: Vec<u32>,
208}
209
210#[derive(Debug, Default, Clone)]
217pub struct WaveAggregateStats {
218 pub chunk_attempts_total: usize,
220 pub store_durations_ms: Vec<u64>,
223 pub retries_histogram: [usize; 4],
228}
229
230impl WaveAggregateStats {
231 pub fn absorb(&mut self, wave: &WaveResult) {
233 self.chunk_attempts_total = self
234 .chunk_attempts_total
235 .saturating_add(wave.chunk_attempts_total);
236 self.store_durations_ms.extend(&wave.store_durations_ms);
237 for &r in &wave.retries_per_chunk {
238 let idx = (r as usize).min(self.retries_histogram.len() - 1);
239 self.retries_histogram[idx] = self.retries_histogram[idx].saturating_add(1);
240 }
241 }
242}
243
244fn percentile(values: &[u64], p: f64) -> u64 {
250 if values.is_empty() {
251 return 0;
252 }
253 let mut sorted = values.to_vec();
254 sorted.sort_unstable();
255 let p = p.clamp(0.0, 1.0);
256 let n = sorted.len();
258 #[allow(
259 clippy::cast_possible_truncation,
260 clippy::cast_sign_loss,
261 clippy::cast_precision_loss
262 )]
263 let rank = ((p * n as f64).ceil() as usize)
264 .saturating_sub(1)
265 .min(n - 1);
266 sorted[rank]
267}
268
269#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
274pub struct PaymentIntent {
275 pub payments: Vec<(QuoteHash, RewardsAddress, Amount)>,
277 pub total_amount: Amount,
279}
280
281impl PaymentIntent {
282 pub fn from_prepared_chunks(prepared: &[PreparedChunk]) -> Self {
286 let mut payments = Vec::new();
287 let mut total = Amount::ZERO;
288 for chunk in prepared {
289 for info in &chunk.payment.quotes {
290 if !info.amount.is_zero() {
291 payments.push((info.quote_hash, info.rewards_address, info.amount));
292 total += info.amount;
293 }
294 }
295 }
296 Self {
297 payments,
298 total_amount: total,
299 }
300 }
301}
302
303fn build_paid_chunks(
310 prepared: Vec<PreparedChunk>,
311 tx_hash_map: &HashMap<QuoteHash, TxHash>,
312) -> Result<Vec<PaidChunk>> {
313 prepared
314 .into_iter()
315 .map(|chunk| super::upload_state::UploadState::pay_prepared(chunk, tx_hash_map))
316 .collect()
317}
318
319pub(super) fn build_plan_proof(
320 plan: &ChunkPaymentPlan,
321 tx_hash_map: &HashMap<QuoteHash, TxHash>,
322) -> Result<Vec<u8>> {
323 let mut tx_hashes = Vec::new();
324 for info in &plan.payment.quotes {
325 if !info.amount.is_zero() {
326 let tx_hash = tx_hash_map.get(&info.quote_hash).copied().ok_or_else(|| {
327 Error::Payment(format!(
328 "Missing tx hash for quote {} — external signer did not return a receipt for this payment",
329 hex::encode(info.quote_hash)
330 ))
331 })?;
332 tx_hashes.push(tx_hash);
333 }
334 }
335
336 let proof = PaymentProof {
337 proof_of_payment: ProofOfPayment {
338 peer_quotes: plan.peer_quotes.clone(),
339 },
340 tx_hashes,
341 commitment_sidecars: plan.commitment_sidecars.clone(),
344 };
345
346 let proof_bytes = serialize_single_node_proof(&proof)
347 .map_err(|e| Error::Serialization(format!("Failed to serialize payment proof: {e}")))?;
348
349 Ok(proof_bytes)
350}
351
352pub fn finalize_batch_payment(
357 prepared: Vec<PreparedChunk>,
358 tx_hash_map: &HashMap<QuoteHash, TxHash>,
359) -> Result<Vec<PaidChunk>> {
360 build_paid_chunks(prepared, tx_hash_map)
361}
362
363impl Client {
364 pub async fn prepare_chunk_payment(&self, content: Bytes) -> Result<Option<PreparedChunk>> {
374 if let Some(refusal) = self.corroborated_settlement_refusal() {
381 return Err(Error::ClientUpdateRequired(refusal));
382 }
383
384 let address = compute_address(&content);
385 let data_size = u64::try_from(content.len())
386 .map_err(|e| Error::InvalidData(format!("content size too large: {e}")))?;
387 self.prepare_chunk_payment_plan(address, data_size)
388 .await?
389 .map(|plan| plan.with_content(content))
390 .transpose()
391 }
392
393 pub async fn prepare_chunk_payment_plan(
396 &self,
397 address: XorName,
398 data_size: u64,
399 ) -> Result<Option<ChunkPaymentPlan>> {
400 if data_size > ant_protocol::MAX_CHUNK_SIZE as u64 {
401 return Err(Error::InvalidData(
402 "chunk exceeds the protocol size limit".into(),
403 ));
404 }
405 let quote_plan = match self
406 .get_store_quote_plan(&address, data_size, DATA_TYPE_CHUNK)
407 .await
408 {
409 Ok(plan) => plan,
410 Err(Error::AlreadyStored) => {
411 debug!("Chunk {} already stored, skipping", hex::encode(address));
412 return Ok(None);
413 }
414 Err(e) => return Err(e),
415 };
416 let quotes_with_peers = quote_plan.quotes;
417
418 let quoted_peers = quote_plan.put_peers;
421
422 let mut peer_quotes = Vec::with_capacity(quotes_with_peers.len());
425 let mut quotes_for_payment = Vec::with_capacity(quotes_with_peers.len());
426 let mut commitment_sidecars = Vec::new();
429
430 for (peer_id, _addrs, quote, _price, commitment) in quotes_with_peers {
431 let encoded = peer_id_to_encoded(&peer_id)?;
432 peer_quotes.push((encoded, quote.clone()));
433 quotes_for_payment.push(quote);
434 if let Some(sidecar) = commitment {
435 commitment_sidecars.push(sidecar);
436 }
437 }
438
439 let payment = SingleNodeQuotePayment::from_quotes(quotes_for_payment)
440 .map_err(|e| Error::Payment(format!("Failed to create payment: {e}")))?;
441
442 Ok(Some(ChunkPaymentPlan {
443 data_size,
444 address,
445 quoted_peers,
446 payment,
447 peer_quotes,
448 commitment_sidecars,
449 }))
450 }
451
452 pub async fn batch_pay(
464 &self,
465 prepared: Vec<PreparedChunk>,
466 ) -> Result<(Vec<PaidChunk>, String, u128)> {
467 if prepared.is_empty() {
468 return Ok((Vec::new(), "0".to_string(), 0));
469 }
470
471 if let Some(refusal) = self.corroborated_settlement_refusal() {
475 return Err(Error::ClientUpdateRequired(refusal));
476 }
477
478 let wallet = self.require_wallet()?;
479
480 let intent = PaymentIntent::from_prepared_chunks(&prepared);
482 let storage_cost_atto = intent.total_amount.to_string();
483
484 let total_quotes: usize = prepared.iter().map(|c| c.payment.quotes.len()).sum();
486 let mut all_payments = Vec::with_capacity(total_quotes);
487 for chunk in &prepared {
488 for info in &chunk.payment.quotes {
489 all_payments.push((info.quote_hash, info.rewards_address, info.amount));
490 }
491 }
492
493 debug!(
494 "Batch payment for {} chunks ({} quote entries)",
495 prepared.len(),
496 all_payments.len()
497 );
498
499 let (tx_hash_map, gas_info) =
500 wallet
501 .pay_for_quotes(all_payments)
502 .await
503 .map_err(|PayForQuotesError(err, _)| {
504 Error::Payment(format!("Batch payment failed: {err}"))
505 })?;
506
507 info!(
508 "Batch payment succeeded: {} transactions",
509 tx_hash_map.len()
510 );
511
512 let tx_hash_map: HashMap<QuoteHash, TxHash> = tx_hash_map.into_iter().collect();
513 let paid_chunks = build_paid_chunks(prepared, &tx_hash_map)?;
514 Ok((paid_chunks, storage_cost_atto, gas_info.gas_cost_wei))
515 }
516
517 pub async fn batch_upload_chunks(
532 &self,
533 chunks: Vec<Bytes>,
534 ) -> Result<(Vec<XorName>, String, u128)> {
535 let (addresses, storage, gas, _stats) = self
536 .batch_upload_chunks_with_events(chunks, None, 0, 0, None)
537 .await?;
538 Ok((addresses, storage, gas))
539 }
540
541 pub async fn batch_upload_chunks_with_events(
555 &self,
556 chunks: Vec<Bytes>,
557 progress: Option<&mpsc::Sender<UploadEvent>>,
558 stored_offset: usize,
559 file_total: usize,
560 _resume_key: Option<&str>,
561 ) -> Result<(Vec<XorName>, String, u128, WaveAggregateStats)> {
562 #[cfg(feature = "native")]
563 let proofs = _resume_key
564 .and_then(crate::data::client::cached_single::try_load_for_file)
565 .map(|(_, receipt)| {
566 prune_locally_expired_proofs(_resume_key.unwrap_or_default(), receipt.proofs)
567 })
568 .unwrap_or_default();
569 #[cfg(not(feature = "native"))]
570 let proofs = HashMap::new();
571 let mut state = super::upload_state::UploadState::from_proofs(proofs);
572 let records = chunks
573 .iter()
574 .enumerate()
575 .map(|(index, bytes)| super::upload::UploadRecord {
576 address: compute_address(bytes),
577 size: bytes.len() as u64,
578 index,
579 })
580 .collect();
581 let adapter = super::upload::MemoryUploadAdapter {
582 client: self,
583 chunks: &chunks,
584 progress,
585 stored_offset,
586 file_total,
587 resume_key: _resume_key,
588 };
589 let result = self
590 .upload_records(
591 records,
592 &mut state,
593 &adapter,
594 super::merkle::PaymentMode::Single,
595 )
596 .await?;
597 Ok((
598 result.addresses,
599 result.amount.to_string(),
600 result.gas,
601 result.stats,
602 ))
603 }
604
605 pub(crate) async fn store_paid_chunks_with_events(
617 &self,
618 paid_chunks: Vec<PaidChunk>,
619 progress: Option<&mpsc::Sender<UploadEvent>>,
620 stored_before: usize,
621 total_chunks: usize,
622 ) -> WaveResult {
623 let mut stored = Vec::new();
624 let mut to_retry = paid_chunks;
625
626 let mut first_seen: HashMap<XorName, Instant> = HashMap::with_capacity(to_retry.len());
630 for chunk in &to_retry {
631 first_seen.entry(chunk.address).or_insert_with(Instant::now);
632 }
633
634 let max_chunk_bytes = to_retry.iter().map(|c| c.content.len()).max().unwrap_or(0);
644 let byte_bound = crate::client_engine::store_byte_bound(max_chunk_bytes);
647
648 let mut chunk_attempts_total: usize = 0;
649 let mut store_durations_ms: Vec<u64> = Vec::new();
650 let mut retries_per_chunk: Vec<u32> = Vec::new();
651
652 for attempt in 0..=crate::client_engine::STORE_MAX_RETRIES {
653 if attempt > 0 {
654 crate::runtime::sleep(crate::client_engine::store_retry_delay(attempt)).await;
655 info!(
656 "Retry attempt {attempt}/{} for {} chunks",
657 crate::client_engine::STORE_MAX_RETRIES,
658 to_retry.len()
659 );
660 }
661
662 chunk_attempts_total = chunk_attempts_total.saturating_add(to_retry.len());
664
665 let store_limiter = self.controller().store.clone();
666 let make_store = |chunk: PaidChunk| {
674 let chunk_clone = chunk.clone();
675 let limiter = store_limiter.clone();
676 async move {
677 let result = observe_op(
678 &limiter,
679 || async move {
680 self.chunk_put_to_close_group(
681 chunk.content,
682 chunk.proof_bytes,
683 &chunk.quoted_peers,
684 )
685 .await
686 },
687 classify_error,
688 )
689 .await;
690 (chunk_clone, result)
691 }
692 };
693 let mut failed_this_round = Vec::new();
694 let results = crate::client_engine::rolling_unordered(to_retry, make_store, || {
695 store_limiter.current().min(byte_bound)
696 });
697 futures::pin_mut!(results);
698 while let Some((chunk, result)) = results.next().await {
699 match result {
700 Ok(name) => {
701 let duration_ms = first_seen
702 .get(&chunk.address)
703 .map(|t| u64::try_from(t.elapsed().as_millis()).unwrap_or(u64::MAX))
704 .unwrap_or(0);
705 store_durations_ms.push(duration_ms);
706 retries_per_chunk.push(attempt);
707 stored.push(name);
708 let stored_num = stored_before + stored.len();
709 if total_chunks > 0 {
710 info!("Stored {stored_num}/{total_chunks}");
711 }
712 if let Some(tx) = progress {
713 let _ = tx.try_send(UploadEvent::ChunkStored {
714 stored: stored_num,
715 total: total_chunks,
716 });
717 }
718 }
719 Err(e) => failed_this_round.push((chunk, e.to_string())),
720 }
721 }
722
723 if failed_this_round.is_empty() {
724 let result = WaveResult {
725 stored,
726 failed: Vec::new(),
727 chunk_attempts_total,
728 store_durations_ms,
729 retries_per_chunk,
730 };
731 log_wave_summary(&result);
732 return result;
733 }
734
735 if attempt == crate::client_engine::STORE_MAX_RETRIES {
736 let failed = failed_this_round
737 .into_iter()
738 .map(|(c, e)| (c.address, e))
739 .collect();
740 let result = WaveResult {
741 stored,
742 failed,
743 chunk_attempts_total,
744 store_durations_ms,
745 retries_per_chunk,
746 };
747 log_wave_summary(&result);
748 return result;
749 }
750
751 warn!(
752 "{} chunks failed on attempt {}, will retry",
753 failed_this_round.len(),
754 attempt + 1
755 );
756 to_retry = failed_this_round.into_iter().map(|(c, _)| c).collect();
757 }
758
759 let result = WaveResult {
761 stored,
762 failed: Vec::new(),
763 chunk_attempts_total,
764 store_durations_ms,
765 retries_per_chunk,
766 };
767 log_wave_summary(&result);
768 result
769 }
770}
771
772fn log_wave_summary(result: &WaveResult) {
778 let retries_round_1 = result.retries_per_chunk.iter().filter(|&&r| r == 1).count();
779 let retries_round_2 = result.retries_per_chunk.iter().filter(|&&r| r == 2).count();
780 let retries_round_3 = result.retries_per_chunk.iter().filter(|&&r| r == 3).count();
781 let chunk_attempts_total = result.chunk_attempts_total;
782 info!(
783 chunks_stored = result.stored.len(),
784 chunks_failed = result.failed.len(),
785 chunk_attempts_total,
786 retries_round_1,
787 retries_round_2,
788 retries_round_3,
789 store_duration_p50_ms = percentile(&result.store_durations_ms, 0.50),
790 store_duration_p95_ms = percentile(&result.store_durations_ms, 0.95),
791 store_duration_max_ms = result.store_durations_ms.iter().max().copied().unwrap_or(0),
792 "chunk_store_wave_complete"
793 );
794}
795
796pub(super) const CACHED_PROOF_SAFETY_MARGIN_SECS: u64 = 300;
808
809pub(super) const CACHED_PROOF_MAX_AGE_SECS: u64 = 24 * 60 * 60;
816
817#[cfg(feature = "native")]
841fn prune_locally_expired_proofs(
842 resume_key: &str,
843 proofs: HashMap<[u8; 32], Vec<u8>>,
844) -> HashMap<XorName, Vec<u8>> {
845 let now = std::time::SystemTime::now();
846 let max_safe_age = Duration::from_secs(
847 CACHED_PROOF_MAX_AGE_SECS.saturating_sub(CACHED_PROOF_SAFETY_MARGIN_SECS),
848 );
849 let mut kept: HashMap<XorName, Vec<u8>> = HashMap::with_capacity(proofs.len());
850 let mut expired: Vec<([u8; 32], Vec<u8>)> = Vec::new();
856 for (addr, bytes) in proofs {
857 match deserialize_proof(&bytes) {
858 Ok((proof, _tx_hashes)) => {
859 if proof_is_safely_fresh(&proof, now, max_safe_age) {
860 kept.insert(addr, bytes);
861 } else {
862 expired.push((addr, bytes));
863 }
864 }
865 Err(_) => {
866 expired.push((addr, bytes));
869 }
870 }
871 }
872 if !expired.is_empty() {
873 info!(
874 "Pruning {} stale cached proofs (quote.timestamp past safe-reuse window) \
875 before resume",
876 expired.len()
877 );
878 crate::data::client::cached_single::try_drop_proofs_for_file(resume_key, &expired);
879 }
880 kept
881}
882
883pub(super) fn proof_is_safely_fresh(
898 proof: &ProofOfPayment,
899 now: std::time::SystemTime,
900 max_safe_age: Duration,
901) -> bool {
902 proof.peer_quotes.iter().all(|(_peer, quote)| {
903 now.duration_since(quote.timestamp)
904 .map_or(true, |age| age <= max_safe_age)
905 })
906}
907
908#[cfg(test)]
910mod send_assertions {
911 use super::*;
912
913 fn _assert_send<T: Send>(_: &T) {}
914
915 #[allow(dead_code)]
916 async fn _batch_upload_is_send(client: &Client) {
917 let fut = client.batch_upload_chunks(Vec::new());
918 _assert_send(&fut);
919 }
920}
921
922#[cfg(test)]
923#[allow(clippy::unwrap_used)]
924mod tests {
925 use super::*;
926 use ant_protocol::payment::SingleNodePayment;
927
928 const MEDIAN_INDEX: usize = CLOSE_GROUP_SIZE / 2;
930
931 fn make_prepared_chunk(median_amount: u64) -> PreparedChunk {
935 let quotes: Vec<QuotePaymentInfo> = (0..CLOSE_GROUP_SIZE)
936 .map(|i| {
937 let amount = if i == MEDIAN_INDEX { median_amount } else { 0 };
938 QuotePaymentInfo {
939 quote_hash: QuoteHash::from([i as u8 + 1; 32]),
940 rewards_address: RewardsAddress::new([i as u8 + 10; 20]),
941 amount: Amount::from(amount),
942 price: Amount::from(amount),
943 }
944 })
945 .collect();
946
947 PreparedChunk {
948 content: Bytes::from(vec![0xAA; 32]),
949 address: [0u8; 32],
950 quoted_peers: Vec::new(),
951 payment: SingleNodeQuotePayment { quotes },
952 peer_quotes: Vec::new(),
953 commitment_sidecars: Vec::new(),
954 }
955 }
956
957 fn payment_quote(seed: u8, price: u64) -> PaymentQuote {
958 PaymentQuote {
959 content: xor_name::XorName([seed; 32]),
960 timestamp: std::time::SystemTime::UNIX_EPOCH,
961 price: Amount::from(price),
962 rewards_address: RewardsAddress::new([seed; 20]),
963 pub_key: Vec::new(),
964 signature: Vec::new(),
965 committed_key_count: 0,
966 commitment_pin: None,
967 }
968 }
969
970 #[test]
971 fn single_node_quote_payment_accepts_every_supported_quote_count() {
972 for quote_count in 1..=CLOSE_GROUP_SIZE {
973 let quotes = (0..quote_count)
974 .rev()
975 .map(|i| payment_quote(i as u8, i as u64 + 1))
976 .collect();
977
978 let payment = SingleNodeQuotePayment::from_quotes(quotes)
979 .expect("every supported quote count should produce an SNP payment");
980 let median_index = quote_count / 2;
981 let enhanced_price =
982 payment.quotes[median_index].price * Amount::from(SINGLE_NODE_PAYMENT_MULTIPLIER);
983
984 assert_eq!(payment.quotes.len(), quote_count);
985 assert!(
986 payment
987 .quotes
988 .windows(2)
989 .all(|pair| pair[0].price <= pair[1].price),
990 "quotes should be sorted by price"
991 );
992 for (index, quote) in payment.quotes.iter().enumerate() {
993 let expected = if index == median_index {
994 enhanced_price
995 } else {
996 Amount::ZERO
997 };
998 assert_eq!(quote.amount, expected);
999 }
1000 assert_eq!(payment.total_amount(), enhanced_price);
1001 }
1002 }
1003
1004 #[test]
1005 fn full_quote_payment_matches_protocol_implementation() {
1006 let quotes = (0..CLOSE_GROUP_SIZE)
1007 .rev()
1008 .map(|i| payment_quote(i as u8, i as u64 + 1))
1009 .collect::<Vec<_>>();
1010
1011 let local = SingleNodeQuotePayment::from_quotes(quotes.clone()).unwrap();
1012 let protocol = SingleNodePayment::from_quotes(
1013 quotes
1014 .into_iter()
1015 .map(|quote| {
1016 let price = quote.price;
1017 (quote, price)
1018 })
1019 .collect(),
1020 )
1021 .unwrap();
1022
1023 assert_eq!(local.total_amount(), protocol.total_amount());
1024 for (local, protocol) in local.quotes.iter().zip(&protocol.quotes) {
1025 assert_eq!(local.quote_hash, protocol.quote_hash);
1026 assert_eq!(local.rewards_address, protocol.rewards_address);
1027 assert_eq!(local.amount, protocol.amount);
1028 assert_eq!(local.price, protocol.price);
1029 }
1030 }
1031
1032 #[test]
1033 fn single_node_quote_payment_rejects_zero_quotes() {
1034 let err = SingleNodeQuotePayment::from_quotes(Vec::new())
1035 .expect_err("empty SNP quote sets must remain invalid");
1036 assert!(
1037 err.to_string().contains("requires 1..="),
1038 "unexpected error: {err}"
1039 );
1040 }
1041
1042 #[test]
1043 fn single_node_quote_payment_rejects_too_many_quotes() {
1044 let quotes = (0..=CLOSE_GROUP_SIZE)
1045 .map(|i| payment_quote(i as u8, i as u64 + 1))
1046 .collect();
1047
1048 let err = SingleNodeQuotePayment::from_quotes(quotes)
1049 .expect_err("quote sets larger than the close group must remain invalid");
1050 assert!(
1051 err.to_string()
1052 .contains(&format!("requires 1..={CLOSE_GROUP_SIZE}")),
1053 "unexpected error: {err}"
1054 );
1055 }
1056
1057 #[test]
1058 fn payment_intent_from_single_chunk() {
1059 let chunk = make_prepared_chunk(300);
1060 let intent = PaymentIntent::from_prepared_chunks(&[chunk]);
1061
1062 assert_eq!(intent.payments.len(), 1, "only non-zero amounts");
1063 assert_eq!(intent.total_amount, Amount::from(300));
1064
1065 let (hash, addr, amt) = &intent.payments[0];
1066 assert_eq!(*hash, QuoteHash::from([MEDIAN_INDEX as u8 + 1; 32]));
1067 assert_eq!(*addr, RewardsAddress::new([MEDIAN_INDEX as u8 + 10; 20]));
1068 assert_eq!(*amt, Amount::from(300));
1069 }
1070
1071 #[test]
1072 fn payment_intent_from_multiple_chunks() {
1073 let c1 = make_prepared_chunk(100);
1074 let c2 = make_prepared_chunk(250);
1075 let intent = PaymentIntent::from_prepared_chunks(&[c1, c2]);
1076
1077 assert_eq!(intent.payments.len(), 2);
1078 assert_eq!(intent.total_amount, Amount::from(350));
1079 }
1080
1081 #[test]
1082 fn payment_intent_skips_all_zero_chunks() {
1083 let chunk = make_prepared_chunk(0);
1084 let intent = PaymentIntent::from_prepared_chunks(&[chunk]);
1085
1086 assert!(intent.payments.is_empty());
1087 assert_eq!(intent.total_amount, Amount::ZERO);
1088 }
1089
1090 #[test]
1091 fn payment_intent_empty_input() {
1092 let intent = PaymentIntent::from_prepared_chunks(&[]);
1093 assert!(intent.payments.is_empty());
1094 assert_eq!(intent.total_amount, Amount::ZERO);
1095 }
1096
1097 #[test]
1098 fn finalize_batch_payment_builds_proofs() {
1099 let chunk = make_prepared_chunk(500);
1100 let quote_hash = chunk.payment.quotes[MEDIAN_INDEX].quote_hash;
1101
1102 let mut tx_map = HashMap::new();
1103 tx_map.insert(quote_hash, TxHash::from([0xBB; 32]));
1104
1105 let paid = finalize_batch_payment(vec![chunk], &tx_map).unwrap();
1106
1107 assert_eq!(paid.len(), 1);
1108 assert!(!paid[0].proof_bytes.is_empty());
1109 assert_eq!(paid[0].address, [0u8; 32]);
1110 }
1111
1112 #[test]
1113 fn finalize_batch_payment_empty_input() {
1114 let paid = finalize_batch_payment(vec![], &HashMap::new()).unwrap();
1115 assert!(paid.is_empty());
1116 }
1117
1118 #[test]
1119 fn finalize_batch_payment_missing_tx_hash_errors() {
1120 let chunk = make_prepared_chunk(500);
1123
1124 let result = finalize_batch_payment(vec![chunk], &HashMap::new());
1125 assert!(result.is_err());
1126 let err = result.unwrap_err().to_string();
1127 assert!(err.contains("Missing tx hash"), "got: {err}");
1128 }
1129
1130 #[test]
1131 fn finalize_batch_payment_multiple_chunks() {
1132 let c1 = make_prepared_chunk(100);
1133 let c2 = make_prepared_chunk(200);
1134 let q1 = c1.payment.quotes[MEDIAN_INDEX].quote_hash;
1135 let mut tx_map = HashMap::new();
1136 tx_map.insert(q1, TxHash::from([0xCC; 32]));
1139
1140 let paid = finalize_batch_payment(vec![c1, c2], &tx_map).unwrap();
1141 assert_eq!(paid.len(), 2);
1142 }
1143
1144 fn make_proof_with_timestamps(timestamps: &[std::time::SystemTime]) -> ProofOfPayment {
1154 let peer_quotes = timestamps
1155 .iter()
1156 .enumerate()
1157 .map(|(i, ts)| {
1158 let quote = PaymentQuote {
1159 content: xor_name::XorName([0u8; 32]),
1160 timestamp: *ts,
1161 price: Amount::from(1u64),
1162 rewards_address: RewardsAddress::new([1u8; 20]),
1163 pub_key: vec![],
1164 signature: vec![],
1165 committed_key_count: 0,
1166 commitment_pin: None,
1167 };
1168 (EncodedPeerId::from([i as u8; 32]), quote)
1169 })
1170 .collect();
1171 ProofOfPayment { peer_quotes }
1172 }
1173
1174 #[test]
1175 #[cfg(any(feature = "native", test))]
1176 fn proof_is_safely_fresh_accepts_recent_quote() {
1177 let proof = make_proof_with_timestamps(&[std::time::SystemTime::now()]);
1178 assert!(proof_is_safely_fresh(
1179 &proof,
1180 std::time::SystemTime::now(),
1181 Duration::from_secs(CACHED_PROOF_MAX_AGE_SECS),
1182 ));
1183 }
1184
1185 #[test]
1186 #[cfg(any(feature = "native", test))]
1187 fn proof_is_safely_fresh_rejects_quote_past_safe_window() {
1188 let too_old = std::time::SystemTime::now() - Duration::from_secs(23 * 60 * 60 + 57 * 60);
1193 let proof = make_proof_with_timestamps(&[too_old]);
1194 let max_safe = Duration::from_secs(
1195 CACHED_PROOF_MAX_AGE_SECS.saturating_sub(CACHED_PROOF_SAFETY_MARGIN_SECS),
1196 );
1197 assert!(
1198 !proof_is_safely_fresh(&proof, std::time::SystemTime::now(), max_safe),
1199 "23h57m-old quote must fail safe-reuse check (limit is 24h - 5min margin)"
1200 );
1201 }
1202
1203 #[test]
1204 #[cfg(any(feature = "native", test))]
1205 fn proof_is_safely_fresh_rejects_if_any_quote_is_stale() {
1206 let now = std::time::SystemTime::now();
1209 let fresh = now;
1210 let stale = now - Duration::from_secs(CACHED_PROOF_MAX_AGE_SECS);
1211 let proof = make_proof_with_timestamps(&[fresh, fresh, stale, fresh]);
1212 let max_safe = Duration::from_secs(
1213 CACHED_PROOF_MAX_AGE_SECS.saturating_sub(CACHED_PROOF_SAFETY_MARGIN_SECS),
1214 );
1215 assert!(!proof_is_safely_fresh(&proof, now, max_safe));
1216 }
1217
1218 #[test]
1219 #[cfg(any(feature = "native", test))]
1220 fn proof_is_safely_fresh_accepts_slight_future_skew() {
1221 let now = std::time::SystemTime::now();
1225 let slight_future = now + Duration::from_secs(60);
1226 let proof = make_proof_with_timestamps(&[slight_future]);
1227 let max_safe = Duration::from_secs(CACHED_PROOF_MAX_AGE_SECS);
1228 assert!(
1229 proof_is_safely_fresh(&proof, now, max_safe),
1230 "60s-future quote must be accepted"
1231 );
1232 }
1233
1234 #[test]
1235 #[cfg(any(feature = "native", test))]
1236 fn proof_is_safely_fresh_accepts_quote_from_peer_clock_hours_ahead() {
1237 let now = std::time::SystemTime::now();
1245 let peer_ahead = now + Duration::from_secs(3 * 60 * 60);
1246 let proof = make_proof_with_timestamps(&[now, peer_ahead, now]);
1247 let max_safe = Duration::from_secs(
1248 CACHED_PROOF_MAX_AGE_SECS.saturating_sub(CACHED_PROOF_SAFETY_MARGIN_SECS),
1249 );
1250 assert!(
1251 proof_is_safely_fresh(&proof, now, max_safe),
1252 "a quote from a peer whose clock is ahead is fresh, not expired"
1253 );
1254 }
1255
1256 #[test]
1257 #[cfg(any(feature = "native", test))]
1258 fn proof_is_safely_fresh_empty_quotes_is_vacuously_safe() {
1259 let proof = make_proof_with_timestamps(&[]);
1264 assert!(proof_is_safely_fresh(
1265 &proof,
1266 std::time::SystemTime::now(),
1267 Duration::from_secs(CACHED_PROOF_MAX_AGE_SECS),
1268 ));
1269 }
1270}