1use crate::chunk::CLOSE_GROUP_SIZE;
17use crate::error::{Error, Result};
18#[cfg(feature = "rpc")]
19use crate::logging::info;
20use evmlib::common::{Amount, QuoteHash};
21#[cfg(feature = "rpc")]
22use evmlib::wallet::Wallet;
23#[cfg(feature = "rpc")]
24use evmlib::Network as EvmNetwork;
25use evmlib::PaymentQuote;
26use evmlib::RewardsAddress;
27
28const MEDIAN_INDEX: usize = CLOSE_GROUP_SIZE / 2;
30
31#[derive(Debug, Clone)]
39pub struct SingleNodePayment {
40 pub quotes: [QuotePaymentInfo; CLOSE_GROUP_SIZE],
42}
43
44#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
46pub struct QuotePaymentInfo {
47 pub quote_hash: QuoteHash,
49 pub rewards_address: RewardsAddress,
51 pub amount: Amount,
53 pub price: Amount,
55}
56
57impl SingleNodePayment {
58 pub fn from_quotes(mut quotes_with_prices: Vec<(PaymentQuote, Amount)>) -> Result<Self> {
72 let len = quotes_with_prices.len();
73 if len != CLOSE_GROUP_SIZE {
74 return Err(Error::Payment(format!(
75 "SingleNode payment requires exactly {CLOSE_GROUP_SIZE} quotes, got {len}"
76 )));
77 }
78
79 quotes_with_prices.sort_by_key(|(_, price)| *price);
81
82 let median_price = quotes_with_prices
84 .get(MEDIAN_INDEX)
85 .ok_or_else(|| {
86 Error::Payment(format!(
87 "Missing median quote at index {MEDIAN_INDEX}: expected {CLOSE_GROUP_SIZE} quotes but get() failed"
88 ))
89 })?
90 .1;
91 let enhanced_price = median_price
92 .checked_mul(Amount::from(3u64))
93 .ok_or_else(|| {
94 Error::Payment("Price overflow when calculating 3x median".to_string())
95 })?;
96
97 let quotes_vec: Vec<QuotePaymentInfo> = quotes_with_prices
100 .into_iter()
101 .enumerate()
102 .map(|(idx, (quote, price))| QuotePaymentInfo {
103 quote_hash: quote.hash(),
104 rewards_address: quote.rewards_address,
105 amount: if idx == MEDIAN_INDEX {
106 enhanced_price
107 } else {
108 Amount::ZERO
109 },
110 price,
111 })
112 .collect();
113
114 let quotes: [QuotePaymentInfo; CLOSE_GROUP_SIZE] = quotes_vec
116 .try_into()
117 .map_err(|_| Error::Payment("Failed to convert quotes to fixed array".to_string()))?;
118
119 Ok(Self { quotes })
120 }
121
122 #[must_use]
124 pub fn total_amount(&self) -> Amount {
125 self.quotes.iter().map(|q| q.amount).sum()
126 }
127
128 #[must_use]
133 pub fn paid_quote(&self) -> Option<&QuotePaymentInfo> {
134 self.quotes.get(MEDIAN_INDEX)
135 }
136
137 #[cfg(feature = "rpc")]
145 pub async fn pay(&self, wallet: &Wallet) -> Result<Vec<evmlib::common::TxHash>> {
146 let quote_payments: Vec<_> = self
148 .quotes
149 .iter()
150 .map(|q| (q.quote_hash, q.rewards_address, q.amount))
151 .collect();
152
153 info!(
154 "Paying for {} quotes: 1 real ({} atto) + {} with 0 atto",
155 CLOSE_GROUP_SIZE,
156 self.total_amount(),
157 CLOSE_GROUP_SIZE - 1
158 );
159
160 let (tx_hashes, _gas_info) = wallet.pay_for_quotes(quote_payments).await.map_err(
161 |evmlib::wallet::PayForQuotesError(err, _)| {
162 Error::Payment(format!("Failed to pay for quotes: {err}"))
163 },
164 )?;
165
166 let mut result_hashes = Vec::new();
169 for quote_info in &self.quotes {
170 if quote_info.amount > Amount::ZERO {
171 let tx_hash = tx_hashes.get("e_info.quote_hash).ok_or_else(|| {
172 Error::Payment(format!(
173 "Missing transaction hash for non-zero quote {}",
174 quote_info.quote_hash
175 ))
176 })?;
177 result_hashes.push(*tx_hash);
178 }
179 }
180
181 info!(
182 "Payment successful: {} on-chain transactions",
183 result_hashes.len()
184 );
185
186 Ok(result_hashes)
187 }
188
189 #[cfg(feature = "rpc")]
205 pub async fn verify(&self, network: &EvmNetwork) -> Result<Amount> {
206 let median = self.quotes.get(MEDIAN_INDEX).ok_or_else(|| {
207 Error::Payment(format!(
208 "Missing median quote at index {MEDIAN_INDEX}: quotes array has only {} elements",
209 self.quotes.len()
210 ))
211 })?;
212 let median_price = median.price;
213 let expected_amount = median.amount;
214
215 if expected_amount == Amount::ZERO || median_price == Amount::ZERO {
220 return Err(Error::Payment(format!(
221 "Median quote has zero price/amount (price={median_price}, amount={expected_amount}); refusing to verify as paid"
222 )));
223 }
224
225 let tied_quotes: Vec<&QuotePaymentInfo> = self
227 .quotes
228 .iter()
229 .filter(|q| q.price == median_price)
230 .collect();
231
232 info!(
233 "Verifying median quote payment: expected at least {expected_amount} atto, {} quote(s) tied at median price",
234 tied_quotes.len()
235 );
236
237 let provider = evmlib::utils::http_provider(network.rpc_url().clone());
238 let vault_address = *network.payment_vault_address();
239 let contract =
240 evmlib::contract::payment_vault::interface::IPaymentVault::new(vault_address, provider);
241
242 for candidate in &tied_quotes {
244 let result = contract
245 .completedPayments(candidate.quote_hash)
246 .call()
247 .await
248 .map_err(|e| Error::Payment(format!("completedPayments lookup failed: {e}")))?;
249
250 let on_chain_amount = Amount::from(result.amount);
251
252 if on_chain_amount >= expected_amount {
253 info!("Payment verified: {on_chain_amount} atto paid for median-priced quote");
254 return Ok(on_chain_amount);
255 }
256 }
257
258 Err(Error::Payment(format!(
259 "No median-priced quote was paid enough: expected at least {expected_amount}, checked {} tied quote(s)",
260 tied_quotes.len()
261 )))
262 }
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268 #[cfg(feature = "native")]
269 use alloy::node_bindings::{Anvil, AnvilInstance};
270 #[cfg(feature = "native")]
271 use evmlib::testnet::{deploy_network_token_contract, deploy_payment_vault_contract, Testnet};
272 #[cfg(feature = "native")]
273 use evmlib::transaction_config::TransactionConfig;
274 #[cfg(feature = "native")]
275 use evmlib::utils::{dummy_address, dummy_hash};
276 #[cfg(feature = "native")]
277 use evmlib::wallet::Wallet;
278 #[cfg(feature = "native")]
279 use serial_test::serial;
280 use std::time::SystemTime;
281 #[cfg(feature = "native")]
282 use url::Url;
283 use xor_name::XorName;
284
285 fn make_test_quote(rewards_addr_seed: u8) -> PaymentQuote {
286 PaymentQuote {
287 content: XorName::random(&mut rand::thread_rng()),
288 timestamp: SystemTime::now(),
289 price: Amount::from(1u64),
290 rewards_address: RewardsAddress::new([rewards_addr_seed; 20]),
291 pub_key: vec![],
292 signature: vec![],
293 committed_key_count: 0,
294 commitment_pin: None,
295 }
296 }
297
298 #[allow(clippy::expect_used, clippy::panic)]
304 #[cfg(feature = "native")]
305 fn start_node_with_timeout() -> (AnvilInstance, Url) {
306 const ANVIL_TIMEOUT_MS: u64 = 60_000; let host = std::env::var("ANVIL_IP_ADDR").unwrap_or_else(|_| "localhost".to_string());
309
310 let anvil = Anvil::new()
313 .timeout(ANVIL_TIMEOUT_MS)
314 .try_spawn()
315 .unwrap_or_else(|_| panic!("Could not spawn Anvil node after {ANVIL_TIMEOUT_MS}ms"));
316
317 let url = Url::parse(&format!("http://{host}:{}", anvil.port()))
318 .expect("Failed to parse Anvil URL");
319
320 (anvil, url)
321 }
322
323 #[cfg(feature = "native")]
325 #[tokio::test]
326 #[serial]
327 #[allow(clippy::expect_used)]
328 async fn test_standard_quote_payment() {
329 let (node, rpc_url) = start_node_with_timeout();
331 let network_token = deploy_network_token_contract(&rpc_url, &node)
332 .await
333 .expect("deploy network token");
334 let mut payment_vault =
335 deploy_payment_vault_contract(&rpc_url, &node, *network_token.contract.address())
336 .await
337 .expect("deploy data payments");
338
339 let transaction_config = TransactionConfig::default();
340
341 let mut quote_payments = vec![];
343 for _ in 0..CLOSE_GROUP_SIZE {
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 for (quote_hash, _reward_address, amount) in "e_payments {
375 let result = payment_vault
376 .contract
377 .completedPayments(*quote_hash)
378 .call()
379 .await
380 .expect("completedPayments lookup failed");
381
382 let on_chain_amount = result.amount;
383 assert!(
384 on_chain_amount >= u128::try_from(*amount).expect("amount fits u128"),
385 "On-chain amount should be >= paid amount"
386 );
387 }
388
389 println!("✓ All {CLOSE_GROUP_SIZE} payments verified successfully");
390 println!("\n✅ Standard {CLOSE_GROUP_SIZE}-quote payment works!");
391 }
392
393 #[cfg(feature = "native")]
395 #[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)
401 .await
402 .expect("deploy network token");
403 let mut payment_vault =
404 deploy_payment_vault_contract(&rpc_url, &node, *network_token.contract.address())
405 .await
406 .expect("deploy data payments");
407
408 let transaction_config = TransactionConfig::default();
409
410 let real_quote_hash = dummy_hash();
412 let real_reward_address = dummy_address();
413 let real_amount = Amount::from(3u64); let mut quote_payments = vec![(real_quote_hash, real_reward_address, real_amount)];
416
417 for _ in 0..CLOSE_GROUP_SIZE - 1 {
419 let dummy_quote_hash = dummy_hash();
420 let dummy_reward_address = dummy_address();
421 let dummy_amount = Amount::from(0u64); quote_payments.push((dummy_quote_hash, dummy_reward_address, dummy_amount));
423 }
424
425 network_token
427 .approve(
428 *payment_vault.contract.address(),
429 evmlib::common::U256::MAX,
430 &transaction_config,
431 )
432 .await
433 .expect("Failed to approve");
434
435 println!("✓ Approved tokens");
436
437 payment_vault.set_provider(network_token.contract.provider().clone());
439
440 let result = payment_vault
442 .pay_for_quotes(quote_payments.clone(), &transaction_config)
443 .await;
444
445 assert!(result.is_ok(), "Payment failed: {:?}", result.err());
446 println!(
447 "✓ Paid: 1 real (3 atto) + {} dummy (0 atto)",
448 CLOSE_GROUP_SIZE - 1
449 );
450
451 let real_result = payment_vault
455 .contract
456 .completedPayments(real_quote_hash)
457 .call()
458 .await
459 .expect("completedPayments lookup failed");
460
461 assert!(
462 real_result.amount > 0,
463 "Real payment should have non-zero amount on-chain"
464 );
465 println!("✓ Real payment verified (3 atto)");
466
467 for (i, (hash, _, _)) in quote_payments.iter().skip(1).enumerate() {
469 let result = payment_vault
470 .contract
471 .completedPayments(*hash)
472 .call()
473 .await
474 .expect("completedPayments lookup failed");
475
476 println!(" Dummy payment {}: amount={}", i + 1, result.amount);
477 }
478
479 println!("\n✅ SingleNode payment strategy works!");
480 }
481
482 #[test]
483 #[allow(clippy::unwrap_used)]
484 fn test_from_quotes_median_selection() {
485 let prices: Vec<u64> = vec![50, 30, 10, 40, 20, 60, 70];
486 let mut quotes_with_prices = Vec::new();
487
488 for price in &prices {
489 let quote = PaymentQuote {
490 content: XorName::random(&mut rand::thread_rng()),
491 timestamp: SystemTime::now(),
492 price: Amount::from(*price),
493 rewards_address: RewardsAddress::new([1u8; 20]),
494 pub_key: vec![],
495 signature: vec![],
496 committed_key_count: 0,
497 commitment_pin: None,
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(120u64));
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(120u64));
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 7"));
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_wrong_count_six() {
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 7"));
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_tied_median_prices_all_share_median_price() {
595 let prices = [10u64, 20, 30, 30, 30, 40, 50];
597 let mut quotes_with_prices = Vec::new();
598
599 for (i, price) in prices.iter().enumerate() {
600 let quote = PaymentQuote {
601 content: XorName::random(&mut rand::thread_rng()),
602 timestamp: SystemTime::now(),
603 price: Amount::from(*price),
604 #[allow(clippy::cast_possible_truncation)] rewards_address: RewardsAddress::new([i as u8 + 1; 20]),
606 pub_key: vec![],
607 signature: vec![],
608 committed_key_count: 0,
609 commitment_pin: None,
610 };
611 quotes_with_prices.push((quote, Amount::from(*price)));
612 }
613
614 let payment = SingleNodePayment::from_quotes(quotes_with_prices).unwrap();
615
616 let tied_count = payment
618 .quotes
619 .iter()
620 .filter(|q| q.price == Amount::from(30u64))
621 .count();
622 assert_eq!(tied_count, 3, "Should have 3 quotes tied at median price");
623
624 assert_eq!(payment.quotes[MEDIAN_INDEX].amount, Amount::from(90u64));
626 assert_eq!(payment.total_amount(), Amount::from(90u64));
627 }
628
629 #[test]
630 #[allow(clippy::unwrap_used)]
631 fn test_total_amount_equals_3x_median() {
632 let prices = [100u64, 200, 300, 400, 500, 600, 700];
633 let quotes: Vec<_> = prices
634 .iter()
635 .map(|price| (make_test_quote(1), Amount::from(*price)))
636 .collect();
637
638 let payment = SingleNodePayment::from_quotes(quotes).unwrap();
639 assert_eq!(payment.total_amount(), Amount::from(1200u64));
641 }
642
643 #[cfg(feature = "native")]
651 #[tokio::test]
652 #[serial]
653 #[allow(clippy::expect_used)]
654 async fn verify_rejects_zero_median_price() -> Result<()> {
655 let testnet = Testnet::new()
656 .await
657 .map_err(|e| Error::Payment(format!("Failed to start testnet: {e}")))?;
658 let network = testnet.to_network();
659
660 let quotes_with_prices: Vec<_> = (0..CLOSE_GROUP_SIZE)
662 .map(|_| (make_test_quote(1), Amount::ZERO))
663 .collect();
664 let payment = SingleNodePayment::from_quotes(quotes_with_prices)?;
665
666 assert_eq!(payment.quotes[MEDIAN_INDEX].amount, Amount::ZERO);
667
668 let err = payment
669 .verify(&network)
670 .await
671 .expect_err("verify must reject zero-priced median");
672 let msg = format!("{err}");
673 assert!(
674 msg.contains("zero price"),
675 "unexpected error message: {msg}"
676 );
677 Ok(())
678 }
679
680 #[cfg(feature = "native")]
682 #[tokio::test]
683 #[serial]
684 async fn test_single_node_with_real_prices() -> Result<()> {
685 let testnet = Testnet::new()
687 .await
688 .map_err(|e| Error::Payment(format!("Failed to start testnet: {e}")))?;
689 let network = testnet.to_network();
690 let wallet_key = testnet
691 .default_wallet_private_key()
692 .map_err(|e| Error::Payment(format!("Failed to get wallet key: {e}")))?;
693 let wallet = Wallet::new_from_private_key(network.clone(), &wallet_key)
694 .map_err(|e| Error::Payment(format!("Failed to create wallet: {e}")))?;
695
696 println!("✓ Started Anvil testnet");
697
698 wallet
700 .approve_to_spend_tokens(*network.payment_vault_address(), evmlib::common::U256::MAX)
701 .await
702 .map_err(|e| Error::Payment(format!("Failed to approve tokens: {e}")))?;
703
704 println!("✓ Approved tokens");
705
706 let chunk_xor = XorName::random(&mut rand::thread_rng());
708
709 let mut quotes_with_prices = Vec::new();
714 for i in 0..CLOSE_GROUP_SIZE {
715 #[allow(clippy::cast_possible_truncation)]
716 let price = Amount::from(100u64 + i as u64);
717
718 let quote = PaymentQuote {
719 content: chunk_xor,
720 timestamp: SystemTime::now(),
721 price,
722 rewards_address: wallet.address(),
723 pub_key: vec![],
724 signature: vec![],
725 committed_key_count: 0,
726 commitment_pin: None,
727 };
728
729 quotes_with_prices.push((quote, price));
730 }
731
732 println!("✓ Got {CLOSE_GROUP_SIZE} quotes with calculated prices");
733
734 let payment = SingleNodePayment::from_quotes(quotes_with_prices)?;
736
737 let median_price = payment
738 .paid_quote()
739 .ok_or_else(|| Error::Payment("Missing paid quote at median index".to_string()))?
740 .amount
741 .checked_div(Amount::from(3u64))
742 .ok_or_else(|| Error::Payment("Failed to calculate median price".to_string()))?;
743 println!("✓ Sorted and selected median price: {median_price} atto");
744
745 assert_eq!(payment.quotes.len(), CLOSE_GROUP_SIZE);
746 let median_amount = payment
747 .quotes
748 .get(MEDIAN_INDEX)
749 .ok_or_else(|| {
750 Error::Payment(format!(
751 "Index out of bounds: tried to access median index {} but quotes array has {} elements",
752 MEDIAN_INDEX,
753 payment.quotes.len()
754 ))
755 })?
756 .amount;
757 assert_eq!(
758 payment.total_amount(),
759 median_amount,
760 "Only median should have non-zero amount"
761 );
762
763 println!(
764 "✓ Created SingleNode payment: {} atto total (3x median)",
765 payment.total_amount()
766 );
767
768 let tx_hashes = payment.pay(&wallet).await?;
770 println!("✓ Payment successful: {} transactions", tx_hashes.len());
771
772 let verified_amount = payment.verify(&network).await?;
774 let expected_median_amount = payment.quotes[MEDIAN_INDEX].amount;
775
776 assert_eq!(
777 verified_amount, expected_median_amount,
778 "Verified amount should match median payment"
779 );
780
781 println!("✓ Payment verified: {verified_amount} atto");
782 println!("\n✅ Complete SingleNode flow with real prices works!");
783
784 Ok(())
785 }
786}