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