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