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