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