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