Skip to main content

ant_node/payment/
single_node.rs

1//! `SingleNode` payment mode implementation for ant-node.
2//!
3//! This module implements the `SingleNode` payment strategy from autonomi:
4//! - Client gets `CLOSE_GROUP_SIZE` quotes from network
5//! - Sort by price and select median (index `CLOSE_GROUP_SIZE / 2`)
6//! - Pay ONLY the median-priced node with 3x the quoted amount
7//! - Other nodes get `Amount::ZERO`
8//! - All are submitted for payment and verification
9//!
10//! Total cost is the same as Standard mode (3x), but with one actual payment.
11//! This saves gas fees while maintaining the same total payment amount.
12
13use 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
22/// Index of the median-priced node after sorting, derived from `CLOSE_GROUP_SIZE`.
23const MEDIAN_INDEX: usize = CLOSE_GROUP_SIZE / 2;
24
25/// Single node payment structure for a chunk.
26///
27/// Contains exactly `CLOSE_GROUP_SIZE` quotes where only the median-priced one
28/// receives payment (3x), and the remaining quotes have `Amount::ZERO`.
29///
30/// The fixed-size array ensures compile-time enforcement of the quote count,
31/// making the median index always valid.
32#[derive(Debug, Clone)]
33pub struct SingleNodePayment {
34    /// All quotes (sorted by price) - fixed size ensures median index is always valid
35    pub quotes: [QuotePaymentInfo; CLOSE_GROUP_SIZE],
36}
37
38/// Information about a single quote payment
39#[derive(Debug, Clone)]
40pub struct QuotePaymentInfo {
41    /// The quote hash
42    pub quote_hash: QuoteHash,
43    /// The rewards address
44    pub rewards_address: RewardsAddress,
45    /// The amount to pay (3x for median, 0 for others)
46    pub amount: Amount,
47    /// The original quoted price (before 3x multiplier)
48    pub price: Amount,
49}
50
51impl SingleNodePayment {
52    /// Create a `SingleNode` payment from `CLOSE_GROUP_SIZE` quotes and their prices.
53    ///
54    /// The quotes are automatically sorted by price (cheapest first).
55    /// The median (index `CLOSE_GROUP_SIZE / 2`) gets 3x its quote price.
56    /// The others get `Amount::ZERO`.
57    ///
58    /// # Arguments
59    ///
60    /// * `quotes_with_prices` - Vec of (`PaymentQuote`, Amount) tuples (will be sorted internally)
61    ///
62    /// # Errors
63    ///
64    /// Returns error if not exactly `CLOSE_GROUP_SIZE` quotes are provided.
65    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        // Sort by price (cheapest first) to ensure correct median selection
74        quotes_with_prices.sort_by_key(|(_, price)| *price);
75
76        // Get median price and calculate 3x
77        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        // Build quote payment info for all CLOSE_GROUP_SIZE quotes
92        // Use try_from to convert Vec to fixed-size array
93        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        // Convert Vec to array - we already validated length is CLOSE_GROUP_SIZE
109        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    /// Get the total payment amount (should be 3x median price)
117    #[must_use]
118    pub fn total_amount(&self) -> Amount {
119        self.quotes.iter().map(|q| q.amount).sum()
120    }
121
122    /// Get the median quote that receives payment.
123    ///
124    /// Returns `None` only if the internal array is somehow shorter than `MEDIAN_INDEX`,
125    /// which should never happen since the array is fixed-size `[_; CLOSE_GROUP_SIZE]`.
126    #[must_use]
127    pub fn paid_quote(&self) -> Option<&QuotePaymentInfo> {
128        self.quotes.get(MEDIAN_INDEX)
129    }
130
131    /// Pay for all quotes on-chain using the wallet.
132    ///
133    /// Pays 3x to the median quote and 0 to the others.
134    ///
135    /// # Errors
136    ///
137    /// Returns an error if the payment transaction fails.
138    pub async fn pay(&self, wallet: &Wallet) -> Result<Vec<evmlib::common::TxHash>> {
139        // Build quote payments: (QuoteHash, RewardsAddress, Amount)
140        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        // Collect transaction hashes only for non-zero amount quotes
160        // Zero-amount quotes don't generate on-chain transactions
161        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(&quote_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    /// Verify that a median-priced quote was paid at least 3× its price on-chain.
183    ///
184    /// When multiple quotes share the median price (a tie), the client and
185    /// verifier may sort them in different order. This method checks all
186    /// quotes tied at the median price and accepts the payment if any one
187    /// of them was paid the correct amount.
188    ///
189    /// # Returns
190    ///
191    /// The on-chain payment amount for the verified quote.
192    ///
193    /// # Errors
194    ///
195    /// Returns an error if the on-chain lookup fails or none of the
196    /// median-priced quotes were paid at least 3× the median price.
197    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        // Collect all quotes tied at the median price
203        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        // Check each tied quote — accept if any one was paid correctly
220        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    /// Start an Anvil node with increased timeout for CI environments.
267    ///
268    /// The default timeout is 10 seconds which can be insufficient in CI.
269    /// This helper uses a 60-second timeout and random port assignment
270    /// to handle slower CI environments and parallel test execution.
271    #[allow(clippy::expect_used, clippy::panic)]
272    fn start_node_with_timeout() -> (AnvilInstance, Url) {
273        const ANVIL_TIMEOUT_MS: u64 = 60_000; // 60 seconds for CI
274
275        let host = std::env::var("ANVIL_IP_ADDR").unwrap_or_else(|_| "localhost".to_string());
276
277        // Use port 0 to let the OS assign a random available port.
278        // This prevents port conflicts when running tests in parallel.
279        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    /// Test: Standard `CLOSE_GROUP_SIZE`-quote payment verification (autonomi baseline)
291    #[tokio::test]
292    #[serial]
293    #[allow(clippy::expect_used)]
294    async fn test_standard_quote_payment() {
295        // Use autonomi's setup pattern with increased timeout for CI
296        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        // Create CLOSE_GROUP_SIZE random quote payments (autonomi pattern)
308        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        // Approve tokens
317        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        // CRITICAL: Set provider to same as network token
329        payment_vault.set_provider(network_token.contract.provider().clone());
330
331        // Pay for quotes
332        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        // Verify payments via completedPayments mapping
340        for (quote_hash, _reward_address, amount) in &quote_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    /// Test: `SingleNode` payment strategy (1 real + N-1 dummy payments)
360    #[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        // Create CLOSE_GROUP_SIZE payments: 1 real (3x) + rest dummy (0x)
376        let real_quote_hash = dummy_hash();
377        let real_reward_address = dummy_address();
378        let real_amount = Amount::from(3u64); // 3x amount
379
380        let mut quote_payments = vec![(real_quote_hash, real_reward_address, real_amount)];
381
382        // Add dummy payments with 0 amount for remaining close group members
383        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); // 0 amount
387            quote_payments.push((dummy_quote_hash, dummy_reward_address, dummy_amount));
388        }
389
390        // Approve tokens
391        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        // Set provider
403        payment_vault.set_provider(network_token.contract.provider().clone());
404
405        // Pay (1 real payment of 3 atto + N-1 dummy payments of 0 atto)
406        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        // Verify via completedPayments mapping
417
418        // Check that real payment is recorded on-chain
419        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        // Check dummy payments (should have 0 amount)
433        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        // After sorting by price: 10, 20, 30, 40, 50, 60, 70
468        // Median (index 3) = 40, paid amount = 3 * 40 = 120
469        let median_quote = payment.quotes.get(MEDIAN_INDEX).unwrap();
470        assert_eq!(median_quote.amount, Amount::from(120u64));
471
472        // Other 6 quotes should have Amount::ZERO
473        for (i, q) in payment.quotes.iter().enumerate() {
474            if i != MEDIAN_INDEX {
475                assert_eq!(q.amount, Amount::ZERO);
476            }
477        }
478
479        // Total should be 3 * median price = 120
480        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        // The paid quote should have a non-zero amount
532        assert!(paid.amount > Amount::ZERO);
533
534        // Total amount should equal the paid quote's amount
535        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        // Verify all quotes are present (sorting doesn't lose data)
549        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        // Prices: 10, 20, 30, 30, 30, 40, 50 — three quotes tied at median price 30
559        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)] // i is always < 7
568                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        // All three tied quotes should have price == 30
578        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        // Only the median index gets the 3x amount
586        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        // Sorted: 100, 200, 300, 400, 500, 600, 700 — median = 400, total = 3 * 400 = 1200
601        assert_eq!(payment.total_amount(), Amount::from(1200u64));
602    }
603
604    /// Test: Complete `SingleNode` flow with real contract prices
605    #[tokio::test]
606    #[serial]
607    async fn test_single_node_with_real_prices() -> Result<()> {
608        // Setup testnet
609        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        // Approve tokens
622        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        // Create CLOSE_GROUP_SIZE quotes with prices calculated from record counts
630        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        // Create SingleNode payment (will sort internally and select median)
652        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        // Pay on-chain
686        let tx_hashes = payment.pay(&wallet).await?;
687        println!("✓ Payment successful: {} transactions", tx_hashes.len());
688
689        // Verify median quote payment — all nodes run this same check
690        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}