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 5 quotes from network (`CLOSE_GROUP_SIZE`)
5//! - Sort by price and select median (index 2)
6//! - Pay ONLY the median-priced node with 3x the quoted amount
7//! - Other 4 nodes get `Amount::ZERO`
8//! - All 5 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 ant_evm::{Amount, PaymentQuote, QuoteHash, QuotingMetrics, RewardsAddress};
16use evmlib::contract::payment_vault;
17use evmlib::wallet::Wallet;
18use evmlib::Network as EvmNetwork;
19use tracing::info;
20
21/// Create zero-valued `QuotingMetrics` for payment verification.
22///
23/// The contract doesn't validate metric values, so we use zeroes.
24fn zero_quoting_metrics() -> QuotingMetrics {
25    QuotingMetrics {
26        data_size: 0,
27        data_type: 0,
28        close_records_stored: 0,
29        records_per_type: vec![],
30        max_records: 0,
31        received_payment_count: 0,
32        live_time: 0,
33        network_density: None,
34        network_size: None,
35    }
36}
37
38/// Index of the median-priced node after sorting
39const MEDIAN_INDEX: usize = 2;
40
41/// Single node payment structure for a chunk.
42///
43/// Contains exactly 5 quotes where only the median-priced one receives payment (3x),
44/// and the other 4 have `Amount::ZERO`.
45///
46/// The fixed-size array ensures compile-time enforcement of the 5-quote requirement,
47/// making the median index (2) always valid.
48#[derive(Debug, Clone)]
49pub struct SingleNodePayment {
50    /// All 5 quotes (sorted by price) - fixed size ensures median index is always valid
51    pub quotes: [QuotePaymentInfo; CLOSE_GROUP_SIZE],
52}
53
54/// Information about a single quote payment
55#[derive(Debug, Clone)]
56pub struct QuotePaymentInfo {
57    /// The quote hash
58    pub quote_hash: QuoteHash,
59    /// The rewards address
60    pub rewards_address: RewardsAddress,
61    /// The amount to pay (3x for median, 0 for others)
62    pub amount: Amount,
63    /// The quoting metrics
64    pub quoting_metrics: QuotingMetrics,
65}
66
67impl SingleNodePayment {
68    /// Create a `SingleNode` payment from 5 quotes and their prices.
69    ///
70    /// The quotes are automatically sorted by price (cheapest first).
71    /// The median (index 2) gets 3x its quote price.
72    /// The other 4 get `Amount::ZERO`.
73    ///
74    /// # Arguments
75    ///
76    /// * `quotes_with_prices` - Vec of (`PaymentQuote`, Amount) tuples (will be sorted internally)
77    ///
78    /// # Errors
79    ///
80    /// Returns error if not exactly 5 quotes are provided.
81    pub fn from_quotes(mut quotes_with_prices: Vec<(PaymentQuote, Amount)>) -> Result<Self> {
82        let len = quotes_with_prices.len();
83        if len != CLOSE_GROUP_SIZE {
84            return Err(Error::Payment(format!(
85                "SingleNode payment requires exactly {CLOSE_GROUP_SIZE} quotes, got {len}"
86            )));
87        }
88
89        // Sort by price (cheapest first) to ensure correct median selection
90        quotes_with_prices.sort_by_key(|(_, price)| *price);
91
92        // Get median price and calculate 3x
93        let median_price = quotes_with_prices
94            .get(MEDIAN_INDEX)
95            .ok_or_else(|| {
96                Error::Payment(format!(
97                    "Missing median quote at index {MEDIAN_INDEX}: expected {CLOSE_GROUP_SIZE} quotes but get() failed"
98                ))
99            })?
100            .1;
101        let enhanced_price = median_price
102            .checked_mul(Amount::from(3u64))
103            .ok_or_else(|| {
104                Error::Payment("Price overflow when calculating 3x median".to_string())
105            })?;
106
107        // Build quote payment info for all 5 quotes
108        // Use try_from to convert Vec to fixed-size array
109        let quotes_vec: Vec<QuotePaymentInfo> = quotes_with_prices
110            .into_iter()
111            .enumerate()
112            .map(|(idx, (quote, _))| QuotePaymentInfo {
113                quote_hash: quote.hash(),
114                rewards_address: quote.rewards_address,
115                amount: if idx == MEDIAN_INDEX {
116                    enhanced_price
117                } else {
118                    Amount::ZERO
119                },
120                quoting_metrics: quote.quoting_metrics,
121            })
122            .collect();
123
124        // Convert Vec to array - we already validated length is CLOSE_GROUP_SIZE
125        let quotes: [QuotePaymentInfo; CLOSE_GROUP_SIZE] = quotes_vec
126            .try_into()
127            .map_err(|_| Error::Payment("Failed to convert quotes to fixed array".to_string()))?;
128
129        Ok(Self { quotes })
130    }
131
132    /// Get the total payment amount (should be 3x median price)
133    #[must_use]
134    pub fn total_amount(&self) -> Amount {
135        self.quotes.iter().map(|q| q.amount).sum()
136    }
137
138    /// Get the median quote that receives payment.
139    ///
140    /// Returns `None` only if the internal array is somehow shorter than `MEDIAN_INDEX`,
141    /// which should never happen since the array is fixed-size `[_; CLOSE_GROUP_SIZE]`.
142    #[must_use]
143    pub fn paid_quote(&self) -> Option<&QuotePaymentInfo> {
144        self.quotes.get(MEDIAN_INDEX)
145    }
146
147    /// Pay for all quotes on-chain using the wallet.
148    ///
149    /// Pays 3x to the median quote and 0 to the other 4.
150    ///
151    /// # Errors
152    ///
153    /// Returns an error if the payment transaction fails.
154    pub async fn pay(&self, wallet: &Wallet) -> Result<Vec<evmlib::common::TxHash>> {
155        // Build quote payments: (QuoteHash, RewardsAddress, Amount)
156        let quote_payments: Vec<_> = self
157            .quotes
158            .iter()
159            .map(|q| (q.quote_hash, q.rewards_address, q.amount))
160            .collect();
161
162        info!(
163            "Paying for {} quotes: 1 real ({} atto) + {} with 0 atto",
164            CLOSE_GROUP_SIZE,
165            self.total_amount(),
166            CLOSE_GROUP_SIZE - 1
167        );
168
169        let (tx_hashes, _gas_info) = wallet.pay_for_quotes(quote_payments).await.map_err(
170            |evmlib::wallet::PayForQuotesError(err, _)| {
171                Error::Payment(format!("Failed to pay for quotes: {err}"))
172            },
173        )?;
174
175        // Collect transaction hashes only for non-zero amount quotes
176        // Zero-amount quotes don't generate on-chain transactions
177        let mut result_hashes = Vec::new();
178        for quote_info in &self.quotes {
179            if quote_info.amount > Amount::ZERO {
180                let tx_hash = tx_hashes.get(&quote_info.quote_hash).ok_or_else(|| {
181                    Error::Payment(format!(
182                        "Missing transaction hash for non-zero quote {}",
183                        quote_info.quote_hash
184                    ))
185                })?;
186                result_hashes.push(*tx_hash);
187            }
188        }
189
190        info!(
191            "Payment successful: {} on-chain transactions",
192            result_hashes.len()
193        );
194
195        Ok(result_hashes)
196    }
197
198    /// Verify all payments on-chain.
199    ///
200    /// This checks that all 5 payments were recorded on the blockchain.
201    /// The contract requires exactly 5 payment verifications.
202    ///
203    /// # Arguments
204    ///
205    /// * `network` - The EVM network to verify on
206    /// * `owned_quote_hash` - Optional quote hash that this node owns (expects to receive payment)
207    ///
208    /// # Returns
209    ///
210    /// The total verified payment amount received by owned quotes.
211    ///
212    /// # Errors
213    ///
214    /// Returns an error if verification fails or payment is invalid.
215    pub async fn verify(
216        &self,
217        network: &EvmNetwork,
218        owned_quote_hash: Option<QuoteHash>,
219    ) -> Result<Amount> {
220        // Build payment digest for all 5 quotes
221        // Each quote needs an owned QuotingMetrics (tuple requires ownership)
222        let payment_digest: Vec<_> = self
223            .quotes
224            .iter()
225            .map(|q| (q.quote_hash, zero_quoting_metrics(), q.rewards_address))
226            .collect();
227
228        // Mark owned quotes
229        let owned_quote_hashes = owned_quote_hash.map_or_else(Vec::new, |hash| vec![hash]);
230
231        info!(
232            "Verifying {} payments (owned: {})",
233            payment_digest.len(),
234            owned_quote_hashes.len()
235        );
236
237        let verified_amount =
238            payment_vault::verify_data_payment(network, owned_quote_hashes.clone(), payment_digest)
239                .await
240                .map_err(|e| Error::Payment(format!("Payment verification failed: {e}")))?;
241
242        if owned_quote_hashes.is_empty() {
243            info!("Payment verified as valid on-chain");
244        } else {
245            // If we own a quote, verify the amount matches
246            let expected = self
247                .quotes
248                .iter()
249                .find(|q| Some(q.quote_hash) == owned_quote_hash)
250                .ok_or_else(|| Error::Payment("Owned quote hash not found in payment".to_string()))?
251                .amount;
252
253            if verified_amount != expected {
254                return Err(Error::Payment(format!(
255                    "Payment amount mismatch: expected {expected}, verified {verified_amount}"
256                )));
257            }
258
259            info!("Payment verified: {verified_amount} atto received");
260        }
261
262        Ok(verified_amount)
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269    use alloy::node_bindings::{Anvil, AnvilInstance};
270    use evmlib::contract::payment_vault::interface;
271    use evmlib::quoting_metrics::QuotingMetrics;
272    use evmlib::testnet::{deploy_data_payments_contract, deploy_network_token_contract, Testnet};
273    use evmlib::transaction_config::TransactionConfig;
274    use evmlib::utils::{dummy_address, dummy_hash};
275    use evmlib::wallet::Wallet;
276    use reqwest::Url;
277    use serial_test::serial;
278    use std::time::SystemTime;
279    use xor_name::XorName;
280
281    fn make_test_quote(rewards_addr_seed: u8) -> PaymentQuote {
282        PaymentQuote {
283            content: XorName::random(&mut rand::thread_rng()),
284            timestamp: SystemTime::now(),
285            quoting_metrics: QuotingMetrics {
286                data_size: 1024,
287                data_type: 0,
288                close_records_stored: 0,
289                records_per_type: vec![],
290                max_records: 1000,
291                received_payment_count: 0,
292                live_time: 0,
293                network_density: None,
294                network_size: None,
295            },
296            rewards_address: RewardsAddress::new([rewards_addr_seed; 20]),
297            pub_key: vec![],
298            signature: vec![],
299        }
300    }
301
302    /// Start an Anvil node with increased timeout for CI environments.
303    ///
304    /// The default timeout is 10 seconds which can be insufficient in CI.
305    /// This helper uses a 60-second timeout and random port assignment
306    /// to handle slower CI environments and parallel test execution.
307    #[allow(clippy::expect_used, clippy::panic)]
308    fn start_node_with_timeout() -> (AnvilInstance, Url) {
309        const ANVIL_TIMEOUT_MS: u64 = 60_000; // 60 seconds for CI
310
311        let host = std::env::var("ANVIL_IP_ADDR").unwrap_or_else(|_| "localhost".to_string());
312
313        // Use port 0 to let the OS assign a random available port.
314        // This prevents port conflicts when running tests in parallel.
315        let anvil = Anvil::new()
316            .timeout(ANVIL_TIMEOUT_MS)
317            .try_spawn()
318            .unwrap_or_else(|_| panic!("Could not spawn Anvil node after {ANVIL_TIMEOUT_MS}ms"));
319
320        let url = Url::parse(&format!("http://{host}:{}", anvil.port()))
321            .expect("Failed to parse Anvil URL");
322
323        (anvil, url)
324    }
325
326    /// Test: Standard 5-quote payment verification (autonomi baseline)
327    #[tokio::test]
328    #[serial]
329    #[allow(clippy::expect_used)]
330    async fn test_standard_five_quote_payment() {
331        // Use autonomi's setup pattern with increased timeout for CI
332        let (node, rpc_url) = start_node_with_timeout();
333        let network_token = deploy_network_token_contract(&rpc_url, &node).await;
334        let mut payment_vault =
335            deploy_data_payments_contract(&rpc_url, &node, *network_token.contract.address()).await;
336
337        let transaction_config = TransactionConfig::default();
338
339        // Create 5 random quote payments (autonomi pattern)
340        let mut quote_payments = vec![];
341        for _ in 0..5 {
342            let quote_hash = dummy_hash();
343            let reward_address = dummy_address();
344            let amount = Amount::from(1u64);
345            quote_payments.push((quote_hash, reward_address, amount));
346        }
347
348        // Approve tokens
349        network_token
350            .approve(
351                *payment_vault.contract.address(),
352                evmlib::common::U256::MAX,
353                &transaction_config,
354            )
355            .await
356            .expect("Failed to approve");
357
358        println!("✓ Approved tokens");
359
360        // CRITICAL: Set provider to same as network token
361        payment_vault.set_provider(network_token.contract.provider().clone());
362
363        // Pay for quotes
364        let result = payment_vault
365            .pay_for_quotes(quote_payments.clone(), &transaction_config)
366            .await;
367
368        assert!(result.is_ok(), "Payment failed: {:?}", result.err());
369        println!("✓ Paid for {} quotes", quote_payments.len());
370
371        // Verify payments using handler directly
372        let payment_verifications: Vec<_> = quote_payments
373            .into_iter()
374            .map(|v| interface::IPaymentVault::PaymentVerification {
375                metrics: zero_quoting_metrics().into(),
376                rewardsAddress: v.1,
377                quoteHash: v.0,
378            })
379            .collect();
380
381        let results = payment_vault
382            .verify_payment(payment_verifications)
383            .await
384            .expect("Verify payment failed");
385
386        for result in results {
387            assert!(result.isValid, "Payment verification should be valid");
388        }
389
390        println!("✓ All 5 payments verified successfully");
391        println!("\n✅ Standard 5-quote payment works!");
392    }
393
394    /// Test: `SingleNode` payment strategy (1 real + 4 dummy payments)
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).await;
401        let mut payment_vault =
402            deploy_data_payments_contract(&rpc_url, &node, *network_token.contract.address()).await;
403
404        let transaction_config = TransactionConfig::default();
405
406        // CHANGE: Create 5 payments: 1 real (3x) + 4 dummy (0x)
407        let real_quote_hash = dummy_hash();
408        let real_reward_address = dummy_address();
409        let real_amount = Amount::from(3u64); // 3x amount
410
411        let mut quote_payments = vec![(real_quote_hash, real_reward_address, real_amount)];
412
413        // Add 4 dummy payments with 0 amount
414        for _ in 0..4 {
415            let dummy_quote_hash = dummy_hash();
416            let dummy_reward_address = dummy_address();
417            let dummy_amount = Amount::from(0u64); // 0 amount
418            quote_payments.push((dummy_quote_hash, dummy_reward_address, dummy_amount));
419        }
420
421        // Approve tokens
422        network_token
423            .approve(
424                *payment_vault.contract.address(),
425                evmlib::common::U256::MAX,
426                &transaction_config,
427            )
428            .await
429            .expect("Failed to approve");
430
431        println!("✓ Approved tokens");
432
433        // Set provider
434        payment_vault.set_provider(network_token.contract.provider().clone());
435
436        // Pay (1 real payment of 3 atto + 4 dummy payments of 0 atto)
437        let result = payment_vault
438            .pay_for_quotes(quote_payments.clone(), &transaction_config)
439            .await;
440
441        assert!(result.is_ok(), "Payment failed: {:?}", result.err());
442        println!("✓ Paid: 1 real (3 atto) + 4 dummy (0 atto)");
443
444        // Verify all 5 payments
445        let payment_verifications: Vec<_> = quote_payments
446            .into_iter()
447            .map(|v| interface::IPaymentVault::PaymentVerification {
448                metrics: zero_quoting_metrics().into(),
449                rewardsAddress: v.1,
450                quoteHash: v.0,
451            })
452            .collect();
453
454        let results = payment_vault
455            .verify_payment(payment_verifications)
456            .await
457            .expect("Verify payment failed");
458
459        // Check that real payment is valid
460        assert!(
461            results.first().is_some_and(|r| r.isValid),
462            "Real payment should be valid"
463        );
464        println!("✓ Real payment verified (3 atto)");
465
466        // Check dummy payments
467        for (i, result) in results.iter().skip(1).enumerate() {
468            println!("  Dummy payment {}: valid={}", i + 1, result.isValid);
469        }
470
471        println!("\n✅ SingleNode payment strategy works!");
472    }
473
474    #[test]
475    #[allow(clippy::unwrap_used)]
476    fn test_from_quotes_median_selection() {
477        let prices: Vec<u64> = vec![50, 30, 10, 40, 20];
478        let mut quotes_with_prices = Vec::new();
479
480        for price in &prices {
481            let quote = PaymentQuote {
482                content: XorName::random(&mut rand::thread_rng()),
483                timestamp: SystemTime::now(),
484                quoting_metrics: QuotingMetrics {
485                    data_size: 1024,
486                    data_type: 0,
487                    close_records_stored: 0,
488                    records_per_type: vec![(0, 10)],
489                    max_records: 1000,
490                    received_payment_count: 5,
491                    live_time: 3600,
492                    network_density: None,
493                    network_size: Some(100),
494                },
495                rewards_address: RewardsAddress::new([1u8; 20]),
496                pub_key: vec![],
497                signature: vec![],
498            };
499            quotes_with_prices.push((quote, Amount::from(*price)));
500        }
501
502        let payment = SingleNodePayment::from_quotes(quotes_with_prices).unwrap();
503
504        // After sorting by price: 10, 20, 30, 40, 50
505        // Median (index 2) = 30, paid amount = 3 * 30 = 90
506        let median_quote = payment.quotes.get(MEDIAN_INDEX).unwrap();
507        assert_eq!(median_quote.amount, Amount::from(90u64));
508
509        // Other 4 quotes should have Amount::ZERO
510        for (i, q) in payment.quotes.iter().enumerate() {
511            if i != MEDIAN_INDEX {
512                assert_eq!(q.amount, Amount::ZERO);
513            }
514        }
515
516        // Total should be 3 * median price = 90
517        assert_eq!(payment.total_amount(), Amount::from(90u64));
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 5"));
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_six_quotes() {
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 5"));
555    }
556
557    #[test]
558    #[allow(clippy::unwrap_used)]
559    fn test_paid_quote_returns_median() {
560        let quotes: Vec<_> = (0..5u8)
561            .map(|i| (make_test_quote(i + 1), Amount::from(u64::from(i + 1) * 10)))
562            .collect();
563
564        let payment = SingleNodePayment::from_quotes(quotes).unwrap();
565        let paid = payment.paid_quote().unwrap();
566
567        // The paid quote should have a non-zero amount
568        assert!(paid.amount > Amount::ZERO);
569
570        // Total amount should equal the paid quote's amount
571        assert_eq!(payment.total_amount(), paid.amount);
572    }
573
574    #[test]
575    #[allow(clippy::unwrap_used)]
576    fn test_all_quotes_have_distinct_addresses() {
577        let quotes: Vec<_> = (0..5u8)
578            .map(|i| (make_test_quote(i + 1), Amount::from(u64::from(i + 1) * 10)))
579            .collect();
580
581        let payment = SingleNodePayment::from_quotes(quotes).unwrap();
582
583        // Verify all 5 quotes are present (sorting doesn't lose data)
584        let mut addresses: Vec<_> = payment.quotes.iter().map(|q| q.rewards_address).collect();
585        addresses.sort();
586        addresses.dedup();
587        assert_eq!(addresses.len(), 5);
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];
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 — median = 300, total = 3 * 300 = 900
601        assert_eq!(payment.total_amount(), Amount::from(900u64));
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().await;
610        let network = testnet.to_network();
611        let wallet =
612            Wallet::new_from_private_key(network.clone(), &testnet.default_wallet_private_key())
613                .map_err(|e| Error::Payment(format!("Failed to create wallet: {e}")))?;
614
615        println!("✓ Started Anvil testnet");
616
617        // Approve tokens
618        wallet
619            .approve_to_spend_tokens(*network.data_payments_address(), evmlib::common::U256::MAX)
620            .await
621            .map_err(|e| Error::Payment(format!("Failed to approve tokens: {e}")))?;
622
623        println!("✓ Approved tokens");
624
625        // Create 5 quotes with real prices from contract
626        let chunk_xor = XorName::random(&mut rand::thread_rng());
627        let chunk_size = 1024usize;
628
629        let mut quotes_with_prices = Vec::new();
630        for i in 0..CLOSE_GROUP_SIZE {
631            let quoting_metrics = QuotingMetrics {
632                data_size: chunk_size,
633                data_type: 0,
634                close_records_stored: 10 + i,
635                records_per_type: vec![(
636                    0,
637                    u32::try_from(10 + i)
638                        .map_err(|e| Error::Payment(format!("Invalid record count: {e}")))?,
639                )],
640                max_records: 1000,
641                received_payment_count: 5,
642                live_time: 3600,
643                network_density: None,
644                network_size: Some(100),
645            };
646
647            // Get market price for this quote
648            // PERF-004: Clone required - payment_vault::get_market_price (external API from evmlib)
649            // takes ownership of Vec<QuotingMetrics>. We need quoting_metrics again below for
650            // PaymentQuote construction, so the clone is unavoidable.
651            let prices = payment_vault::get_market_price(&network, vec![quoting_metrics.clone()])
652                .await
653                .map_err(|e| Error::Payment(format!("Failed to get market price: {e}")))?;
654
655            let price = prices.first().ok_or_else(|| {
656                Error::Payment(format!(
657                    "Empty price list from get_market_price for quote {}: expected at least 1 price but got {} elements",
658                    i,
659                    prices.len()
660                ))
661            })?;
662
663            let quote = PaymentQuote {
664                content: chunk_xor,
665                timestamp: SystemTime::now(),
666                quoting_metrics,
667                rewards_address: wallet.address(),
668                pub_key: vec![],
669                signature: vec![],
670            };
671
672            quotes_with_prices.push((quote, *price));
673        }
674
675        println!("✓ Got 5 real quotes from contract");
676
677        // Create SingleNode payment (will sort internally and select median)
678        let payment = SingleNodePayment::from_quotes(quotes_with_prices)?;
679
680        let median_price = payment
681            .paid_quote()
682            .ok_or_else(|| Error::Payment("Missing paid quote at median index".to_string()))?
683            .amount
684            .checked_div(Amount::from(3u64))
685            .ok_or_else(|| Error::Payment("Failed to calculate median price".to_string()))?;
686        println!("✓ Sorted and selected median price: {median_price} atto");
687
688        assert_eq!(payment.quotes.len(), CLOSE_GROUP_SIZE);
689        let median_amount = payment
690            .quotes
691            .get(MEDIAN_INDEX)
692            .ok_or_else(|| {
693                Error::Payment(format!(
694                    "Index out of bounds: tried to access median index {} but quotes array has {} elements",
695                    MEDIAN_INDEX,
696                    payment.quotes.len()
697                ))
698            })?
699            .amount;
700        assert_eq!(
701            payment.total_amount(),
702            median_amount,
703            "Only median should have non-zero amount"
704        );
705
706        println!(
707            "✓ Created SingleNode payment: {} atto total (3x median)",
708            payment.total_amount()
709        );
710
711        // Pay on-chain
712        let tx_hashes = payment.pay(&wallet).await?;
713        println!("✓ Payment successful: {} transactions", tx_hashes.len());
714
715        // Verify payment (as owner of median quote)
716        let median_quote = payment
717            .quotes
718            .get(MEDIAN_INDEX)
719            .ok_or_else(|| {
720                Error::Payment(format!(
721                    "Index out of bounds: tried to access median index {} but quotes array has {} elements",
722                    MEDIAN_INDEX,
723                    payment.quotes.len()
724                ))
725            })?;
726        let median_quote_hash = median_quote.quote_hash;
727        let verified_amount = payment.verify(&network, Some(median_quote_hash)).await?;
728
729        assert_eq!(
730            verified_amount, median_quote.amount,
731            "Verified amount should match median payment"
732        );
733
734        println!("✓ Payment verified: {verified_amount} atto");
735        println!("\n✅ Complete SingleNode flow with real prices works!");
736
737        Ok(())
738    }
739}