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 = CLOSE_GROUP_SIZE / 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..CLOSE_GROUP_SIZE {
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..CLOSE_GROUP_SIZE - 1 {
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<_> = (1u8..)
561 .take(CLOSE_GROUP_SIZE)
562 .map(|i| (make_test_quote(i), Amount::from(u64::from(i) * 10)))
563 .collect();
564
565 let payment = SingleNodePayment::from_quotes(quotes).unwrap();
566 let paid = payment.paid_quote().unwrap();
567
568 assert!(paid.amount > Amount::ZERO);
570
571 assert_eq!(payment.total_amount(), paid.amount);
573 }
574
575 #[test]
576 #[allow(clippy::unwrap_used)]
577 fn test_all_quotes_have_distinct_addresses() {
578 let quotes: Vec<_> = (1u8..)
579 .take(CLOSE_GROUP_SIZE)
580 .map(|i| (make_test_quote(i), Amount::from(u64::from(i) * 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(), CLOSE_GROUP_SIZE);
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..CLOSE_GROUP_SIZE {
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(), CLOSE_GROUP_SIZE);
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}