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            committed_key_count: 0,
282            commitment_pin: None,
283        }
284    }
285
286    /// Start an Anvil node with increased timeout for CI environments.
287    ///
288    /// The default timeout is 10 seconds which can be insufficient in CI.
289    /// This helper uses a 60-second timeout and random port assignment
290    /// to handle slower CI environments and parallel test execution.
291    #[allow(clippy::expect_used, clippy::panic)]
292    fn start_node_with_timeout() -> (AnvilInstance, Url) {
293        const ANVIL_TIMEOUT_MS: u64 = 60_000; // 60 seconds for CI
294
295        let host = std::env::var("ANVIL_IP_ADDR").unwrap_or_else(|_| "localhost".to_string());
296
297        // Use port 0 to let the OS assign a random available port.
298        // This prevents port conflicts when running tests in parallel.
299        let anvil = Anvil::new()
300            .timeout(ANVIL_TIMEOUT_MS)
301            .try_spawn()
302            .unwrap_or_else(|_| panic!("Could not spawn Anvil node after {ANVIL_TIMEOUT_MS}ms"));
303
304        let url = Url::parse(&format!("http://{host}:{}", anvil.port()))
305            .expect("Failed to parse Anvil URL");
306
307        (anvil, url)
308    }
309
310    /// Test: Standard `CLOSE_GROUP_SIZE`-quote payment verification (autonomi baseline)
311    #[tokio::test]
312    #[serial]
313    #[allow(clippy::expect_used)]
314    async fn test_standard_quote_payment() {
315        // Use autonomi's setup pattern with increased timeout for CI
316        let (node, rpc_url) = start_node_with_timeout();
317        let network_token = deploy_network_token_contract(&rpc_url, &node)
318            .await
319            .expect("deploy network token");
320        let mut payment_vault =
321            deploy_payment_vault_contract(&rpc_url, &node, *network_token.contract.address())
322                .await
323                .expect("deploy data payments");
324
325        let transaction_config = TransactionConfig::default();
326
327        // Create CLOSE_GROUP_SIZE random quote payments (autonomi pattern)
328        let mut quote_payments = vec![];
329        for _ in 0..CLOSE_GROUP_SIZE {
330            let quote_hash = dummy_hash();
331            let reward_address = dummy_address();
332            let amount = Amount::from(1u64);
333            quote_payments.push((quote_hash, reward_address, amount));
334        }
335
336        // Approve tokens
337        network_token
338            .approve(
339                *payment_vault.contract.address(),
340                evmlib::common::U256::MAX,
341                &transaction_config,
342            )
343            .await
344            .expect("Failed to approve");
345
346        println!("✓ Approved tokens");
347
348        // CRITICAL: Set provider to same as network token
349        payment_vault.set_provider(network_token.contract.provider().clone());
350
351        // Pay for quotes
352        let result = payment_vault
353            .pay_for_quotes(quote_payments.clone(), &transaction_config)
354            .await;
355
356        assert!(result.is_ok(), "Payment failed: {:?}", result.err());
357        println!("✓ Paid for {} quotes", quote_payments.len());
358
359        // Verify payments via completedPayments mapping
360        for (quote_hash, _reward_address, amount) in &quote_payments {
361            let result = payment_vault
362                .contract
363                .completedPayments(*quote_hash)
364                .call()
365                .await
366                .expect("completedPayments lookup failed");
367
368            let on_chain_amount = result.amount;
369            assert!(
370                on_chain_amount >= u128::try_from(*amount).expect("amount fits u128"),
371                "On-chain amount should be >= paid amount"
372            );
373        }
374
375        println!("✓ All {CLOSE_GROUP_SIZE} payments verified successfully");
376        println!("\n✅ Standard {CLOSE_GROUP_SIZE}-quote payment works!");
377    }
378
379    /// Test: `SingleNode` payment strategy (1 real + N-1 dummy payments)
380    #[tokio::test]
381    #[serial]
382    #[allow(clippy::expect_used)]
383    async fn test_single_node_payment_strategy() {
384        let (node, rpc_url) = start_node_with_timeout();
385        let network_token = deploy_network_token_contract(&rpc_url, &node)
386            .await
387            .expect("deploy network token");
388        let mut payment_vault =
389            deploy_payment_vault_contract(&rpc_url, &node, *network_token.contract.address())
390                .await
391                .expect("deploy data payments");
392
393        let transaction_config = TransactionConfig::default();
394
395        // Create CLOSE_GROUP_SIZE payments: 1 real (3x) + rest dummy (0x)
396        let real_quote_hash = dummy_hash();
397        let real_reward_address = dummy_address();
398        let real_amount = Amount::from(3u64); // 3x amount
399
400        let mut quote_payments = vec![(real_quote_hash, real_reward_address, real_amount)];
401
402        // Add dummy payments with 0 amount for remaining close group members
403        for _ in 0..CLOSE_GROUP_SIZE - 1 {
404            let dummy_quote_hash = dummy_hash();
405            let dummy_reward_address = dummy_address();
406            let dummy_amount = Amount::from(0u64); // 0 amount
407            quote_payments.push((dummy_quote_hash, dummy_reward_address, dummy_amount));
408        }
409
410        // Approve tokens
411        network_token
412            .approve(
413                *payment_vault.contract.address(),
414                evmlib::common::U256::MAX,
415                &transaction_config,
416            )
417            .await
418            .expect("Failed to approve");
419
420        println!("✓ Approved tokens");
421
422        // Set provider
423        payment_vault.set_provider(network_token.contract.provider().clone());
424
425        // Pay (1 real payment of 3 atto + N-1 dummy payments of 0 atto)
426        let result = payment_vault
427            .pay_for_quotes(quote_payments.clone(), &transaction_config)
428            .await;
429
430        assert!(result.is_ok(), "Payment failed: {:?}", result.err());
431        println!(
432            "✓ Paid: 1 real (3 atto) + {} dummy (0 atto)",
433            CLOSE_GROUP_SIZE - 1
434        );
435
436        // Verify via completedPayments mapping
437
438        // Check that real payment is recorded on-chain
439        let real_result = payment_vault
440            .contract
441            .completedPayments(real_quote_hash)
442            .call()
443            .await
444            .expect("completedPayments lookup failed");
445
446        assert!(
447            real_result.amount > 0,
448            "Real payment should have non-zero amount on-chain"
449        );
450        println!("✓ Real payment verified (3 atto)");
451
452        // Check dummy payments (should have 0 amount)
453        for (i, (hash, _, _)) in quote_payments.iter().skip(1).enumerate() {
454            let result = payment_vault
455                .contract
456                .completedPayments(*hash)
457                .call()
458                .await
459                .expect("completedPayments lookup failed");
460
461            println!("  Dummy payment {}: amount={}", i + 1, result.amount);
462        }
463
464        println!("\n✅ SingleNode payment strategy works!");
465    }
466
467    #[test]
468    #[allow(clippy::unwrap_used)]
469    fn test_from_quotes_median_selection() {
470        let prices: Vec<u64> = vec![50, 30, 10, 40, 20, 60, 70];
471        let mut quotes_with_prices = Vec::new();
472
473        for price in &prices {
474            let quote = PaymentQuote {
475                content: XorName::random(&mut rand::thread_rng()),
476                timestamp: SystemTime::now(),
477                price: Amount::from(*price),
478                rewards_address: RewardsAddress::new([1u8; 20]),
479                pub_key: vec![],
480                signature: vec![],
481                committed_key_count: 0,
482                commitment_pin: None,
483            };
484            quotes_with_prices.push((quote, Amount::from(*price)));
485        }
486
487        let payment = SingleNodePayment::from_quotes(quotes_with_prices).unwrap();
488
489        // After sorting by price: 10, 20, 30, 40, 50, 60, 70
490        // Median (index 3) = 40, paid amount = 3 * 40 = 120
491        let median_quote = payment.quotes.get(MEDIAN_INDEX).unwrap();
492        assert_eq!(median_quote.amount, Amount::from(120u64));
493
494        // Other 6 quotes should have Amount::ZERO
495        for (i, q) in payment.quotes.iter().enumerate() {
496            if i != MEDIAN_INDEX {
497                assert_eq!(q.amount, Amount::ZERO);
498            }
499        }
500
501        // Total should be 3 * median price = 120
502        assert_eq!(payment.total_amount(), Amount::from(120u64));
503    }
504
505    #[test]
506    fn test_from_quotes_wrong_count() {
507        let quotes: Vec<_> = (0..3)
508            .map(|_| (make_test_quote(1), Amount::from(10u64)))
509            .collect();
510        let result = SingleNodePayment::from_quotes(quotes);
511        assert!(result.is_err());
512    }
513
514    #[test]
515    #[allow(clippy::expect_used)]
516    fn test_from_quotes_zero_quotes() {
517        let result = SingleNodePayment::from_quotes(vec![]);
518        assert!(result.is_err());
519        let err_msg = format!("{}", result.expect_err("should fail"));
520        assert!(err_msg.contains("exactly 7"));
521    }
522
523    #[test]
524    fn test_from_quotes_one_quote() {
525        let result =
526            SingleNodePayment::from_quotes(vec![(make_test_quote(1), Amount::from(10u64))]);
527        assert!(result.is_err());
528    }
529
530    #[test]
531    #[allow(clippy::expect_used)]
532    fn test_from_quotes_wrong_count_six() {
533        let quotes: Vec<_> = (0..6)
534            .map(|_| (make_test_quote(1), Amount::from(10u64)))
535            .collect();
536        let result = SingleNodePayment::from_quotes(quotes);
537        assert!(result.is_err());
538        let err_msg = format!("{}", result.expect_err("should fail"));
539        assert!(err_msg.contains("exactly 7"));
540    }
541
542    #[test]
543    #[allow(clippy::unwrap_used)]
544    fn test_paid_quote_returns_median() {
545        let quotes: Vec<_> = (1u8..)
546            .take(CLOSE_GROUP_SIZE)
547            .map(|i| (make_test_quote(i), Amount::from(u64::from(i) * 10)))
548            .collect();
549
550        let payment = SingleNodePayment::from_quotes(quotes).unwrap();
551        let paid = payment.paid_quote().unwrap();
552
553        // The paid quote should have a non-zero amount
554        assert!(paid.amount > Amount::ZERO);
555
556        // Total amount should equal the paid quote's amount
557        assert_eq!(payment.total_amount(), paid.amount);
558    }
559
560    #[test]
561    #[allow(clippy::unwrap_used)]
562    fn test_all_quotes_have_distinct_addresses() {
563        let quotes: Vec<_> = (1u8..)
564            .take(CLOSE_GROUP_SIZE)
565            .map(|i| (make_test_quote(i), Amount::from(u64::from(i) * 10)))
566            .collect();
567
568        let payment = SingleNodePayment::from_quotes(quotes).unwrap();
569
570        // Verify all quotes are present (sorting doesn't lose data)
571        let mut addresses: Vec<_> = payment.quotes.iter().map(|q| q.rewards_address).collect();
572        addresses.sort();
573        addresses.dedup();
574        assert_eq!(addresses.len(), CLOSE_GROUP_SIZE);
575    }
576
577    #[test]
578    #[allow(clippy::unwrap_used)]
579    fn test_tied_median_prices_all_share_median_price() {
580        // Prices: 10, 20, 30, 30, 30, 40, 50 — three quotes tied at median price 30
581        let prices = [10u64, 20, 30, 30, 30, 40, 50];
582        let mut quotes_with_prices = Vec::new();
583
584        for (i, price) in prices.iter().enumerate() {
585            let quote = PaymentQuote {
586                content: XorName::random(&mut rand::thread_rng()),
587                timestamp: SystemTime::now(),
588                price: Amount::from(*price),
589                #[allow(clippy::cast_possible_truncation)] // i is always < 7
590                rewards_address: RewardsAddress::new([i as u8 + 1; 20]),
591                pub_key: vec![],
592                signature: vec![],
593                committed_key_count: 0,
594                commitment_pin: None,
595            };
596            quotes_with_prices.push((quote, Amount::from(*price)));
597        }
598
599        let payment = SingleNodePayment::from_quotes(quotes_with_prices).unwrap();
600
601        // All three tied quotes should have price == 30
602        let tied_count = payment
603            .quotes
604            .iter()
605            .filter(|q| q.price == Amount::from(30u64))
606            .count();
607        assert_eq!(tied_count, 3, "Should have 3 quotes tied at median price");
608
609        // Only the median index gets the 3x amount
610        assert_eq!(payment.quotes[MEDIAN_INDEX].amount, Amount::from(90u64));
611        assert_eq!(payment.total_amount(), Amount::from(90u64));
612    }
613
614    #[test]
615    #[allow(clippy::unwrap_used)]
616    fn test_total_amount_equals_3x_median() {
617        let prices = [100u64, 200, 300, 400, 500, 600, 700];
618        let quotes: Vec<_> = prices
619            .iter()
620            .map(|price| (make_test_quote(1), Amount::from(*price)))
621            .collect();
622
623        let payment = SingleNodePayment::from_quotes(quotes).unwrap();
624        // Sorted: 100, 200, 300, 400, 500, 600, 700 — median = 400, total = 3 * 400 = 1200
625        assert_eq!(payment.total_amount(), Amount::from(1200u64));
626    }
627
628    /// Regression test: `verify()` must reject a payment where the median
629    /// quote has zero price (or zero paid amount). Otherwise the on-chain
630    /// `completedPayments >= 0` check would trivially succeed for any quote
631    /// and a malicious client could PUT free data.
632    ///
633    /// Uses a testnet only so `network` is a real `EvmNetwork`; the test
634    /// never reaches the RPC call because the zero-price guard short-circuits.
635    #[tokio::test]
636    #[serial]
637    #[allow(clippy::expect_used)]
638    async fn verify_rejects_zero_median_price() -> Result<()> {
639        let testnet = Testnet::new()
640            .await
641            .map_err(|e| Error::Payment(format!("Failed to start testnet: {e}")))?;
642        let network = testnet.to_network();
643
644        // 7 quotes all priced at zero — median is zero.
645        let quotes_with_prices: Vec<_> = (0..CLOSE_GROUP_SIZE)
646            .map(|_| (make_test_quote(1), Amount::ZERO))
647            .collect();
648        let payment = SingleNodePayment::from_quotes(quotes_with_prices)?;
649
650        assert_eq!(payment.quotes[MEDIAN_INDEX].amount, Amount::ZERO);
651
652        let err = payment
653            .verify(&network)
654            .await
655            .expect_err("verify must reject zero-priced median");
656        let msg = format!("{err}");
657        assert!(
658            msg.contains("zero price"),
659            "unexpected error message: {msg}"
660        );
661        Ok(())
662    }
663
664    /// Test: Complete `SingleNode` flow with real contract prices
665    #[tokio::test]
666    #[serial]
667    async fn test_single_node_with_real_prices() -> Result<()> {
668        // Setup testnet
669        let testnet = Testnet::new()
670            .await
671            .map_err(|e| Error::Payment(format!("Failed to start testnet: {e}")))?;
672        let network = testnet.to_network();
673        let wallet_key = testnet
674            .default_wallet_private_key()
675            .map_err(|e| Error::Payment(format!("Failed to get wallet key: {e}")))?;
676        let wallet = Wallet::new_from_private_key(network.clone(), &wallet_key)
677            .map_err(|e| Error::Payment(format!("Failed to create wallet: {e}")))?;
678
679        println!("✓ Started Anvil testnet");
680
681        // Approve tokens
682        wallet
683            .approve_to_spend_tokens(*network.payment_vault_address(), evmlib::common::U256::MAX)
684            .await
685            .map_err(|e| Error::Payment(format!("Failed to approve tokens: {e}")))?;
686
687        println!("✓ Approved tokens");
688
689        // Create CLOSE_GROUP_SIZE quotes with prices calculated from record counts
690        let chunk_xor = XorName::random(&mut rand::thread_rng());
691
692        // Prices are arbitrary but distinct so median selection is unambiguous.
693        // This test only exercises payment construction and on-chain
694        // verification; the real `calculate_price` (now in this crate's
695        // `payment::pricing`, per ADR-0004) is unit-tested there.
696        let mut quotes_with_prices = Vec::new();
697        for i in 0..CLOSE_GROUP_SIZE {
698            #[allow(clippy::cast_possible_truncation)]
699            let price = Amount::from(100u64 + i as u64);
700
701            let quote = PaymentQuote {
702                content: chunk_xor,
703                timestamp: SystemTime::now(),
704                price,
705                rewards_address: wallet.address(),
706                pub_key: vec![],
707                signature: vec![],
708                committed_key_count: 0,
709                commitment_pin: None,
710            };
711
712            quotes_with_prices.push((quote, price));
713        }
714
715        println!("✓ Got {CLOSE_GROUP_SIZE} quotes with calculated prices");
716
717        // Create SingleNode payment (will sort internally and select median)
718        let payment = SingleNodePayment::from_quotes(quotes_with_prices)?;
719
720        let median_price = payment
721            .paid_quote()
722            .ok_or_else(|| Error::Payment("Missing paid quote at median index".to_string()))?
723            .amount
724            .checked_div(Amount::from(3u64))
725            .ok_or_else(|| Error::Payment("Failed to calculate median price".to_string()))?;
726        println!("✓ Sorted and selected median price: {median_price} atto");
727
728        assert_eq!(payment.quotes.len(), CLOSE_GROUP_SIZE);
729        let median_amount = payment
730            .quotes
731            .get(MEDIAN_INDEX)
732            .ok_or_else(|| {
733                Error::Payment(format!(
734                    "Index out of bounds: tried to access median index {} but quotes array has {} elements",
735                    MEDIAN_INDEX,
736                    payment.quotes.len()
737                ))
738            })?
739            .amount;
740        assert_eq!(
741            payment.total_amount(),
742            median_amount,
743            "Only median should have non-zero amount"
744        );
745
746        println!(
747            "✓ Created SingleNode payment: {} atto total (3x median)",
748            payment.total_amount()
749        );
750
751        // Pay on-chain
752        let tx_hashes = payment.pay(&wallet).await?;
753        println!("✓ Payment successful: {} transactions", tx_hashes.len());
754
755        // Verify median quote payment — all nodes run this same check
756        let verified_amount = payment.verify(&network).await?;
757        let expected_median_amount = payment.quotes[MEDIAN_INDEX].amount;
758
759        assert_eq!(
760            verified_amount, expected_median_amount,
761            "Verified amount should match median payment"
762        );
763
764        println!("✓ Payment verified: {verified_amount} atto");
765        println!("\n✅ Complete SingleNode flow with real prices works!");
766
767        Ok(())
768    }
769}