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 }
282 }
283
284 #[allow(clippy::expect_used, clippy::panic)]
290 fn start_node_with_timeout() -> (AnvilInstance, Url) {
291 const ANVIL_TIMEOUT_MS: u64 = 60_000; let host = std::env::var("ANVIL_IP_ADDR").unwrap_or_else(|_| "localhost".to_string());
294
295 let anvil = Anvil::new()
298 .timeout(ANVIL_TIMEOUT_MS)
299 .try_spawn()
300 .unwrap_or_else(|_| panic!("Could not spawn Anvil node after {ANVIL_TIMEOUT_MS}ms"));
301
302 let url = Url::parse(&format!("http://{host}:{}", anvil.port()))
303 .expect("Failed to parse Anvil URL");
304
305 (anvil, url)
306 }
307
308 #[tokio::test]
310 #[serial]
311 #[allow(clippy::expect_used)]
312 async fn test_standard_quote_payment() {
313 let (node, rpc_url) = start_node_with_timeout();
315 let network_token = deploy_network_token_contract(&rpc_url, &node)
316 .await
317 .expect("deploy network token");
318 let mut payment_vault =
319 deploy_payment_vault_contract(&rpc_url, &node, *network_token.contract.address())
320 .await
321 .expect("deploy data payments");
322
323 let transaction_config = TransactionConfig::default();
324
325 let mut quote_payments = vec![];
327 for _ in 0..CLOSE_GROUP_SIZE {
328 let quote_hash = dummy_hash();
329 let reward_address = dummy_address();
330 let amount = Amount::from(1u64);
331 quote_payments.push((quote_hash, reward_address, amount));
332 }
333
334 network_token
336 .approve(
337 *payment_vault.contract.address(),
338 evmlib::common::U256::MAX,
339 &transaction_config,
340 )
341 .await
342 .expect("Failed to approve");
343
344 println!("✓ Approved tokens");
345
346 payment_vault.set_provider(network_token.contract.provider().clone());
348
349 let result = payment_vault
351 .pay_for_quotes(quote_payments.clone(), &transaction_config)
352 .await;
353
354 assert!(result.is_ok(), "Payment failed: {:?}", result.err());
355 println!("✓ Paid for {} quotes", quote_payments.len());
356
357 for (quote_hash, _reward_address, amount) in "e_payments {
359 let result = payment_vault
360 .contract
361 .completedPayments(*quote_hash)
362 .call()
363 .await
364 .expect("completedPayments lookup failed");
365
366 let on_chain_amount = result.amount;
367 assert!(
368 on_chain_amount >= u128::try_from(*amount).expect("amount fits u128"),
369 "On-chain amount should be >= paid amount"
370 );
371 }
372
373 println!("✓ All {CLOSE_GROUP_SIZE} payments verified successfully");
374 println!("\n✅ Standard {CLOSE_GROUP_SIZE}-quote payment works!");
375 }
376
377 #[tokio::test]
379 #[serial]
380 #[allow(clippy::expect_used)]
381 async fn test_single_node_payment_strategy() {
382 let (node, rpc_url) = start_node_with_timeout();
383 let network_token = deploy_network_token_contract(&rpc_url, &node)
384 .await
385 .expect("deploy network token");
386 let mut payment_vault =
387 deploy_payment_vault_contract(&rpc_url, &node, *network_token.contract.address())
388 .await
389 .expect("deploy data payments");
390
391 let transaction_config = TransactionConfig::default();
392
393 let real_quote_hash = dummy_hash();
395 let real_reward_address = dummy_address();
396 let real_amount = Amount::from(3u64); let mut quote_payments = vec![(real_quote_hash, real_reward_address, real_amount)];
399
400 for _ in 0..CLOSE_GROUP_SIZE - 1 {
402 let dummy_quote_hash = dummy_hash();
403 let dummy_reward_address = dummy_address();
404 let dummy_amount = Amount::from(0u64); quote_payments.push((dummy_quote_hash, dummy_reward_address, dummy_amount));
406 }
407
408 network_token
410 .approve(
411 *payment_vault.contract.address(),
412 evmlib::common::U256::MAX,
413 &transaction_config,
414 )
415 .await
416 .expect("Failed to approve");
417
418 println!("✓ Approved tokens");
419
420 payment_vault.set_provider(network_token.contract.provider().clone());
422
423 let result = payment_vault
425 .pay_for_quotes(quote_payments.clone(), &transaction_config)
426 .await;
427
428 assert!(result.is_ok(), "Payment failed: {:?}", result.err());
429 println!(
430 "✓ Paid: 1 real (3 atto) + {} dummy (0 atto)",
431 CLOSE_GROUP_SIZE - 1
432 );
433
434 let real_result = payment_vault
438 .contract
439 .completedPayments(real_quote_hash)
440 .call()
441 .await
442 .expect("completedPayments lookup failed");
443
444 assert!(
445 real_result.amount > 0,
446 "Real payment should have non-zero amount on-chain"
447 );
448 println!("✓ Real payment verified (3 atto)");
449
450 for (i, (hash, _, _)) in quote_payments.iter().skip(1).enumerate() {
452 let result = payment_vault
453 .contract
454 .completedPayments(*hash)
455 .call()
456 .await
457 .expect("completedPayments lookup failed");
458
459 println!(" Dummy payment {}: amount={}", i + 1, result.amount);
460 }
461
462 println!("\n✅ SingleNode payment strategy works!");
463 }
464
465 #[test]
466 #[allow(clippy::unwrap_used)]
467 fn test_from_quotes_median_selection() {
468 let prices: Vec<u64> = vec![50, 30, 10, 40, 20, 60, 70];
469 let mut quotes_with_prices = Vec::new();
470
471 for price in &prices {
472 let quote = PaymentQuote {
473 content: XorName::random(&mut rand::thread_rng()),
474 timestamp: SystemTime::now(),
475 price: Amount::from(*price),
476 rewards_address: RewardsAddress::new([1u8; 20]),
477 pub_key: vec![],
478 signature: vec![],
479 };
480 quotes_with_prices.push((quote, Amount::from(*price)));
481 }
482
483 let payment = SingleNodePayment::from_quotes(quotes_with_prices).unwrap();
484
485 let median_quote = payment.quotes.get(MEDIAN_INDEX).unwrap();
488 assert_eq!(median_quote.amount, Amount::from(120u64));
489
490 for (i, q) in payment.quotes.iter().enumerate() {
492 if i != MEDIAN_INDEX {
493 assert_eq!(q.amount, Amount::ZERO);
494 }
495 }
496
497 assert_eq!(payment.total_amount(), Amount::from(120u64));
499 }
500
501 #[test]
502 fn test_from_quotes_wrong_count() {
503 let quotes: Vec<_> = (0..3)
504 .map(|_| (make_test_quote(1), Amount::from(10u64)))
505 .collect();
506 let result = SingleNodePayment::from_quotes(quotes);
507 assert!(result.is_err());
508 }
509
510 #[test]
511 #[allow(clippy::expect_used)]
512 fn test_from_quotes_zero_quotes() {
513 let result = SingleNodePayment::from_quotes(vec![]);
514 assert!(result.is_err());
515 let err_msg = format!("{}", result.expect_err("should fail"));
516 assert!(err_msg.contains("exactly 7"));
517 }
518
519 #[test]
520 fn test_from_quotes_one_quote() {
521 let result =
522 SingleNodePayment::from_quotes(vec![(make_test_quote(1), Amount::from(10u64))]);
523 assert!(result.is_err());
524 }
525
526 #[test]
527 #[allow(clippy::expect_used)]
528 fn test_from_quotes_wrong_count_six() {
529 let quotes: Vec<_> = (0..6)
530 .map(|_| (make_test_quote(1), Amount::from(10u64)))
531 .collect();
532 let result = SingleNodePayment::from_quotes(quotes);
533 assert!(result.is_err());
534 let err_msg = format!("{}", result.expect_err("should fail"));
535 assert!(err_msg.contains("exactly 7"));
536 }
537
538 #[test]
539 #[allow(clippy::unwrap_used)]
540 fn test_paid_quote_returns_median() {
541 let quotes: Vec<_> = (1u8..)
542 .take(CLOSE_GROUP_SIZE)
543 .map(|i| (make_test_quote(i), Amount::from(u64::from(i) * 10)))
544 .collect();
545
546 let payment = SingleNodePayment::from_quotes(quotes).unwrap();
547 let paid = payment.paid_quote().unwrap();
548
549 assert!(paid.amount > Amount::ZERO);
551
552 assert_eq!(payment.total_amount(), paid.amount);
554 }
555
556 #[test]
557 #[allow(clippy::unwrap_used)]
558 fn test_all_quotes_have_distinct_addresses() {
559 let quotes: Vec<_> = (1u8..)
560 .take(CLOSE_GROUP_SIZE)
561 .map(|i| (make_test_quote(i), Amount::from(u64::from(i) * 10)))
562 .collect();
563
564 let payment = SingleNodePayment::from_quotes(quotes).unwrap();
565
566 let mut addresses: Vec<_> = payment.quotes.iter().map(|q| q.rewards_address).collect();
568 addresses.sort();
569 addresses.dedup();
570 assert_eq!(addresses.len(), CLOSE_GROUP_SIZE);
571 }
572
573 #[test]
574 #[allow(clippy::unwrap_used)]
575 fn test_tied_median_prices_all_share_median_price() {
576 let prices = [10u64, 20, 30, 30, 30, 40, 50];
578 let mut quotes_with_prices = Vec::new();
579
580 for (i, price) in prices.iter().enumerate() {
581 let quote = PaymentQuote {
582 content: XorName::random(&mut rand::thread_rng()),
583 timestamp: SystemTime::now(),
584 price: Amount::from(*price),
585 #[allow(clippy::cast_possible_truncation)] rewards_address: RewardsAddress::new([i as u8 + 1; 20]),
587 pub_key: vec![],
588 signature: vec![],
589 };
590 quotes_with_prices.push((quote, Amount::from(*price)));
591 }
592
593 let payment = SingleNodePayment::from_quotes(quotes_with_prices).unwrap();
594
595 let tied_count = payment
597 .quotes
598 .iter()
599 .filter(|q| q.price == Amount::from(30u64))
600 .count();
601 assert_eq!(tied_count, 3, "Should have 3 quotes tied at median price");
602
603 assert_eq!(payment.quotes[MEDIAN_INDEX].amount, Amount::from(90u64));
605 assert_eq!(payment.total_amount(), Amount::from(90u64));
606 }
607
608 #[test]
609 #[allow(clippy::unwrap_used)]
610 fn test_total_amount_equals_3x_median() {
611 let prices = [100u64, 200, 300, 400, 500, 600, 700];
612 let quotes: Vec<_> = prices
613 .iter()
614 .map(|price| (make_test_quote(1), Amount::from(*price)))
615 .collect();
616
617 let payment = SingleNodePayment::from_quotes(quotes).unwrap();
618 assert_eq!(payment.total_amount(), Amount::from(1200u64));
620 }
621
622 #[tokio::test]
630 #[serial]
631 #[allow(clippy::expect_used)]
632 async fn verify_rejects_zero_median_price() -> Result<()> {
633 let testnet = Testnet::new()
634 .await
635 .map_err(|e| Error::Payment(format!("Failed to start testnet: {e}")))?;
636 let network = testnet.to_network();
637
638 let quotes_with_prices: Vec<_> = (0..CLOSE_GROUP_SIZE)
640 .map(|_| (make_test_quote(1), Amount::ZERO))
641 .collect();
642 let payment = SingleNodePayment::from_quotes(quotes_with_prices)?;
643
644 assert_eq!(payment.quotes[MEDIAN_INDEX].amount, Amount::ZERO);
645
646 let err = payment
647 .verify(&network)
648 .await
649 .expect_err("verify must reject zero-priced median");
650 let msg = format!("{err}");
651 assert!(
652 msg.contains("zero price"),
653 "unexpected error message: {msg}"
654 );
655 Ok(())
656 }
657
658 #[tokio::test]
660 #[serial]
661 async fn test_single_node_with_real_prices() -> Result<()> {
662 let testnet = Testnet::new()
664 .await
665 .map_err(|e| Error::Payment(format!("Failed to start testnet: {e}")))?;
666 let network = testnet.to_network();
667 let wallet_key = testnet
668 .default_wallet_private_key()
669 .map_err(|e| Error::Payment(format!("Failed to get wallet key: {e}")))?;
670 let wallet = Wallet::new_from_private_key(network.clone(), &wallet_key)
671 .map_err(|e| Error::Payment(format!("Failed to create wallet: {e}")))?;
672
673 println!("✓ Started Anvil testnet");
674
675 wallet
677 .approve_to_spend_tokens(*network.payment_vault_address(), evmlib::common::U256::MAX)
678 .await
679 .map_err(|e| Error::Payment(format!("Failed to approve tokens: {e}")))?;
680
681 println!("✓ Approved tokens");
682
683 let chunk_xor = XorName::random(&mut rand::thread_rng());
685
686 let mut quotes_with_prices = Vec::new();
690 for i in 0..CLOSE_GROUP_SIZE {
691 #[allow(clippy::cast_possible_truncation)]
692 let price = Amount::from(100u64 + i as u64);
693
694 let quote = PaymentQuote {
695 content: chunk_xor,
696 timestamp: SystemTime::now(),
697 price,
698 rewards_address: wallet.address(),
699 pub_key: vec![],
700 signature: vec![],
701 };
702
703 quotes_with_prices.push((quote, price));
704 }
705
706 println!("✓ Got {CLOSE_GROUP_SIZE} quotes with calculated prices");
707
708 let payment = SingleNodePayment::from_quotes(quotes_with_prices)?;
710
711 let median_price = payment
712 .paid_quote()
713 .ok_or_else(|| Error::Payment("Missing paid quote at median index".to_string()))?
714 .amount
715 .checked_div(Amount::from(3u64))
716 .ok_or_else(|| Error::Payment("Failed to calculate median price".to_string()))?;
717 println!("✓ Sorted and selected median price: {median_price} atto");
718
719 assert_eq!(payment.quotes.len(), CLOSE_GROUP_SIZE);
720 let median_amount = payment
721 .quotes
722 .get(MEDIAN_INDEX)
723 .ok_or_else(|| {
724 Error::Payment(format!(
725 "Index out of bounds: tried to access median index {} but quotes array has {} elements",
726 MEDIAN_INDEX,
727 payment.quotes.len()
728 ))
729 })?
730 .amount;
731 assert_eq!(
732 payment.total_amount(),
733 median_amount,
734 "Only median should have non-zero amount"
735 );
736
737 println!(
738 "✓ Created SingleNode payment: {} atto total (3x median)",
739 payment.total_amount()
740 );
741
742 let tx_hashes = payment.pay(&wallet).await?;
744 println!("✓ Payment successful: {} transactions", tx_hashes.len());
745
746 let verified_amount = payment.verify(&network).await?;
748 let expected_median_amount = payment.quotes[MEDIAN_INDEX].amount;
749
750 assert_eq!(
751 verified_amount, expected_median_amount,
752 "Verified amount should match median payment"
753 );
754
755 println!("✓ Payment verified: {verified_amount} atto");
756 println!("\n✅ Complete SingleNode flow with real prices works!");
757
758 Ok(())
759 }
760}