1use crate::ant_protocol::CLOSE_GROUP_SIZE;
14use crate::error::{Error, Result};
15use crate::logging::info;
16use evmlib::common::{Amount, QuoteHash};
17use evmlib::wallet::Wallet;
18use evmlib::Network as EvmNetwork;
19use evmlib::PaymentQuote;
20use evmlib::RewardsAddress;
21
22const MEDIAN_INDEX: usize = CLOSE_GROUP_SIZE / 2;
24
25#[derive(Debug, Clone)]
33pub struct SingleNodePayment {
34 pub quotes: [QuotePaymentInfo; CLOSE_GROUP_SIZE],
36}
37
38#[derive(Debug, Clone)]
40pub struct QuotePaymentInfo {
41 pub quote_hash: QuoteHash,
43 pub rewards_address: RewardsAddress,
45 pub amount: Amount,
47 pub price: Amount,
49}
50
51impl SingleNodePayment {
52 pub fn from_quotes(mut quotes_with_prices: Vec<(PaymentQuote, Amount)>) -> Result<Self> {
66 let len = quotes_with_prices.len();
67 if len != CLOSE_GROUP_SIZE {
68 return Err(Error::Payment(format!(
69 "SingleNode payment requires exactly {CLOSE_GROUP_SIZE} quotes, got {len}"
70 )));
71 }
72
73 quotes_with_prices.sort_by_key(|(_, price)| *price);
75
76 let median_price = quotes_with_prices
78 .get(MEDIAN_INDEX)
79 .ok_or_else(|| {
80 Error::Payment(format!(
81 "Missing median quote at index {MEDIAN_INDEX}: expected {CLOSE_GROUP_SIZE} quotes but get() failed"
82 ))
83 })?
84 .1;
85 let enhanced_price = median_price
86 .checked_mul(Amount::from(3u64))
87 .ok_or_else(|| {
88 Error::Payment("Price overflow when calculating 3x median".to_string())
89 })?;
90
91 let quotes_vec: Vec<QuotePaymentInfo> = quotes_with_prices
94 .into_iter()
95 .enumerate()
96 .map(|(idx, (quote, price))| QuotePaymentInfo {
97 quote_hash: quote.hash(),
98 rewards_address: quote.rewards_address,
99 amount: if idx == MEDIAN_INDEX {
100 enhanced_price
101 } else {
102 Amount::ZERO
103 },
104 price,
105 })
106 .collect();
107
108 let quotes: [QuotePaymentInfo; CLOSE_GROUP_SIZE] = quotes_vec
110 .try_into()
111 .map_err(|_| Error::Payment("Failed to convert quotes to fixed array".to_string()))?;
112
113 Ok(Self { quotes })
114 }
115
116 #[must_use]
118 pub fn total_amount(&self) -> Amount {
119 self.quotes.iter().map(|q| q.amount).sum()
120 }
121
122 #[must_use]
127 pub fn paid_quote(&self) -> Option<&QuotePaymentInfo> {
128 self.quotes.get(MEDIAN_INDEX)
129 }
130
131 pub async fn pay(&self, wallet: &Wallet) -> Result<Vec<evmlib::common::TxHash>> {
139 let quote_payments: Vec<_> = self
141 .quotes
142 .iter()
143 .map(|q| (q.quote_hash, q.rewards_address, q.amount))
144 .collect();
145
146 info!(
147 "Paying for {} quotes: 1 real ({} atto) + {} with 0 atto",
148 CLOSE_GROUP_SIZE,
149 self.total_amount(),
150 CLOSE_GROUP_SIZE - 1
151 );
152
153 let (tx_hashes, _gas_info) = wallet.pay_for_quotes(quote_payments).await.map_err(
154 |evmlib::wallet::PayForQuotesError(err, _)| {
155 Error::Payment(format!("Failed to pay for quotes: {err}"))
156 },
157 )?;
158
159 let mut result_hashes = Vec::new();
162 for quote_info in &self.quotes {
163 if quote_info.amount > Amount::ZERO {
164 let tx_hash = tx_hashes.get("e_info.quote_hash).ok_or_else(|| {
165 Error::Payment(format!(
166 "Missing transaction hash for non-zero quote {}",
167 quote_info.quote_hash
168 ))
169 })?;
170 result_hashes.push(*tx_hash);
171 }
172 }
173
174 info!(
175 "Payment successful: {} on-chain transactions",
176 result_hashes.len()
177 );
178
179 Ok(result_hashes)
180 }
181
182 pub async fn verify(&self, network: &EvmNetwork) -> Result<Amount> {
198 let median = &self.quotes[MEDIAN_INDEX];
199 let median_price = median.price;
200 let expected_amount = median.amount;
201
202 let tied_quotes: Vec<&QuotePaymentInfo> = self
204 .quotes
205 .iter()
206 .filter(|q| q.price == median_price)
207 .collect();
208
209 info!(
210 "Verifying median quote payment: expected at least {expected_amount} atto, {} quote(s) tied at median price",
211 tied_quotes.len()
212 );
213
214 let provider = evmlib::utils::http_provider(network.rpc_url().clone());
215 let vault_address = *network.payment_vault_address();
216 let contract =
217 evmlib::contract::payment_vault::interface::IPaymentVault::new(vault_address, provider);
218
219 for candidate in &tied_quotes {
221 let result = contract
222 .completedPayments(candidate.quote_hash)
223 .call()
224 .await
225 .map_err(|e| Error::Payment(format!("completedPayments lookup failed: {e}")))?;
226
227 let on_chain_amount = Amount::from(result.amount);
228
229 if on_chain_amount >= expected_amount {
230 info!("Payment verified: {on_chain_amount} atto paid for median-priced quote");
231 return Ok(on_chain_amount);
232 }
233 }
234
235 Err(Error::Payment(format!(
236 "No median-priced quote was paid enough: expected at least {expected_amount}, checked {} tied quote(s)",
237 tied_quotes.len()
238 )))
239 }
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245 use alloy::node_bindings::{Anvil, AnvilInstance};
246 use evmlib::testnet::{deploy_network_token_contract, deploy_payment_vault_contract, Testnet};
247 use evmlib::transaction_config::TransactionConfig;
248 use evmlib::utils::{dummy_address, dummy_hash};
249 use evmlib::wallet::Wallet;
250 use reqwest::Url;
251 use serial_test::serial;
252 use std::time::SystemTime;
253 use xor_name::XorName;
254
255 fn make_test_quote(rewards_addr_seed: u8) -> PaymentQuote {
256 PaymentQuote {
257 content: XorName::random(&mut rand::thread_rng()),
258 timestamp: SystemTime::now(),
259 price: Amount::from(1u64),
260 rewards_address: RewardsAddress::new([rewards_addr_seed; 20]),
261 pub_key: vec![],
262 signature: vec![],
263 }
264 }
265
266 #[allow(clippy::expect_used, clippy::panic)]
272 fn start_node_with_timeout() -> (AnvilInstance, Url) {
273 const ANVIL_TIMEOUT_MS: u64 = 60_000; let host = std::env::var("ANVIL_IP_ADDR").unwrap_or_else(|_| "localhost".to_string());
276
277 let anvil = Anvil::new()
280 .timeout(ANVIL_TIMEOUT_MS)
281 .try_spawn()
282 .unwrap_or_else(|_| panic!("Could not spawn Anvil node after {ANVIL_TIMEOUT_MS}ms"));
283
284 let url = Url::parse(&format!("http://{host}:{}", anvil.port()))
285 .expect("Failed to parse Anvil URL");
286
287 (anvil, url)
288 }
289
290 #[tokio::test]
292 #[serial]
293 #[allow(clippy::expect_used)]
294 async fn test_standard_quote_payment() {
295 let (node, rpc_url) = start_node_with_timeout();
297 let network_token = deploy_network_token_contract(&rpc_url, &node)
298 .await
299 .expect("deploy network token");
300 let mut payment_vault =
301 deploy_payment_vault_contract(&rpc_url, &node, *network_token.contract.address())
302 .await
303 .expect("deploy data payments");
304
305 let transaction_config = TransactionConfig::default();
306
307 let mut quote_payments = vec![];
309 for _ in 0..CLOSE_GROUP_SIZE {
310 let quote_hash = dummy_hash();
311 let reward_address = dummy_address();
312 let amount = Amount::from(1u64);
313 quote_payments.push((quote_hash, reward_address, amount));
314 }
315
316 network_token
318 .approve(
319 *payment_vault.contract.address(),
320 evmlib::common::U256::MAX,
321 &transaction_config,
322 )
323 .await
324 .expect("Failed to approve");
325
326 println!("✓ Approved tokens");
327
328 payment_vault.set_provider(network_token.contract.provider().clone());
330
331 let result = payment_vault
333 .pay_for_quotes(quote_payments.clone(), &transaction_config)
334 .await;
335
336 assert!(result.is_ok(), "Payment failed: {:?}", result.err());
337 println!("✓ Paid for {} quotes", quote_payments.len());
338
339 for (quote_hash, _reward_address, amount) in "e_payments {
341 let result = payment_vault
342 .contract
343 .completedPayments(*quote_hash)
344 .call()
345 .await
346 .expect("completedPayments lookup failed");
347
348 let on_chain_amount = result.amount;
349 assert!(
350 on_chain_amount >= u128::try_from(*amount).expect("amount fits u128"),
351 "On-chain amount should be >= paid amount"
352 );
353 }
354
355 println!("✓ All {CLOSE_GROUP_SIZE} payments verified successfully");
356 println!("\n✅ Standard {CLOSE_GROUP_SIZE}-quote payment works!");
357 }
358
359 #[tokio::test]
361 #[serial]
362 #[allow(clippy::expect_used)]
363 async fn test_single_node_payment_strategy() {
364 let (node, rpc_url) = start_node_with_timeout();
365 let network_token = deploy_network_token_contract(&rpc_url, &node)
366 .await
367 .expect("deploy network token");
368 let mut payment_vault =
369 deploy_payment_vault_contract(&rpc_url, &node, *network_token.contract.address())
370 .await
371 .expect("deploy data payments");
372
373 let transaction_config = TransactionConfig::default();
374
375 let real_quote_hash = dummy_hash();
377 let real_reward_address = dummy_address();
378 let real_amount = Amount::from(3u64); let mut quote_payments = vec![(real_quote_hash, real_reward_address, real_amount)];
381
382 for _ in 0..CLOSE_GROUP_SIZE - 1 {
384 let dummy_quote_hash = dummy_hash();
385 let dummy_reward_address = dummy_address();
386 let dummy_amount = Amount::from(0u64); quote_payments.push((dummy_quote_hash, dummy_reward_address, dummy_amount));
388 }
389
390 network_token
392 .approve(
393 *payment_vault.contract.address(),
394 evmlib::common::U256::MAX,
395 &transaction_config,
396 )
397 .await
398 .expect("Failed to approve");
399
400 println!("✓ Approved tokens");
401
402 payment_vault.set_provider(network_token.contract.provider().clone());
404
405 let result = payment_vault
407 .pay_for_quotes(quote_payments.clone(), &transaction_config)
408 .await;
409
410 assert!(result.is_ok(), "Payment failed: {:?}", result.err());
411 println!(
412 "✓ Paid: 1 real (3 atto) + {} dummy (0 atto)",
413 CLOSE_GROUP_SIZE - 1
414 );
415
416 let real_result = payment_vault
420 .contract
421 .completedPayments(real_quote_hash)
422 .call()
423 .await
424 .expect("completedPayments lookup failed");
425
426 assert!(
427 real_result.amount > 0,
428 "Real payment should have non-zero amount on-chain"
429 );
430 println!("✓ Real payment verified (3 atto)");
431
432 for (i, (hash, _, _)) in quote_payments.iter().skip(1).enumerate() {
434 let result = payment_vault
435 .contract
436 .completedPayments(*hash)
437 .call()
438 .await
439 .expect("completedPayments lookup failed");
440
441 println!(" Dummy payment {}: amount={}", i + 1, result.amount);
442 }
443
444 println!("\n✅ SingleNode payment strategy works!");
445 }
446
447 #[test]
448 #[allow(clippy::unwrap_used)]
449 fn test_from_quotes_median_selection() {
450 let prices: Vec<u64> = vec![50, 30, 10, 40, 20, 60, 70];
451 let mut quotes_with_prices = Vec::new();
452
453 for price in &prices {
454 let quote = PaymentQuote {
455 content: XorName::random(&mut rand::thread_rng()),
456 timestamp: SystemTime::now(),
457 price: Amount::from(*price),
458 rewards_address: RewardsAddress::new([1u8; 20]),
459 pub_key: vec![],
460 signature: vec![],
461 };
462 quotes_with_prices.push((quote, Amount::from(*price)));
463 }
464
465 let payment = SingleNodePayment::from_quotes(quotes_with_prices).unwrap();
466
467 let median_quote = payment.quotes.get(MEDIAN_INDEX).unwrap();
470 assert_eq!(median_quote.amount, Amount::from(120u64));
471
472 for (i, q) in payment.quotes.iter().enumerate() {
474 if i != MEDIAN_INDEX {
475 assert_eq!(q.amount, Amount::ZERO);
476 }
477 }
478
479 assert_eq!(payment.total_amount(), Amount::from(120u64));
481 }
482
483 #[test]
484 fn test_from_quotes_wrong_count() {
485 let quotes: Vec<_> = (0..3)
486 .map(|_| (make_test_quote(1), Amount::from(10u64)))
487 .collect();
488 let result = SingleNodePayment::from_quotes(quotes);
489 assert!(result.is_err());
490 }
491
492 #[test]
493 #[allow(clippy::expect_used)]
494 fn test_from_quotes_zero_quotes() {
495 let result = SingleNodePayment::from_quotes(vec![]);
496 assert!(result.is_err());
497 let err_msg = format!("{}", result.expect_err("should fail"));
498 assert!(err_msg.contains("exactly 7"));
499 }
500
501 #[test]
502 fn test_from_quotes_one_quote() {
503 let result =
504 SingleNodePayment::from_quotes(vec![(make_test_quote(1), Amount::from(10u64))]);
505 assert!(result.is_err());
506 }
507
508 #[test]
509 #[allow(clippy::expect_used)]
510 fn test_from_quotes_wrong_count_six() {
511 let quotes: Vec<_> = (0..6)
512 .map(|_| (make_test_quote(1), Amount::from(10u64)))
513 .collect();
514 let result = SingleNodePayment::from_quotes(quotes);
515 assert!(result.is_err());
516 let err_msg = format!("{}", result.expect_err("should fail"));
517 assert!(err_msg.contains("exactly 7"));
518 }
519
520 #[test]
521 #[allow(clippy::unwrap_used)]
522 fn test_paid_quote_returns_median() {
523 let quotes: Vec<_> = (1u8..)
524 .take(CLOSE_GROUP_SIZE)
525 .map(|i| (make_test_quote(i), Amount::from(u64::from(i) * 10)))
526 .collect();
527
528 let payment = SingleNodePayment::from_quotes(quotes).unwrap();
529 let paid = payment.paid_quote().unwrap();
530
531 assert!(paid.amount > Amount::ZERO);
533
534 assert_eq!(payment.total_amount(), paid.amount);
536 }
537
538 #[test]
539 #[allow(clippy::unwrap_used)]
540 fn test_all_quotes_have_distinct_addresses() {
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
548 let mut addresses: Vec<_> = payment.quotes.iter().map(|q| q.rewards_address).collect();
550 addresses.sort();
551 addresses.dedup();
552 assert_eq!(addresses.len(), CLOSE_GROUP_SIZE);
553 }
554
555 #[test]
556 #[allow(clippy::unwrap_used)]
557 fn test_tied_median_prices_all_share_median_price() {
558 let prices = [10u64, 20, 30, 30, 30, 40, 50];
560 let mut quotes_with_prices = Vec::new();
561
562 for (i, price) in prices.iter().enumerate() {
563 let quote = PaymentQuote {
564 content: XorName::random(&mut rand::thread_rng()),
565 timestamp: SystemTime::now(),
566 price: Amount::from(*price),
567 #[allow(clippy::cast_possible_truncation)] rewards_address: RewardsAddress::new([i as u8 + 1; 20]),
569 pub_key: vec![],
570 signature: vec![],
571 };
572 quotes_with_prices.push((quote, Amount::from(*price)));
573 }
574
575 let payment = SingleNodePayment::from_quotes(quotes_with_prices).unwrap();
576
577 let tied_count = payment
579 .quotes
580 .iter()
581 .filter(|q| q.price == Amount::from(30u64))
582 .count();
583 assert_eq!(tied_count, 3, "Should have 3 quotes tied at median price");
584
585 assert_eq!(payment.quotes[MEDIAN_INDEX].amount, Amount::from(90u64));
587 assert_eq!(payment.total_amount(), Amount::from(90u64));
588 }
589
590 #[test]
591 #[allow(clippy::unwrap_used)]
592 fn test_total_amount_equals_3x_median() {
593 let prices = [100u64, 200, 300, 400, 500, 600, 700];
594 let quotes: Vec<_> = prices
595 .iter()
596 .map(|price| (make_test_quote(1), Amount::from(*price)))
597 .collect();
598
599 let payment = SingleNodePayment::from_quotes(quotes).unwrap();
600 assert_eq!(payment.total_amount(), Amount::from(1200u64));
602 }
603
604 #[tokio::test]
606 #[serial]
607 async fn test_single_node_with_real_prices() -> Result<()> {
608 let testnet = Testnet::new()
610 .await
611 .map_err(|e| Error::Payment(format!("Failed to start testnet: {e}")))?;
612 let network = testnet.to_network();
613 let wallet_key = testnet
614 .default_wallet_private_key()
615 .map_err(|e| Error::Payment(format!("Failed to get wallet key: {e}")))?;
616 let wallet = Wallet::new_from_private_key(network.clone(), &wallet_key)
617 .map_err(|e| Error::Payment(format!("Failed to create wallet: {e}")))?;
618
619 println!("✓ Started Anvil testnet");
620
621 wallet
623 .approve_to_spend_tokens(*network.payment_vault_address(), evmlib::common::U256::MAX)
624 .await
625 .map_err(|e| Error::Payment(format!("Failed to approve tokens: {e}")))?;
626
627 println!("✓ Approved tokens");
628
629 let chunk_xor = XorName::random(&mut rand::thread_rng());
631
632 let mut quotes_with_prices = Vec::new();
633 for i in 0..CLOSE_GROUP_SIZE {
634 let records_stored = 10 + i;
635 let price = crate::payment::pricing::calculate_price(records_stored);
636
637 let quote = PaymentQuote {
638 content: chunk_xor,
639 timestamp: SystemTime::now(),
640 price,
641 rewards_address: wallet.address(),
642 pub_key: vec![],
643 signature: vec![],
644 };
645
646 quotes_with_prices.push((quote, price));
647 }
648
649 println!("✓ Got {CLOSE_GROUP_SIZE} quotes with calculated prices");
650
651 let payment = SingleNodePayment::from_quotes(quotes_with_prices)?;
653
654 let median_price = payment
655 .paid_quote()
656 .ok_or_else(|| Error::Payment("Missing paid quote at median index".to_string()))?
657 .amount
658 .checked_div(Amount::from(3u64))
659 .ok_or_else(|| Error::Payment("Failed to calculate median price".to_string()))?;
660 println!("✓ Sorted and selected median price: {median_price} atto");
661
662 assert_eq!(payment.quotes.len(), CLOSE_GROUP_SIZE);
663 let median_amount = payment
664 .quotes
665 .get(MEDIAN_INDEX)
666 .ok_or_else(|| {
667 Error::Payment(format!(
668 "Index out of bounds: tried to access median index {} but quotes array has {} elements",
669 MEDIAN_INDEX,
670 payment.quotes.len()
671 ))
672 })?
673 .amount;
674 assert_eq!(
675 payment.total_amount(),
676 median_amount,
677 "Only median should have non-zero amount"
678 );
679
680 println!(
681 "✓ Created SingleNode payment: {} atto total (3x median)",
682 payment.total_amount()
683 );
684
685 let tx_hashes = payment.pay(&wallet).await?;
687 println!("✓ Payment successful: {} transactions", tx_hashes.len());
688
689 let verified_amount = payment.verify(&network).await?;
691 let expected_median_amount = payment.quotes[MEDIAN_INDEX].amount;
692
693 assert_eq!(
694 verified_amount, expected_median_amount,
695 "Verified amount should match median payment"
696 );
697
698 println!("✓ Payment verified: {verified_amount} atto");
699 println!("\n✅ Complete SingleNode flow with real prices works!");
700
701 Ok(())
702 }
703}