Skip to main content

ant_protocol/payment/
single_node.rs

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