1use crate::chunk::CLOSE_GROUP_SIZE;
17use crate::error::{Error, Result};
18use crate::logging::info;
19use evmlib::common::{Amount, QuoteHash};
20use evmlib::wallet::Wallet;
21use evmlib::Network as EvmNetwork;
22use evmlib::PaymentQuote;
23use evmlib::RewardsAddress;
24
25const MEDIAN_INDEX: usize = CLOSE_GROUP_SIZE / 2;
27
28#[derive(Debug, Clone)]
36pub struct SingleNodePayment {
37 pub quotes: [QuotePaymentInfo; CLOSE_GROUP_SIZE],
39}
40
41#[derive(Debug, Clone)]
43pub struct QuotePaymentInfo {
44 pub quote_hash: QuoteHash,
46 pub rewards_address: RewardsAddress,
48 pub amount: Amount,
50 pub price: Amount,
52}
53
54impl SingleNodePayment {
55 pub fn from_quotes(mut quotes_with_prices: Vec<(PaymentQuote, Amount)>) -> Result<Self> {
69 let len = quotes_with_prices.len();
70 if len != CLOSE_GROUP_SIZE {
71 return Err(Error::Payment(format!(
72 "SingleNode payment requires exactly {CLOSE_GROUP_SIZE} quotes, got {len}"
73 )));
74 }
75
76 quotes_with_prices.sort_by_key(|(_, price)| *price);
78
79 let median_price = quotes_with_prices
81 .get(MEDIAN_INDEX)
82 .ok_or_else(|| {
83 Error::Payment(format!(
84 "Missing median quote at index {MEDIAN_INDEX}: expected {CLOSE_GROUP_SIZE} quotes but get() failed"
85 ))
86 })?
87 .1;
88 let enhanced_price = median_price
89 .checked_mul(Amount::from(3u64))
90 .ok_or_else(|| {
91 Error::Payment("Price overflow when calculating 3x median".to_string())
92 })?;
93
94 let quotes_vec: Vec<QuotePaymentInfo> = quotes_with_prices
97 .into_iter()
98 .enumerate()
99 .map(|(idx, (quote, price))| QuotePaymentInfo {
100 quote_hash: quote.hash(),
101 rewards_address: quote.rewards_address,
102 amount: if idx == MEDIAN_INDEX {
103 enhanced_price
104 } else {
105 Amount::ZERO
106 },
107 price,
108 })
109 .collect();
110
111 let quotes: [QuotePaymentInfo; CLOSE_GROUP_SIZE] = quotes_vec
113 .try_into()
114 .map_err(|_| Error::Payment("Failed to convert quotes to fixed array".to_string()))?;
115
116 Ok(Self { quotes })
117 }
118
119 #[must_use]
121 pub fn total_amount(&self) -> Amount {
122 self.quotes.iter().map(|q| q.amount).sum()
123 }
124
125 #[must_use]
130 pub fn paid_quote(&self) -> Option<&QuotePaymentInfo> {
131 self.quotes.get(MEDIAN_INDEX)
132 }
133
134 pub async fn pay(&self, wallet: &Wallet) -> Result<Vec<evmlib::common::TxHash>> {
142 let quote_payments: Vec<_> = self
144 .quotes
145 .iter()
146 .map(|q| (q.quote_hash, q.rewards_address, q.amount))
147 .collect();
148
149 info!(
150 "Paying for {} quotes: 1 real ({} atto) + {} with 0 atto",
151 CLOSE_GROUP_SIZE,
152 self.total_amount(),
153 CLOSE_GROUP_SIZE - 1
154 );
155
156 let (tx_hashes, _gas_info) = wallet.pay_for_quotes(quote_payments).await.map_err(
157 |evmlib::wallet::PayForQuotesError(err, _)| {
158 Error::Payment(format!("Failed to pay for quotes: {err}"))
159 },
160 )?;
161
162 let mut result_hashes = Vec::new();
165 for quote_info in &self.quotes {
166 if quote_info.amount > Amount::ZERO {
167 let tx_hash = tx_hashes.get("e_info.quote_hash).ok_or_else(|| {
168 Error::Payment(format!(
169 "Missing transaction hash for non-zero quote {}",
170 quote_info.quote_hash
171 ))
172 })?;
173 result_hashes.push(*tx_hash);
174 }
175 }
176
177 info!(
178 "Payment successful: {} on-chain transactions",
179 result_hashes.len()
180 );
181
182 Ok(result_hashes)
183 }
184
185 pub async fn verify(&self, network: &EvmNetwork) -> Result<Amount> {
201 let median = self.quotes.get(MEDIAN_INDEX).ok_or_else(|| {
202 Error::Payment(format!(
203 "Missing median quote at index {MEDIAN_INDEX}: quotes array has only {} elements",
204 self.quotes.len()
205 ))
206 })?;
207 let median_price = median.price;
208 let expected_amount = median.amount;
209
210 if expected_amount == Amount::ZERO || median_price == Amount::ZERO {
215 return Err(Error::Payment(format!(
216 "Median quote has zero price/amount (price={median_price}, amount={expected_amount}); refusing to verify as paid"
217 )));
218 }
219
220 let tied_quotes: Vec<&QuotePaymentInfo> = self
222 .quotes
223 .iter()
224 .filter(|q| q.price == median_price)
225 .collect();
226
227 info!(
228 "Verifying median quote payment: expected at least {expected_amount} atto, {} quote(s) tied at median price",
229 tied_quotes.len()
230 );
231
232 let provider = evmlib::utils::http_provider(network.rpc_url().clone());
233 let vault_address = *network.payment_vault_address();
234 let contract =
235 evmlib::contract::payment_vault::interface::IPaymentVault::new(vault_address, provider);
236
237 for candidate in &tied_quotes {
239 let result = contract
240 .completedPayments(candidate.quote_hash)
241 .call()
242 .await
243 .map_err(|e| Error::Payment(format!("completedPayments lookup failed: {e}")))?;
244
245 let on_chain_amount = Amount::from(result.amount);
246
247 if on_chain_amount >= expected_amount {
248 info!("Payment verified: {on_chain_amount} atto paid for median-priced quote");
249 return Ok(on_chain_amount);
250 }
251 }
252
253 Err(Error::Payment(format!(
254 "No median-priced quote was paid enough: expected at least {expected_amount}, checked {} tied quote(s)",
255 tied_quotes.len()
256 )))
257 }
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263 use alloy::node_bindings::{Anvil, AnvilInstance};
264 use evmlib::testnet::{deploy_network_token_contract, deploy_payment_vault_contract, Testnet};
265 use evmlib::transaction_config::TransactionConfig;
266 use evmlib::utils::{dummy_address, dummy_hash};
267 use evmlib::wallet::Wallet;
268 use serial_test::serial;
269 use std::time::SystemTime;
270 use url::Url;
271 use xor_name::XorName;
272
273 fn make_test_quote(rewards_addr_seed: u8) -> PaymentQuote {
274 PaymentQuote {
275 content: XorName::random(&mut rand::thread_rng()),
276 timestamp: SystemTime::now(),
277 price: Amount::from(1u64),
278 rewards_address: RewardsAddress::new([rewards_addr_seed; 20]),
279 pub_key: vec![],
280 signature: vec![],
281 committed_key_count: 0,
282 commitment_pin: None,
283 }
284 }
285
286 #[allow(clippy::expect_used, clippy::panic)]
292 fn start_node_with_timeout() -> (AnvilInstance, Url) {
293 const ANVIL_TIMEOUT_MS: u64 = 60_000; let host = std::env::var("ANVIL_IP_ADDR").unwrap_or_else(|_| "localhost".to_string());
296
297 let anvil = Anvil::new()
300 .timeout(ANVIL_TIMEOUT_MS)
301 .try_spawn()
302 .unwrap_or_else(|_| panic!("Could not spawn Anvil node after {ANVIL_TIMEOUT_MS}ms"));
303
304 let url = Url::parse(&format!("http://{host}:{}", anvil.port()))
305 .expect("Failed to parse Anvil URL");
306
307 (anvil, url)
308 }
309
310 #[tokio::test]
312 #[serial]
313 #[allow(clippy::expect_used)]
314 async fn test_standard_quote_payment() {
315 let (node, rpc_url) = start_node_with_timeout();
317 let network_token = deploy_network_token_contract(&rpc_url, &node)
318 .await
319 .expect("deploy network token");
320 let mut payment_vault =
321 deploy_payment_vault_contract(&rpc_url, &node, *network_token.contract.address())
322 .await
323 .expect("deploy data payments");
324
325 let transaction_config = TransactionConfig::default();
326
327 let mut quote_payments = vec![];
329 for _ in 0..CLOSE_GROUP_SIZE {
330 let quote_hash = dummy_hash();
331 let reward_address = dummy_address();
332 let amount = Amount::from(1u64);
333 quote_payments.push((quote_hash, reward_address, amount));
334 }
335
336 network_token
338 .approve(
339 *payment_vault.contract.address(),
340 evmlib::common::U256::MAX,
341 &transaction_config,
342 )
343 .await
344 .expect("Failed to approve");
345
346 println!("✓ Approved tokens");
347
348 payment_vault.set_provider(network_token.contract.provider().clone());
350
351 let result = payment_vault
353 .pay_for_quotes(quote_payments.clone(), &transaction_config)
354 .await;
355
356 assert!(result.is_ok(), "Payment failed: {:?}", result.err());
357 println!("✓ Paid for {} quotes", quote_payments.len());
358
359 for (quote_hash, _reward_address, amount) in "e_payments {
361 let result = payment_vault
362 .contract
363 .completedPayments(*quote_hash)
364 .call()
365 .await
366 .expect("completedPayments lookup failed");
367
368 let on_chain_amount = result.amount;
369 assert!(
370 on_chain_amount >= u128::try_from(*amount).expect("amount fits u128"),
371 "On-chain amount should be >= paid amount"
372 );
373 }
374
375 println!("✓ All {CLOSE_GROUP_SIZE} payments verified successfully");
376 println!("\n✅ Standard {CLOSE_GROUP_SIZE}-quote payment works!");
377 }
378
379 #[tokio::test]
381 #[serial]
382 #[allow(clippy::expect_used)]
383 async fn test_single_node_payment_strategy() {
384 let (node, rpc_url) = start_node_with_timeout();
385 let network_token = deploy_network_token_contract(&rpc_url, &node)
386 .await
387 .expect("deploy network token");
388 let mut payment_vault =
389 deploy_payment_vault_contract(&rpc_url, &node, *network_token.contract.address())
390 .await
391 .expect("deploy data payments");
392
393 let transaction_config = TransactionConfig::default();
394
395 let real_quote_hash = dummy_hash();
397 let real_reward_address = dummy_address();
398 let real_amount = Amount::from(3u64); let mut quote_payments = vec![(real_quote_hash, real_reward_address, real_amount)];
401
402 for _ in 0..CLOSE_GROUP_SIZE - 1 {
404 let dummy_quote_hash = dummy_hash();
405 let dummy_reward_address = dummy_address();
406 let dummy_amount = Amount::from(0u64); quote_payments.push((dummy_quote_hash, dummy_reward_address, dummy_amount));
408 }
409
410 network_token
412 .approve(
413 *payment_vault.contract.address(),
414 evmlib::common::U256::MAX,
415 &transaction_config,
416 )
417 .await
418 .expect("Failed to approve");
419
420 println!("✓ Approved tokens");
421
422 payment_vault.set_provider(network_token.contract.provider().clone());
424
425 let result = payment_vault
427 .pay_for_quotes(quote_payments.clone(), &transaction_config)
428 .await;
429
430 assert!(result.is_ok(), "Payment failed: {:?}", result.err());
431 println!(
432 "✓ Paid: 1 real (3 atto) + {} dummy (0 atto)",
433 CLOSE_GROUP_SIZE - 1
434 );
435
436 let real_result = payment_vault
440 .contract
441 .completedPayments(real_quote_hash)
442 .call()
443 .await
444 .expect("completedPayments lookup failed");
445
446 assert!(
447 real_result.amount > 0,
448 "Real payment should have non-zero amount on-chain"
449 );
450 println!("✓ Real payment verified (3 atto)");
451
452 for (i, (hash, _, _)) in quote_payments.iter().skip(1).enumerate() {
454 let result = payment_vault
455 .contract
456 .completedPayments(*hash)
457 .call()
458 .await
459 .expect("completedPayments lookup failed");
460
461 println!(" Dummy payment {}: amount={}", i + 1, result.amount);
462 }
463
464 println!("\n✅ SingleNode payment strategy works!");
465 }
466
467 #[test]
468 #[allow(clippy::unwrap_used)]
469 fn test_from_quotes_median_selection() {
470 let prices: Vec<u64> = vec![50, 30, 10, 40, 20, 60, 70];
471 let mut quotes_with_prices = Vec::new();
472
473 for price in &prices {
474 let quote = PaymentQuote {
475 content: XorName::random(&mut rand::thread_rng()),
476 timestamp: SystemTime::now(),
477 price: Amount::from(*price),
478 rewards_address: RewardsAddress::new([1u8; 20]),
479 pub_key: vec![],
480 signature: vec![],
481 committed_key_count: 0,
482 commitment_pin: None,
483 };
484 quotes_with_prices.push((quote, Amount::from(*price)));
485 }
486
487 let payment = SingleNodePayment::from_quotes(quotes_with_prices).unwrap();
488
489 let median_quote = payment.quotes.get(MEDIAN_INDEX).unwrap();
492 assert_eq!(median_quote.amount, Amount::from(120u64));
493
494 for (i, q) in payment.quotes.iter().enumerate() {
496 if i != MEDIAN_INDEX {
497 assert_eq!(q.amount, Amount::ZERO);
498 }
499 }
500
501 assert_eq!(payment.total_amount(), Amount::from(120u64));
503 }
504
505 #[test]
506 fn test_from_quotes_wrong_count() {
507 let quotes: Vec<_> = (0..3)
508 .map(|_| (make_test_quote(1), Amount::from(10u64)))
509 .collect();
510 let result = SingleNodePayment::from_quotes(quotes);
511 assert!(result.is_err());
512 }
513
514 #[test]
515 #[allow(clippy::expect_used)]
516 fn test_from_quotes_zero_quotes() {
517 let result = SingleNodePayment::from_quotes(vec![]);
518 assert!(result.is_err());
519 let err_msg = format!("{}", result.expect_err("should fail"));
520 assert!(err_msg.contains("exactly 7"));
521 }
522
523 #[test]
524 fn test_from_quotes_one_quote() {
525 let result =
526 SingleNodePayment::from_quotes(vec![(make_test_quote(1), Amount::from(10u64))]);
527 assert!(result.is_err());
528 }
529
530 #[test]
531 #[allow(clippy::expect_used)]
532 fn test_from_quotes_wrong_count_six() {
533 let quotes: Vec<_> = (0..6)
534 .map(|_| (make_test_quote(1), Amount::from(10u64)))
535 .collect();
536 let result = SingleNodePayment::from_quotes(quotes);
537 assert!(result.is_err());
538 let err_msg = format!("{}", result.expect_err("should fail"));
539 assert!(err_msg.contains("exactly 7"));
540 }
541
542 #[test]
543 #[allow(clippy::unwrap_used)]
544 fn test_paid_quote_returns_median() {
545 let quotes: Vec<_> = (1u8..)
546 .take(CLOSE_GROUP_SIZE)
547 .map(|i| (make_test_quote(i), Amount::from(u64::from(i) * 10)))
548 .collect();
549
550 let payment = SingleNodePayment::from_quotes(quotes).unwrap();
551 let paid = payment.paid_quote().unwrap();
552
553 assert!(paid.amount > Amount::ZERO);
555
556 assert_eq!(payment.total_amount(), paid.amount);
558 }
559
560 #[test]
561 #[allow(clippy::unwrap_used)]
562 fn test_all_quotes_have_distinct_addresses() {
563 let quotes: Vec<_> = (1u8..)
564 .take(CLOSE_GROUP_SIZE)
565 .map(|i| (make_test_quote(i), Amount::from(u64::from(i) * 10)))
566 .collect();
567
568 let payment = SingleNodePayment::from_quotes(quotes).unwrap();
569
570 let mut addresses: Vec<_> = payment.quotes.iter().map(|q| q.rewards_address).collect();
572 addresses.sort();
573 addresses.dedup();
574 assert_eq!(addresses.len(), CLOSE_GROUP_SIZE);
575 }
576
577 #[test]
578 #[allow(clippy::unwrap_used)]
579 fn test_tied_median_prices_all_share_median_price() {
580 let prices = [10u64, 20, 30, 30, 30, 40, 50];
582 let mut quotes_with_prices = Vec::new();
583
584 for (i, price) in prices.iter().enumerate() {
585 let quote = PaymentQuote {
586 content: XorName::random(&mut rand::thread_rng()),
587 timestamp: SystemTime::now(),
588 price: Amount::from(*price),
589 #[allow(clippy::cast_possible_truncation)] rewards_address: RewardsAddress::new([i as u8 + 1; 20]),
591 pub_key: vec![],
592 signature: vec![],
593 committed_key_count: 0,
594 commitment_pin: None,
595 };
596 quotes_with_prices.push((quote, Amount::from(*price)));
597 }
598
599 let payment = SingleNodePayment::from_quotes(quotes_with_prices).unwrap();
600
601 let tied_count = payment
603 .quotes
604 .iter()
605 .filter(|q| q.price == Amount::from(30u64))
606 .count();
607 assert_eq!(tied_count, 3, "Should have 3 quotes tied at median price");
608
609 assert_eq!(payment.quotes[MEDIAN_INDEX].amount, Amount::from(90u64));
611 assert_eq!(payment.total_amount(), Amount::from(90u64));
612 }
613
614 #[test]
615 #[allow(clippy::unwrap_used)]
616 fn test_total_amount_equals_3x_median() {
617 let prices = [100u64, 200, 300, 400, 500, 600, 700];
618 let quotes: Vec<_> = prices
619 .iter()
620 .map(|price| (make_test_quote(1), Amount::from(*price)))
621 .collect();
622
623 let payment = SingleNodePayment::from_quotes(quotes).unwrap();
624 assert_eq!(payment.total_amount(), Amount::from(1200u64));
626 }
627
628 #[tokio::test]
636 #[serial]
637 #[allow(clippy::expect_used)]
638 async fn verify_rejects_zero_median_price() -> Result<()> {
639 let testnet = Testnet::new()
640 .await
641 .map_err(|e| Error::Payment(format!("Failed to start testnet: {e}")))?;
642 let network = testnet.to_network();
643
644 let quotes_with_prices: Vec<_> = (0..CLOSE_GROUP_SIZE)
646 .map(|_| (make_test_quote(1), Amount::ZERO))
647 .collect();
648 let payment = SingleNodePayment::from_quotes(quotes_with_prices)?;
649
650 assert_eq!(payment.quotes[MEDIAN_INDEX].amount, Amount::ZERO);
651
652 let err = payment
653 .verify(&network)
654 .await
655 .expect_err("verify must reject zero-priced median");
656 let msg = format!("{err}");
657 assert!(
658 msg.contains("zero price"),
659 "unexpected error message: {msg}"
660 );
661 Ok(())
662 }
663
664 #[tokio::test]
666 #[serial]
667 async fn test_single_node_with_real_prices() -> Result<()> {
668 let testnet = Testnet::new()
670 .await
671 .map_err(|e| Error::Payment(format!("Failed to start testnet: {e}")))?;
672 let network = testnet.to_network();
673 let wallet_key = testnet
674 .default_wallet_private_key()
675 .map_err(|e| Error::Payment(format!("Failed to get wallet key: {e}")))?;
676 let wallet = Wallet::new_from_private_key(network.clone(), &wallet_key)
677 .map_err(|e| Error::Payment(format!("Failed to create wallet: {e}")))?;
678
679 println!("✓ Started Anvil testnet");
680
681 wallet
683 .approve_to_spend_tokens(*network.payment_vault_address(), evmlib::common::U256::MAX)
684 .await
685 .map_err(|e| Error::Payment(format!("Failed to approve tokens: {e}")))?;
686
687 println!("✓ Approved tokens");
688
689 let chunk_xor = XorName::random(&mut rand::thread_rng());
691
692 let mut quotes_with_prices = Vec::new();
697 for i in 0..CLOSE_GROUP_SIZE {
698 #[allow(clippy::cast_possible_truncation)]
699 let price = Amount::from(100u64 + i as u64);
700
701 let quote = PaymentQuote {
702 content: chunk_xor,
703 timestamp: SystemTime::now(),
704 price,
705 rewards_address: wallet.address(),
706 pub_key: vec![],
707 signature: vec![],
708 committed_key_count: 0,
709 commitment_pin: None,
710 };
711
712 quotes_with_prices.push((quote, price));
713 }
714
715 println!("✓ Got {CLOSE_GROUP_SIZE} quotes with calculated prices");
716
717 let payment = SingleNodePayment::from_quotes(quotes_with_prices)?;
719
720 let median_price = payment
721 .paid_quote()
722 .ok_or_else(|| Error::Payment("Missing paid quote at median index".to_string()))?
723 .amount
724 .checked_div(Amount::from(3u64))
725 .ok_or_else(|| Error::Payment("Failed to calculate median price".to_string()))?;
726 println!("✓ Sorted and selected median price: {median_price} atto");
727
728 assert_eq!(payment.quotes.len(), CLOSE_GROUP_SIZE);
729 let median_amount = payment
730 .quotes
731 .get(MEDIAN_INDEX)
732 .ok_or_else(|| {
733 Error::Payment(format!(
734 "Index out of bounds: tried to access median index {} but quotes array has {} elements",
735 MEDIAN_INDEX,
736 payment.quotes.len()
737 ))
738 })?
739 .amount;
740 assert_eq!(
741 payment.total_amount(),
742 median_amount,
743 "Only median should have non-zero amount"
744 );
745
746 println!(
747 "✓ Created SingleNode payment: {} atto total (3x median)",
748 payment.total_amount()
749 );
750
751 let tx_hashes = payment.pay(&wallet).await?;
753 println!("✓ Payment successful: {} transactions", tx_hashes.len());
754
755 let verified_amount = payment.verify(&network).await?;
757 let expected_median_amount = payment.quotes[MEDIAN_INDEX].amount;
758
759 assert_eq!(
760 verified_amount, expected_median_amount,
761 "Verified amount should match median payment"
762 );
763
764 println!("✓ Payment verified: {verified_amount} atto");
765 println!("\n✅ Complete SingleNode flow with real prices works!");
766
767 Ok(())
768 }
769}