1use crate::ant_protocol::CLOSE_GROUP_SIZE;
14use crate::error::{Error, Result};
15use ant_evm::{Amount, PaymentQuote, QuoteHash, QuotingMetrics, RewardsAddress};
16use evmlib::contract::payment_vault;
17use evmlib::wallet::Wallet;
18use evmlib::Network as EvmNetwork;
19use tracing::info;
20
21fn zero_quoting_metrics() -> QuotingMetrics {
25 QuotingMetrics {
26 data_size: 0,
27 data_type: 0,
28 close_records_stored: 0,
29 records_per_type: vec![],
30 max_records: 0,
31 received_payment_count: 0,
32 live_time: 0,
33 network_density: None,
34 network_size: None,
35 }
36}
37
38const MEDIAN_INDEX: usize = 2;
40
41#[derive(Debug, Clone)]
49pub struct SingleNodePayment {
50 pub quotes: [QuotePaymentInfo; CLOSE_GROUP_SIZE],
52}
53
54#[derive(Debug, Clone)]
56pub struct QuotePaymentInfo {
57 pub quote_hash: QuoteHash,
59 pub rewards_address: RewardsAddress,
61 pub amount: Amount,
63 pub quoting_metrics: QuotingMetrics,
65}
66
67impl SingleNodePayment {
68 pub fn from_quotes(mut quotes_with_prices: Vec<(PaymentQuote, Amount)>) -> Result<Self> {
82 let len = quotes_with_prices.len();
83 if len != CLOSE_GROUP_SIZE {
84 return Err(Error::Payment(format!(
85 "SingleNode payment requires exactly {CLOSE_GROUP_SIZE} quotes, got {len}"
86 )));
87 }
88
89 quotes_with_prices.sort_by_key(|(_, price)| *price);
91
92 let median_price = quotes_with_prices
94 .get(MEDIAN_INDEX)
95 .ok_or_else(|| {
96 Error::Payment(format!(
97 "Missing median quote at index {MEDIAN_INDEX}: expected {CLOSE_GROUP_SIZE} quotes but get() failed"
98 ))
99 })?
100 .1;
101 let enhanced_price = median_price
102 .checked_mul(Amount::from(3u64))
103 .ok_or_else(|| {
104 Error::Payment("Price overflow when calculating 3x median".to_string())
105 })?;
106
107 let quotes_vec: Vec<QuotePaymentInfo> = quotes_with_prices
110 .into_iter()
111 .enumerate()
112 .map(|(idx, (quote, _))| QuotePaymentInfo {
113 quote_hash: quote.hash(),
114 rewards_address: quote.rewards_address,
115 amount: if idx == MEDIAN_INDEX {
116 enhanced_price
117 } else {
118 Amount::ZERO
119 },
120 quoting_metrics: quote.quoting_metrics,
121 })
122 .collect();
123
124 let quotes: [QuotePaymentInfo; CLOSE_GROUP_SIZE] = quotes_vec
126 .try_into()
127 .map_err(|_| Error::Payment("Failed to convert quotes to fixed array".to_string()))?;
128
129 Ok(Self { quotes })
130 }
131
132 #[must_use]
134 pub fn total_amount(&self) -> Amount {
135 self.quotes.iter().map(|q| q.amount).sum()
136 }
137
138 #[must_use]
143 pub fn paid_quote(&self) -> Option<&QuotePaymentInfo> {
144 self.quotes.get(MEDIAN_INDEX)
145 }
146
147 pub async fn pay(&self, wallet: &Wallet) -> Result<Vec<evmlib::common::TxHash>> {
155 let quote_payments: Vec<_> = self
157 .quotes
158 .iter()
159 .map(|q| (q.quote_hash, q.rewards_address, q.amount))
160 .collect();
161
162 info!(
163 "Paying for {} quotes: 1 real ({} atto) + {} with 0 atto",
164 CLOSE_GROUP_SIZE,
165 self.total_amount(),
166 CLOSE_GROUP_SIZE - 1
167 );
168
169 let (tx_hashes, _gas_info) = wallet.pay_for_quotes(quote_payments).await.map_err(
170 |evmlib::wallet::PayForQuotesError(err, _)| {
171 Error::Payment(format!("Failed to pay for quotes: {err}"))
172 },
173 )?;
174
175 let mut result_hashes = Vec::new();
178 for quote_info in &self.quotes {
179 if quote_info.amount > Amount::ZERO {
180 let tx_hash = tx_hashes.get("e_info.quote_hash).ok_or_else(|| {
181 Error::Payment(format!(
182 "Missing transaction hash for non-zero quote {}",
183 quote_info.quote_hash
184 ))
185 })?;
186 result_hashes.push(*tx_hash);
187 }
188 }
189
190 info!(
191 "Payment successful: {} on-chain transactions",
192 result_hashes.len()
193 );
194
195 Ok(result_hashes)
196 }
197
198 pub async fn verify(
216 &self,
217 network: &EvmNetwork,
218 owned_quote_hash: Option<QuoteHash>,
219 ) -> Result<Amount> {
220 let payment_digest: Vec<_> = self
223 .quotes
224 .iter()
225 .map(|q| (q.quote_hash, zero_quoting_metrics(), q.rewards_address))
226 .collect();
227
228 let owned_quote_hashes = owned_quote_hash.map_or_else(Vec::new, |hash| vec![hash]);
230
231 info!(
232 "Verifying {} payments (owned: {})",
233 payment_digest.len(),
234 owned_quote_hashes.len()
235 );
236
237 let verified_amount =
238 payment_vault::verify_data_payment(network, owned_quote_hashes.clone(), payment_digest)
239 .await
240 .map_err(|e| Error::Payment(format!("Payment verification failed: {e}")))?;
241
242 if owned_quote_hashes.is_empty() {
243 info!("Payment verified as valid on-chain");
244 } else {
245 let expected = self
247 .quotes
248 .iter()
249 .find(|q| Some(q.quote_hash) == owned_quote_hash)
250 .ok_or_else(|| Error::Payment("Owned quote hash not found in payment".to_string()))?
251 .amount;
252
253 if verified_amount != expected {
254 return Err(Error::Payment(format!(
255 "Payment amount mismatch: expected {expected}, verified {verified_amount}"
256 )));
257 }
258
259 info!("Payment verified: {verified_amount} atto received");
260 }
261
262 Ok(verified_amount)
263 }
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269 use alloy::node_bindings::{Anvil, AnvilInstance};
270 use evmlib::contract::payment_vault::interface;
271 use evmlib::quoting_metrics::QuotingMetrics;
272 use evmlib::testnet::{deploy_data_payments_contract, deploy_network_token_contract, Testnet};
273 use evmlib::transaction_config::TransactionConfig;
274 use evmlib::utils::{dummy_address, dummy_hash};
275 use evmlib::wallet::Wallet;
276 use reqwest::Url;
277 use serial_test::serial;
278 use std::time::SystemTime;
279 use xor_name::XorName;
280
281 fn make_test_quote(rewards_addr_seed: u8) -> PaymentQuote {
282 PaymentQuote {
283 content: XorName::random(&mut rand::thread_rng()),
284 timestamp: SystemTime::now(),
285 quoting_metrics: QuotingMetrics {
286 data_size: 1024,
287 data_type: 0,
288 close_records_stored: 0,
289 records_per_type: vec![],
290 max_records: 1000,
291 received_payment_count: 0,
292 live_time: 0,
293 network_density: None,
294 network_size: None,
295 },
296 rewards_address: RewardsAddress::new([rewards_addr_seed; 20]),
297 pub_key: vec![],
298 signature: vec![],
299 }
300 }
301
302 #[allow(clippy::expect_used, clippy::panic)]
308 fn start_node_with_timeout() -> (AnvilInstance, Url) {
309 const ANVIL_TIMEOUT_MS: u64 = 60_000; let host = std::env::var("ANVIL_IP_ADDR").unwrap_or_else(|_| "localhost".to_string());
312
313 let anvil = Anvil::new()
316 .timeout(ANVIL_TIMEOUT_MS)
317 .try_spawn()
318 .unwrap_or_else(|_| panic!("Could not spawn Anvil node after {ANVIL_TIMEOUT_MS}ms"));
319
320 let url = Url::parse(&format!("http://{host}:{}", anvil.port()))
321 .expect("Failed to parse Anvil URL");
322
323 (anvil, url)
324 }
325
326 #[tokio::test]
328 #[serial]
329 #[allow(clippy::expect_used)]
330 async fn test_standard_five_quote_payment() {
331 let (node, rpc_url) = start_node_with_timeout();
333 let network_token = deploy_network_token_contract(&rpc_url, &node).await;
334 let mut payment_vault =
335 deploy_data_payments_contract(&rpc_url, &node, *network_token.contract.address()).await;
336
337 let transaction_config = TransactionConfig::default();
338
339 let mut quote_payments = vec![];
341 for _ in 0..5 {
342 let quote_hash = dummy_hash();
343 let reward_address = dummy_address();
344 let amount = Amount::from(1u64);
345 quote_payments.push((quote_hash, reward_address, amount));
346 }
347
348 network_token
350 .approve(
351 *payment_vault.contract.address(),
352 evmlib::common::U256::MAX,
353 &transaction_config,
354 )
355 .await
356 .expect("Failed to approve");
357
358 println!("✓ Approved tokens");
359
360 payment_vault.set_provider(network_token.contract.provider().clone());
362
363 let result = payment_vault
365 .pay_for_quotes(quote_payments.clone(), &transaction_config)
366 .await;
367
368 assert!(result.is_ok(), "Payment failed: {:?}", result.err());
369 println!("✓ Paid for {} quotes", quote_payments.len());
370
371 let payment_verifications: Vec<_> = quote_payments
373 .into_iter()
374 .map(|v| interface::IPaymentVault::PaymentVerification {
375 metrics: zero_quoting_metrics().into(),
376 rewardsAddress: v.1,
377 quoteHash: v.0,
378 })
379 .collect();
380
381 let results = payment_vault
382 .verify_payment(payment_verifications)
383 .await
384 .expect("Verify payment failed");
385
386 for result in results {
387 assert!(result.isValid, "Payment verification should be valid");
388 }
389
390 println!("✓ All 5 payments verified successfully");
391 println!("\n✅ Standard 5-quote payment works!");
392 }
393
394 #[tokio::test]
396 #[serial]
397 #[allow(clippy::expect_used)]
398 async fn test_single_node_payment_strategy() {
399 let (node, rpc_url) = start_node_with_timeout();
400 let network_token = deploy_network_token_contract(&rpc_url, &node).await;
401 let mut payment_vault =
402 deploy_data_payments_contract(&rpc_url, &node, *network_token.contract.address()).await;
403
404 let transaction_config = TransactionConfig::default();
405
406 let real_quote_hash = dummy_hash();
408 let real_reward_address = dummy_address();
409 let real_amount = Amount::from(3u64); let mut quote_payments = vec![(real_quote_hash, real_reward_address, real_amount)];
412
413 for _ in 0..4 {
415 let dummy_quote_hash = dummy_hash();
416 let dummy_reward_address = dummy_address();
417 let dummy_amount = Amount::from(0u64); quote_payments.push((dummy_quote_hash, dummy_reward_address, dummy_amount));
419 }
420
421 network_token
423 .approve(
424 *payment_vault.contract.address(),
425 evmlib::common::U256::MAX,
426 &transaction_config,
427 )
428 .await
429 .expect("Failed to approve");
430
431 println!("✓ Approved tokens");
432
433 payment_vault.set_provider(network_token.contract.provider().clone());
435
436 let result = payment_vault
438 .pay_for_quotes(quote_payments.clone(), &transaction_config)
439 .await;
440
441 assert!(result.is_ok(), "Payment failed: {:?}", result.err());
442 println!("✓ Paid: 1 real (3 atto) + 4 dummy (0 atto)");
443
444 let payment_verifications: Vec<_> = quote_payments
446 .into_iter()
447 .map(|v| interface::IPaymentVault::PaymentVerification {
448 metrics: zero_quoting_metrics().into(),
449 rewardsAddress: v.1,
450 quoteHash: v.0,
451 })
452 .collect();
453
454 let results = payment_vault
455 .verify_payment(payment_verifications)
456 .await
457 .expect("Verify payment failed");
458
459 assert!(
461 results.first().is_some_and(|r| r.isValid),
462 "Real payment should be valid"
463 );
464 println!("✓ Real payment verified (3 atto)");
465
466 for (i, result) in results.iter().skip(1).enumerate() {
468 println!(" Dummy payment {}: valid={}", i + 1, result.isValid);
469 }
470
471 println!("\n✅ SingleNode payment strategy works!");
472 }
473
474 #[test]
475 #[allow(clippy::unwrap_used)]
476 fn test_from_quotes_median_selection() {
477 let prices: Vec<u64> = vec![50, 30, 10, 40, 20];
478 let mut quotes_with_prices = Vec::new();
479
480 for price in &prices {
481 let quote = PaymentQuote {
482 content: XorName::random(&mut rand::thread_rng()),
483 timestamp: SystemTime::now(),
484 quoting_metrics: QuotingMetrics {
485 data_size: 1024,
486 data_type: 0,
487 close_records_stored: 0,
488 records_per_type: vec![(0, 10)],
489 max_records: 1000,
490 received_payment_count: 5,
491 live_time: 3600,
492 network_density: None,
493 network_size: Some(100),
494 },
495 rewards_address: RewardsAddress::new([1u8; 20]),
496 pub_key: vec![],
497 signature: vec![],
498 };
499 quotes_with_prices.push((quote, Amount::from(*price)));
500 }
501
502 let payment = SingleNodePayment::from_quotes(quotes_with_prices).unwrap();
503
504 let median_quote = payment.quotes.get(MEDIAN_INDEX).unwrap();
507 assert_eq!(median_quote.amount, Amount::from(90u64));
508
509 for (i, q) in payment.quotes.iter().enumerate() {
511 if i != MEDIAN_INDEX {
512 assert_eq!(q.amount, Amount::ZERO);
513 }
514 }
515
516 assert_eq!(payment.total_amount(), Amount::from(90u64));
518 }
519
520 #[test]
521 fn test_from_quotes_wrong_count() {
522 let quotes: Vec<_> = (0..3)
523 .map(|_| (make_test_quote(1), Amount::from(10u64)))
524 .collect();
525 let result = SingleNodePayment::from_quotes(quotes);
526 assert!(result.is_err());
527 }
528
529 #[test]
530 #[allow(clippy::expect_used)]
531 fn test_from_quotes_zero_quotes() {
532 let result = SingleNodePayment::from_quotes(vec![]);
533 assert!(result.is_err());
534 let err_msg = format!("{}", result.expect_err("should fail"));
535 assert!(err_msg.contains("exactly 5"));
536 }
537
538 #[test]
539 fn test_from_quotes_one_quote() {
540 let result =
541 SingleNodePayment::from_quotes(vec![(make_test_quote(1), Amount::from(10u64))]);
542 assert!(result.is_err());
543 }
544
545 #[test]
546 #[allow(clippy::expect_used)]
547 fn test_from_quotes_six_quotes() {
548 let quotes: Vec<_> = (0..6)
549 .map(|_| (make_test_quote(1), Amount::from(10u64)))
550 .collect();
551 let result = SingleNodePayment::from_quotes(quotes);
552 assert!(result.is_err());
553 let err_msg = format!("{}", result.expect_err("should fail"));
554 assert!(err_msg.contains("exactly 5"));
555 }
556
557 #[test]
558 #[allow(clippy::unwrap_used)]
559 fn test_paid_quote_returns_median() {
560 let quotes: Vec<_> = (0..5u8)
561 .map(|i| (make_test_quote(i + 1), Amount::from(u64::from(i + 1) * 10)))
562 .collect();
563
564 let payment = SingleNodePayment::from_quotes(quotes).unwrap();
565 let paid = payment.paid_quote().unwrap();
566
567 assert!(paid.amount > Amount::ZERO);
569
570 assert_eq!(payment.total_amount(), paid.amount);
572 }
573
574 #[test]
575 #[allow(clippy::unwrap_used)]
576 fn test_all_quotes_have_distinct_addresses() {
577 let quotes: Vec<_> = (0..5u8)
578 .map(|i| (make_test_quote(i + 1), Amount::from(u64::from(i + 1) * 10)))
579 .collect();
580
581 let payment = SingleNodePayment::from_quotes(quotes).unwrap();
582
583 let mut addresses: Vec<_> = payment.quotes.iter().map(|q| q.rewards_address).collect();
585 addresses.sort();
586 addresses.dedup();
587 assert_eq!(addresses.len(), 5);
588 }
589
590 #[test]
591 #[allow(clippy::unwrap_used)]
592 fn test_total_amount_equals_3x_median() {
593 let prices = [100u64, 200, 300, 400, 500];
594 let quotes: Vec<_> = prices
595 .iter()
596 .map(|price| (make_test_quote(1), Amount::from(*price)))
597 .collect();
598
599 let payment = SingleNodePayment::from_quotes(quotes).unwrap();
600 assert_eq!(payment.total_amount(), Amount::from(900u64));
602 }
603
604 #[tokio::test]
606 #[serial]
607 async fn test_single_node_with_real_prices() -> Result<()> {
608 let testnet = Testnet::new().await;
610 let network = testnet.to_network();
611 let wallet =
612 Wallet::new_from_private_key(network.clone(), &testnet.default_wallet_private_key())
613 .map_err(|e| Error::Payment(format!("Failed to create wallet: {e}")))?;
614
615 println!("✓ Started Anvil testnet");
616
617 wallet
619 .approve_to_spend_tokens(*network.data_payments_address(), evmlib::common::U256::MAX)
620 .await
621 .map_err(|e| Error::Payment(format!("Failed to approve tokens: {e}")))?;
622
623 println!("✓ Approved tokens");
624
625 let chunk_xor = XorName::random(&mut rand::thread_rng());
627 let chunk_size = 1024usize;
628
629 let mut quotes_with_prices = Vec::new();
630 for i in 0..CLOSE_GROUP_SIZE {
631 let quoting_metrics = QuotingMetrics {
632 data_size: chunk_size,
633 data_type: 0,
634 close_records_stored: 10 + i,
635 records_per_type: vec![(
636 0,
637 u32::try_from(10 + i)
638 .map_err(|e| Error::Payment(format!("Invalid record count: {e}")))?,
639 )],
640 max_records: 1000,
641 received_payment_count: 5,
642 live_time: 3600,
643 network_density: None,
644 network_size: Some(100),
645 };
646
647 let prices = payment_vault::get_market_price(&network, vec![quoting_metrics.clone()])
652 .await
653 .map_err(|e| Error::Payment(format!("Failed to get market price: {e}")))?;
654
655 let price = prices.first().ok_or_else(|| {
656 Error::Payment(format!(
657 "Empty price list from get_market_price for quote {}: expected at least 1 price but got {} elements",
658 i,
659 prices.len()
660 ))
661 })?;
662
663 let quote = PaymentQuote {
664 content: chunk_xor,
665 timestamp: SystemTime::now(),
666 quoting_metrics,
667 rewards_address: wallet.address(),
668 pub_key: vec![],
669 signature: vec![],
670 };
671
672 quotes_with_prices.push((quote, *price));
673 }
674
675 println!("✓ Got 5 real quotes from contract");
676
677 let payment = SingleNodePayment::from_quotes(quotes_with_prices)?;
679
680 let median_price = payment
681 .paid_quote()
682 .ok_or_else(|| Error::Payment("Missing paid quote at median index".to_string()))?
683 .amount
684 .checked_div(Amount::from(3u64))
685 .ok_or_else(|| Error::Payment("Failed to calculate median price".to_string()))?;
686 println!("✓ Sorted and selected median price: {median_price} atto");
687
688 assert_eq!(payment.quotes.len(), CLOSE_GROUP_SIZE);
689 let median_amount = payment
690 .quotes
691 .get(MEDIAN_INDEX)
692 .ok_or_else(|| {
693 Error::Payment(format!(
694 "Index out of bounds: tried to access median index {} but quotes array has {} elements",
695 MEDIAN_INDEX,
696 payment.quotes.len()
697 ))
698 })?
699 .amount;
700 assert_eq!(
701 payment.total_amount(),
702 median_amount,
703 "Only median should have non-zero amount"
704 );
705
706 println!(
707 "✓ Created SingleNode payment: {} atto total (3x median)",
708 payment.total_amount()
709 );
710
711 let tx_hashes = payment.pay(&wallet).await?;
713 println!("✓ Payment successful: {} transactions", tx_hashes.len());
714
715 let median_quote = payment
717 .quotes
718 .get(MEDIAN_INDEX)
719 .ok_or_else(|| {
720 Error::Payment(format!(
721 "Index out of bounds: tried to access median index {} but quotes array has {} elements",
722 MEDIAN_INDEX,
723 payment.quotes.len()
724 ))
725 })?;
726 let median_quote_hash = median_quote.quote_hash;
727 let verified_amount = payment.verify(&network, Some(median_quote_hash)).await?;
728
729 assert_eq!(
730 verified_amount, median_quote.amount,
731 "Verified amount should match median payment"
732 );
733
734 println!("✓ Payment verified: {verified_amount} atto");
735 println!("\n✅ Complete SingleNode flow with real prices works!");
736
737 Ok(())
738 }
739}