From d66e1b3e32ffecc558c99384c4e3711f49633deb Mon Sep 17 00:00:00 2001 From: grumbach Date: Tue, 24 Feb 2026 19:00:17 +0900 Subject: [PATCH 1/7] feat: payments integration --- src/payment/mod.rs | 2 + src/payment/single_node.rs | 610 +++++++++++++++++++++++++++++++++++++ 2 files changed, 612 insertions(+) create mode 100644 src/payment/single_node.rs diff --git a/src/payment/mod.rs b/src/payment/mod.rs index 0f742875..309227dd 100644 --- a/src/payment/mod.rs +++ b/src/payment/mod.rs @@ -34,11 +34,13 @@ mod cache; pub mod metrics; pub mod quote; +pub mod single_node; mod verifier; pub mod wallet; pub use cache::{CacheStats, VerifiedCache}; pub use metrics::QuotingMetricsTracker; pub use quote::{verify_quote_content, QuoteGenerator, XorName}; +pub use single_node::SingleNodePayment; pub use verifier::{EvmVerifierConfig, PaymentStatus, PaymentVerifier, PaymentVerifierConfig}; pub use wallet::{is_valid_address, parse_rewards_address, WalletConfig}; diff --git a/src/payment/single_node.rs b/src/payment/single_node.rs new file mode 100644 index 00000000..411eae40 --- /dev/null +++ b/src/payment/single_node.rs @@ -0,0 +1,610 @@ +//! `SingleNode` payment mode implementation for saorsa-node. +//! +//! This module implements the `SingleNode` payment strategy from autonomi: +//! - Client gets 5 quotes from network (`CLOSE_GROUP_SIZE`) +//! - Sort by price and select median (index 2) +//! - Pay ONLY the median-priced node with 3x the quoted amount +//! - Other 4 nodes get `Amount::ZERO` +//! - All 5 are submitted for payment and verification +//! +//! Total cost is the same as Standard mode (3x), but with one actual payment. +//! This saves gas fees while maintaining the same total payment amount. + +use crate::error::{Error, Result}; +use ant_evm::{Amount, PaymentQuote, QuoteHash, QuotingMetrics, RewardsAddress}; +use evmlib::contract::payment_vault; +use evmlib::wallet::Wallet; +use evmlib::Network as EvmNetwork; +use tracing::info; + +/// Required number of quotes for `SingleNode` payment (matches `CLOSE_GROUP_SIZE`) +pub const REQUIRED_QUOTES: usize = 5; + +/// Index of the median-priced node after sorting +const MEDIAN_INDEX: usize = 2; + +/// Single node payment structure for a chunk. +/// +/// Contains 5 quotes where only the median-priced one receives payment (3x), +/// and the other 4 have `Amount::ZERO`. +#[derive(Debug, Clone)] +pub struct SingleNodePayment { + /// All 5 quotes (sorted by price) + pub quotes: Vec, +} + +/// Information about a single quote payment +#[derive(Debug, Clone)] +pub struct QuotePaymentInfo { + /// The quote hash + pub quote_hash: QuoteHash, + /// The rewards address + pub rewards_address: RewardsAddress, + /// The amount to pay (3x for median, 0 for others) + pub amount: Amount, + /// The quoting metrics + pub quoting_metrics: QuotingMetrics, +} + +impl SingleNodePayment { + /// Create a `SingleNode` payment from 5 quotes and their prices. + /// + /// The quotes should already be sorted by price (cheapest first). + /// The median (index 2) gets 3x its quote price. + /// The other 4 get `Amount::ZERO`. + /// + /// # Arguments + /// + /// * `quotes_with_prices` - Vec of (`PaymentQuote`, Amount) tuples, sorted by price + /// + /// # Errors + /// + /// Returns error if not exactly 5 quotes are provided. + pub fn from_quotes(quotes_with_prices: Vec<(PaymentQuote, Amount)>) -> Result { + if quotes_with_prices.len() != REQUIRED_QUOTES { + return Err(Error::Payment(format!( + "SingleNode payment requires exactly {} quotes, got {}", + REQUIRED_QUOTES, + quotes_with_prices.len() + ))); + } + + // Get median price and calculate 3x + let median_price = quotes_with_prices + .get(MEDIAN_INDEX) + .ok_or_else(|| Error::Payment("Missing median quote".to_string()))? + .1; + let enhanced_price = median_price + .checked_mul(Amount::from(3u64)) + .ok_or_else(|| { + Error::Payment("Price overflow when calculating 3x median".to_string()) + })?; + + // Build quote payment info for all 5 quotes + let quotes = quotes_with_prices + .into_iter() + .enumerate() + .map(|(idx, (quote, _))| QuotePaymentInfo { + quote_hash: quote.hash(), + rewards_address: quote.rewards_address, + amount: if idx == MEDIAN_INDEX { + enhanced_price + } else { + Amount::ZERO + }, + quoting_metrics: quote.quoting_metrics, + }) + .collect(); + + Ok(Self { quotes }) + } + + /// Get the total payment amount (should be 3x median price) + #[must_use] + pub fn total_amount(&self) -> Amount { + self.quotes.iter().map(|q| q.amount).sum() + } + + /// Get the median quote that receives payment + #[must_use] + pub fn paid_quote(&self) -> Option<&QuotePaymentInfo> { + self.quotes.get(MEDIAN_INDEX) + } + + /// Pay for all quotes on-chain using the wallet. + /// + /// Pays 3x to the median quote and 0 to the other 4. + /// + /// # Errors + /// + /// Returns an error if the payment transaction fails. + pub async fn pay(&self, wallet: &Wallet) -> Result> { + // Build quote payments: (QuoteHash, RewardsAddress, Amount) + let quote_payments: Vec<_> = self + .quotes + .iter() + .map(|q| (q.quote_hash, q.rewards_address, q.amount)) + .collect(); + + info!( + "Paying for {} quotes: 1 real ({} atto) + {} with 0 atto", + REQUIRED_QUOTES, + self.total_amount(), + REQUIRED_QUOTES - 1 + ); + + let (tx_hashes, _gas_info) = wallet.pay_for_quotes(quote_payments).await.map_err( + |evmlib::wallet::PayForQuotesError(err, _)| { + Error::Payment(format!("Failed to pay for quotes: {err}")) + }, + )?; + + // Collect transaction hashes for all quotes + let mut result_hashes = Vec::new(); + for quote_info in &self.quotes { + let tx_hash = tx_hashes.get("e_info.quote_hash).ok_or_else(|| { + Error::Payment(format!( + "Missing transaction hash for quote {}", + quote_info.quote_hash + )) + })?; + result_hashes.push(*tx_hash); + } + + info!("Payment successful: {} transactions", result_hashes.len()); + + Ok(result_hashes) + } + + /// Verify all payments on-chain. + /// + /// This checks that all 5 payments were recorded on the blockchain. + /// The contract requires exactly 5 payment verifications. + /// + /// # Arguments + /// + /// * `network` - The EVM network to verify on + /// * `owned_quote_hash` - Optional quote hash that this node owns (expects to receive payment) + /// + /// # Returns + /// + /// The total verified payment amount received by owned quotes. + /// + /// # Errors + /// + /// Returns an error if verification fails or payment is invalid. + pub async fn verify( + &self, + network: &EvmNetwork, + owned_quote_hash: Option, + ) -> Result { + // Use zero metrics for verification (contract doesn't validate them) + let zero_metrics = QuotingMetrics { + data_size: 0, + data_type: 0, + close_records_stored: 0, + records_per_type: vec![], + max_records: 0, + received_payment_count: 0, + live_time: 0, + network_density: None, + network_size: None, + }; + + // Build payment digest for all 5 quotes + let payment_digest: Vec<_> = self + .quotes + .iter() + .map(|q| (q.quote_hash, zero_metrics.clone(), q.rewards_address)) + .collect(); + + // Mark owned quotes + let owned_quote_hashes = owned_quote_hash.map_or_else(Vec::new, |hash| vec![hash]); + + info!( + "Verifying {} payments (owned: {})", + payment_digest.len(), + owned_quote_hashes.len() + ); + + let verified_amount = + payment_vault::verify_data_payment(network, owned_quote_hashes.clone(), payment_digest) + .await + .map_err(|e| Error::Payment(format!("Payment verification failed: {e}")))?; + + if owned_quote_hashes.is_empty() { + info!("Payment verified as valid on-chain"); + } else { + // If we own a quote, verify the amount matches + let expected = self + .quotes + .iter() + .find(|q| Some(q.quote_hash) == owned_quote_hash) + .ok_or_else(|| Error::Payment("Owned quote hash not found in payment".to_string()))? + .amount; + + if verified_amount != expected { + return Err(Error::Payment(format!( + "Payment amount mismatch: expected {expected}, verified {verified_amount}" + ))); + } + + info!("Payment verified: {verified_amount} atto received"); + } + + Ok(verified_amount) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use evmlib::contract::payment_vault::interface; + use evmlib::quoting_metrics::QuotingMetrics; + use evmlib::testnet::{ + deploy_data_payments_contract, deploy_network_token_contract, start_node, + }; + use evmlib::transaction_config::TransactionConfig; + use evmlib::utils::{dummy_address, dummy_hash}; + + /// Step 1: Exact copy of autonomi's `test_verify_payment_on_local` + #[tokio::test] + #[allow(clippy::expect_used)] + async fn test_exact_copy_of_autonomi_verify_payment() { + // Use autonomi's setup pattern + let (node, rpc_url) = start_node(); + let network_token = deploy_network_token_contract(&rpc_url, &node).await; + let mut payment_vault = + deploy_data_payments_contract(&rpc_url, &node, *network_token.contract.address()).await; + + let transaction_config = TransactionConfig::default(); + + // Create 5 random quote payments (autonomi pattern) + let mut quote_payments = vec![]; + for _ in 0..5 { + let quote_hash = dummy_hash(); + let reward_address = dummy_address(); + let amount = Amount::from(1u64); + quote_payments.push((quote_hash, reward_address, amount)); + } + + // Approve tokens + network_token + .approve( + *payment_vault.contract.address(), + evmlib::common::U256::MAX, + &transaction_config, + ) + .await + .expect("Failed to approve"); + + println!("✓ Approved tokens"); + + // CRITICAL: Set provider to same as network token + payment_vault.set_provider(network_token.contract.provider().clone()); + + // Pay for quotes + let result = payment_vault + .pay_for_quotes(quote_payments.clone(), &transaction_config) + .await; + + assert!(result.is_ok(), "Payment failed: {:?}", result.err()); + println!("✓ Paid for {} quotes", quote_payments.len()); + + // Verify payments using handler directly + let payment_verifications: Vec<_> = quote_payments + .into_iter() + .map(|v| interface::IPaymentVault::PaymentVerification { + metrics: QuotingMetrics { + data_size: 0, + data_type: 0, + close_records_stored: 0, + records_per_type: vec![], + max_records: 0, + received_payment_count: 0, + live_time: 0, + network_density: None, + network_size: None, + } + .into(), + rewardsAddress: v.1, + quoteHash: v.0, + }) + .collect(); + + let results = payment_vault + .verify_payment(payment_verifications) + .await + .expect("Verify payment failed"); + + for result in results { + assert!(result.isValid, "Payment verification should be valid"); + } + + println!("✓ All {} payments verified successfully", 5); + println!("\n✅ Exact autonomi pattern works!"); + } + + /// Step 2: Change to 3 payments instead of 5 (matching `SingleNode` 3x) + #[tokio::test] + #[allow(clippy::expect_used)] + async fn test_step2_three_payments() { + let (node, rpc_url) = start_node(); + let network_token = deploy_network_token_contract(&rpc_url, &node).await; + let mut payment_vault = + deploy_data_payments_contract(&rpc_url, &node, *network_token.contract.address()).await; + + let transaction_config = TransactionConfig::default(); + + // CHANGE: Create 3 payments instead of 5 + let mut quote_payments = vec![]; + for _ in 0..3 { + let quote_hash = dummy_hash(); + let reward_address = dummy_address(); + let amount = Amount::from(1u64); + quote_payments.push((quote_hash, reward_address, amount)); + } + + // Approve tokens + network_token + .approve( + *payment_vault.contract.address(), + evmlib::common::U256::MAX, + &transaction_config, + ) + .await + .expect("Failed to approve"); + + println!("✓ Approved tokens"); + + // Set provider + payment_vault.set_provider(network_token.contract.provider().clone()); + + // Pay + let result = payment_vault + .pay_for_quotes(quote_payments.clone(), &transaction_config) + .await; + + assert!(result.is_ok(), "Payment failed: {:?}", result.err()); + println!("✓ Paid for 3 quotes"); + + // Verify with 3 payments + let payment_verifications: Vec<_> = quote_payments + .into_iter() + .map(|v| interface::IPaymentVault::PaymentVerification { + metrics: QuotingMetrics { + data_size: 0, + data_type: 0, + close_records_stored: 0, + records_per_type: vec![], + max_records: 0, + received_payment_count: 0, + live_time: 0, + network_density: None, + network_size: None, + } + .into(), + rewardsAddress: v.1, + quoteHash: v.0, + }) + .collect(); + + let results = payment_vault + .verify_payment(payment_verifications) + .await + .expect("Verify payment failed"); + + for result in results { + assert!(result.isValid, "Payment verification should be valid"); + } + + println!("✓ All 3 payments verified successfully"); + println!("\n✅ Step 2: Three payments work!"); + } + + /// Step 3: Pay 3x for ONE quote and 0 for the other 4 (`SingleNode` mode) + #[tokio::test] + #[allow(clippy::expect_used)] + async fn test_step3_single_node_payment_pattern() { + let (node, rpc_url) = start_node(); + let network_token = deploy_network_token_contract(&rpc_url, &node).await; + let mut payment_vault = + deploy_data_payments_contract(&rpc_url, &node, *network_token.contract.address()).await; + + let transaction_config = TransactionConfig::default(); + + // CHANGE: Create 5 payments: 1 real (3x) + 4 dummy (0x) + let real_quote_hash = dummy_hash(); + let real_reward_address = dummy_address(); + let real_amount = Amount::from(3u64); // 3x amount + + let mut quote_payments = vec![(real_quote_hash, real_reward_address, real_amount)]; + + // Add 4 dummy payments with 0 amount + for _ in 0..4 { + let dummy_quote_hash = dummy_hash(); + let dummy_reward_address = dummy_address(); + let dummy_amount = Amount::from(0u64); // 0 amount + quote_payments.push((dummy_quote_hash, dummy_reward_address, dummy_amount)); + } + + // Approve tokens + network_token + .approve( + *payment_vault.contract.address(), + evmlib::common::U256::MAX, + &transaction_config, + ) + .await + .expect("Failed to approve"); + + println!("✓ Approved tokens"); + + // Set provider + payment_vault.set_provider(network_token.contract.provider().clone()); + + // Pay (1 real payment of 3 atto + 4 dummy payments of 0 atto) + let result = payment_vault + .pay_for_quotes(quote_payments.clone(), &transaction_config) + .await; + + assert!(result.is_ok(), "Payment failed: {:?}", result.err()); + println!("✓ Paid: 1 real (3 atto) + 4 dummy (0 atto)"); + + // Verify all 5 payments + let payment_verifications: Vec<_> = quote_payments + .into_iter() + .map(|v| interface::IPaymentVault::PaymentVerification { + metrics: QuotingMetrics { + data_size: 0, + data_type: 0, + close_records_stored: 0, + records_per_type: vec![], + max_records: 0, + received_payment_count: 0, + live_time: 0, + network_density: None, + network_size: None, + } + .into(), + rewardsAddress: v.1, + quoteHash: v.0, + }) + .collect(); + + let results = payment_vault + .verify_payment(payment_verifications) + .await + .expect("Verify payment failed"); + + // Check that real payment is valid + assert!( + results.first().is_some_and(|r| r.isValid), + "Real payment should be valid" + ); + println!("✓ Real payment verified (3 atto)"); + + // Check dummy payments + for (i, result) in results.iter().skip(1).enumerate() { + println!(" Dummy payment {}: valid={}", i + 1, result.isValid); + } + + println!("\n✅ Step 3: SingleNode pattern (1 real + 4 dummy) works!"); + } + + /// Step 4: Complete `SingleNode` payment flow with real quotes + #[tokio::test] + async fn test_step4_complete_single_node_payment_flow() -> Result<()> { + use evmlib::testnet::Testnet; + use evmlib::wallet::Wallet; + use std::time::SystemTime; + use xor_name::XorName; + + // Setup testnet + let testnet = Testnet::new().await; + let network = testnet.to_network(); + let wallet = + Wallet::new_from_private_key(network.clone(), &testnet.default_wallet_private_key()) + .map_err(|e| Error::Payment(format!("Failed to create wallet: {e}")))?; + + println!("✓ Started Anvil testnet"); + + // Approve tokens + wallet + .approve_to_spend_tokens(*network.data_payments_address(), evmlib::common::U256::MAX) + .await + .map_err(|e| Error::Payment(format!("Failed to approve tokens: {e}")))?; + + println!("✓ Approved tokens"); + + // Create 5 quotes with real prices from contract + let chunk_xor = XorName::random(&mut rand::thread_rng()); + let chunk_size = 1024usize; + + let mut quotes_with_prices = Vec::new(); + for i in 0..REQUIRED_QUOTES { + let quoting_metrics = QuotingMetrics { + data_size: chunk_size, + data_type: 0, + close_records_stored: 10 + i, + records_per_type: vec![(0, (10 + i) as u32)], + max_records: 1000, + received_payment_count: 5, + live_time: 3600, + network_density: None, + network_size: Some(100), + }; + + // Get market price for this quote + let prices = payment_vault::get_market_price(&network, vec![quoting_metrics.clone()]) + .await + .map_err(|e| Error::Payment(format!("Failed to get market price: {e}")))?; + + let price = prices.first().ok_or_else(|| { + Error::Payment("Empty price list from get_market_price".to_string()) + })?; + + let quote = PaymentQuote { + content: chunk_xor, + timestamp: SystemTime::now(), + quoting_metrics, + rewards_address: wallet.address(), + pub_key: vec![], + signature: vec![], + }; + + quotes_with_prices.push((quote, *price)); + } + + // Sort by price (as autonomi does) + quotes_with_prices.sort_by_key(|(_, price)| *price); + + let median_price = quotes_with_prices + .get(MEDIAN_INDEX) + .ok_or_else(|| Error::Payment("Missing median quote".to_string()))? + .1; + println!("✓ Got 5 real quotes from contract, median price: {median_price} atto"); + + // Create SingleNode payment + let payment = SingleNodePayment::from_quotes(quotes_with_prices)?; + + assert_eq!(payment.quotes.len(), REQUIRED_QUOTES); + let median_amount = payment + .quotes + .get(MEDIAN_INDEX) + .ok_or_else(|| Error::Payment("Missing median quote".to_string()))? + .amount; + assert_eq!( + payment.total_amount(), + median_amount, + "Only median should have non-zero amount" + ); + + println!( + "✓ Created SingleNode payment: {} atto total (3x median)", + payment.total_amount() + ); + + // Pay on-chain + let tx_hashes = payment.pay(&wallet).await?; + println!("✓ Payment successful: {} transactions", tx_hashes.len()); + + // Verify payment (as owner of median quote) + let median_quote = payment + .quotes + .get(MEDIAN_INDEX) + .ok_or_else(|| Error::Payment("Missing median quote".to_string()))?; + let median_quote_hash = median_quote.quote_hash; + let verified_amount = payment.verify(&network, Some(median_quote_hash)).await?; + + assert_eq!( + verified_amount, median_quote.amount, + "Verified amount should match median payment" + ); + + println!("✓ Payment verified: {verified_amount} atto"); + println!("\n✅ Step 4: Complete SingleNode flow with real quotes works!"); + + Ok(()) + } +} From 9fdbc75d3e65c1e407db67593d8f7fa3d42015b9 Mon Sep 17 00:00:00 2001 From: grumbach Date: Tue, 24 Feb 2026 19:03:57 +0900 Subject: [PATCH 2/7] fix: clippy issue --- src/payment/single_node.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/payment/single_node.rs b/src/payment/single_node.rs index 411eae40..54ca0a1d 100644 --- a/src/payment/single_node.rs +++ b/src/payment/single_node.rs @@ -527,7 +527,11 @@ mod tests { data_size: chunk_size, data_type: 0, close_records_stored: 10 + i, - records_per_type: vec![(0, (10 + i) as u32)], + records_per_type: vec![( + 0, + u32::try_from(10 + i) + .map_err(|e| Error::Payment(format!("Invalid record count: {e}")))?, + )], max_records: 1000, received_payment_count: 5, live_time: 3600, From 14edc86303faf683565241cc219a5c5d620205aa Mon Sep 17 00:00:00 2001 From: grumbach Date: Tue, 24 Feb 2026 20:46:07 +0900 Subject: [PATCH 3/7] ci: fix missing anvil in ci --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 17dd793c..67628e35 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,10 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + with: + version: nightly - name: Run tests run: cargo test From e7fd5f8656f9d1ea8a2ee997f272dca56720d465 Mon Sep 17 00:00:00 2001 From: grumbach Date: Wed, 25 Feb 2026 14:53:45 +0900 Subject: [PATCH 4/7] fix: increase Anvil timeout and improve SingleNodePayment type safety Fixes CI test failures caused by Anvil startup timeouts and improves the SingleNodePayment API with compile-time guarantees. ## Anvil Timeout Fix - Add alloy dev dependency with node-bindings feature - Create start_node_with_timeout() helper with 60s timeout (vs 10s default) - Use random port assignment to prevent parallel test conflicts - Update failing tests to use new helper function ## Type Safety Improvements - Change quotes field from Vec to fixed-size array [QuotePaymentInfo; 5] - Provides compile-time enforcement of 5-quote requirement - Makes MEDIAN_INDEX always valid (no bounds checking needed) - Simplifies paid_quote() to return &T instead of Option<&T> - Add internal sorting to from_quotes() - Caller no longer needs to pre-sort quotes - Eliminates risk of incorrect median selection from unsorted input - Safer, simpler API These changes make invalid states unrepresentable, preventing entire classes of bugs at compile time. --- Cargo.toml | 1 + src/payment/single_node.rs | 93 +++++++++++++++++++++++++++----------- 2 files changed, 68 insertions(+), 26 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 639502ce..21c1ce30 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -98,6 +98,7 @@ postcard = { version = "1.1.3", features = ["use-std"] } [dev-dependencies] tokio-test = "0.4" proptest = "1" +alloy = { version = "1", features = ["node-bindings"] } # E2E test infrastructure [[test]] diff --git a/src/payment/single_node.rs b/src/payment/single_node.rs index 54ca0a1d..bc39d189 100644 --- a/src/payment/single_node.rs +++ b/src/payment/single_node.rs @@ -25,12 +25,15 @@ const MEDIAN_INDEX: usize = 2; /// Single node payment structure for a chunk. /// -/// Contains 5 quotes where only the median-priced one receives payment (3x), +/// Contains exactly 5 quotes where only the median-priced one receives payment (3x), /// and the other 4 have `Amount::ZERO`. +/// +/// The fixed-size array ensures compile-time enforcement of the 5-quote requirement, +/// making the median index (2) always valid. #[derive(Debug, Clone)] pub struct SingleNodePayment { - /// All 5 quotes (sorted by price) - pub quotes: Vec, + /// All 5 quotes (sorted by price) - fixed size ensures median index is always valid + pub quotes: [QuotePaymentInfo; REQUIRED_QUOTES], } /// Information about a single quote payment @@ -49,18 +52,18 @@ pub struct QuotePaymentInfo { impl SingleNodePayment { /// Create a `SingleNode` payment from 5 quotes and their prices. /// - /// The quotes should already be sorted by price (cheapest first). + /// The quotes are automatically sorted by price (cheapest first). /// The median (index 2) gets 3x its quote price. /// The other 4 get `Amount::ZERO`. /// /// # Arguments /// - /// * `quotes_with_prices` - Vec of (`PaymentQuote`, Amount) tuples, sorted by price + /// * `quotes_with_prices` - Vec of (`PaymentQuote`, Amount) tuples (will be sorted internally) /// /// # Errors /// /// Returns error if not exactly 5 quotes are provided. - pub fn from_quotes(quotes_with_prices: Vec<(PaymentQuote, Amount)>) -> Result { + pub fn from_quotes(mut quotes_with_prices: Vec<(PaymentQuote, Amount)>) -> Result { if quotes_with_prices.len() != REQUIRED_QUOTES { return Err(Error::Payment(format!( "SingleNode payment requires exactly {} quotes, got {}", @@ -69,6 +72,9 @@ impl SingleNodePayment { ))); } + // Sort by price (cheapest first) to ensure correct median selection + quotes_with_prices.sort_by_key(|(_, price)| *price); + // Get median price and calculate 3x let median_price = quotes_with_prices .get(MEDIAN_INDEX) @@ -81,7 +87,8 @@ impl SingleNodePayment { })?; // Build quote payment info for all 5 quotes - let quotes = quotes_with_prices + // Use try_from to convert Vec to fixed-size array + let quotes_vec: Vec = quotes_with_prices .into_iter() .enumerate() .map(|(idx, (quote, _))| QuotePaymentInfo { @@ -96,6 +103,11 @@ impl SingleNodePayment { }) .collect(); + // Convert Vec to array - we already validated length is REQUIRED_QUOTES + let quotes: [QuotePaymentInfo; REQUIRED_QUOTES] = quotes_vec + .try_into() + .map_err(|_| Error::Payment("Failed to convert quotes to fixed array".to_string()))?; + Ok(Self { quotes }) } @@ -105,10 +117,13 @@ impl SingleNodePayment { self.quotes.iter().map(|q| q.amount).sum() } - /// Get the median quote that receives payment + /// Get the median quote that receives payment. + /// + /// This always returns a valid reference since the array is fixed-size + /// and `MEDIAN_INDEX` is guaranteed to be in bounds. #[must_use] - pub fn paid_quote(&self) -> Option<&QuotePaymentInfo> { - self.quotes.get(MEDIAN_INDEX) + pub fn paid_quote(&self) -> &QuotePaymentInfo { + &self.quotes[MEDIAN_INDEX] } /// Pay for all quotes on-chain using the wallet. @@ -239,20 +254,46 @@ impl SingleNodePayment { #[cfg(test)] mod tests { use super::*; + use alloy::node_bindings::{Anvil, AnvilInstance}; use evmlib::contract::payment_vault::interface; use evmlib::quoting_metrics::QuotingMetrics; - use evmlib::testnet::{ - deploy_data_payments_contract, deploy_network_token_contract, start_node, - }; + use evmlib::testnet::{deploy_data_payments_contract, deploy_network_token_contract}; use evmlib::transaction_config::TransactionConfig; use evmlib::utils::{dummy_address, dummy_hash}; + use reqwest::Url; + + /// Start an Anvil node with increased timeout for CI environments. + /// + /// The default timeout is 10 seconds which can be insufficient in CI. + /// This helper uses a 60-second timeout and random port assignment + /// to handle slower CI environments and parallel test execution. + #[allow(clippy::expect_used)] + fn start_node_with_timeout() -> (AnvilInstance, Url) { + const ANVIL_TIMEOUT_MS: u64 = 60_000; // 60 seconds for CI + + let host = std::env::var("ANVIL_IP_ADDR").unwrap_or_else(|_| "localhost".to_string()); + + // Use port 0 to let the OS assign a random available port. + // This prevents port conflicts when running tests in parallel. + let anvil = Anvil::new() + .timeout(ANVIL_TIMEOUT_MS) + .try_spawn() + .expect(&format!( + "Could not spawn Anvil node after {ANVIL_TIMEOUT_MS}ms" + )); + + let url = Url::parse(&format!("http://{host}:{}", anvil.port())) + .expect("Failed to parse Anvil URL"); + + (anvil, url) + } /// Step 1: Exact copy of autonomi's `test_verify_payment_on_local` #[tokio::test] #[allow(clippy::expect_used)] async fn test_exact_copy_of_autonomi_verify_payment() { - // Use autonomi's setup pattern - let (node, rpc_url) = start_node(); + // Use autonomi's setup pattern with increased timeout for CI + let (node, rpc_url) = start_node_with_timeout(); let network_token = deploy_network_token_contract(&rpc_url, &node).await; let mut payment_vault = deploy_data_payments_contract(&rpc_url, &node, *network_token.contract.address()).await; @@ -329,7 +370,7 @@ mod tests { #[tokio::test] #[allow(clippy::expect_used)] async fn test_step2_three_payments() { - let (node, rpc_url) = start_node(); + let (node, rpc_url) = start_node_with_timeout(); let network_token = deploy_network_token_contract(&rpc_url, &node).await; let mut payment_vault = deploy_data_payments_contract(&rpc_url, &node, *network_token.contract.address()).await; @@ -406,7 +447,7 @@ mod tests { #[tokio::test] #[allow(clippy::expect_used)] async fn test_step3_single_node_payment_pattern() { - let (node, rpc_url) = start_node(); + let (node, rpc_url) = start_node_with_timeout(); let network_token = deploy_network_token_contract(&rpc_url, &node).await; let mut payment_vault = deploy_data_payments_contract(&rpc_url, &node, *network_token.contract.address()).await; @@ -560,18 +601,18 @@ mod tests { quotes_with_prices.push((quote, *price)); } - // Sort by price (as autonomi does) - quotes_with_prices.sort_by_key(|(_, price)| *price); - - let median_price = quotes_with_prices - .get(MEDIAN_INDEX) - .ok_or_else(|| Error::Payment("Missing median quote".to_string()))? - .1; - println!("✓ Got 5 real quotes from contract, median price: {median_price} atto"); + println!("✓ Got 5 real quotes from contract"); - // Create SingleNode payment + // Create SingleNode payment (will sort internally and select median) let payment = SingleNodePayment::from_quotes(quotes_with_prices)?; + let median_price = payment + .paid_quote() + .amount + .checked_div(Amount::from(3u64)) + .ok_or_else(|| Error::Payment("Failed to calculate median price".to_string()))?; + println!("✓ Sorted and selected median price: {median_price} atto"); + assert_eq!(payment.quotes.len(), REQUIRED_QUOTES); let median_amount = payment .quotes From d9f9c260aea3f94cd9257bc5e8f5fa21d387e226 Mon Sep 17 00:00:00 2001 From: grumbach Date: Wed, 25 Feb 2026 14:58:13 +0900 Subject: [PATCH 5/7] fix: clippy allow panic in test helper --- src/payment/single_node.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/payment/single_node.rs b/src/payment/single_node.rs index bc39d189..af9fd3d2 100644 --- a/src/payment/single_node.rs +++ b/src/payment/single_node.rs @@ -267,7 +267,7 @@ mod tests { /// The default timeout is 10 seconds which can be insufficient in CI. /// This helper uses a 60-second timeout and random port assignment /// to handle slower CI environments and parallel test execution. - #[allow(clippy::expect_used)] + #[allow(clippy::expect_used, clippy::panic)] fn start_node_with_timeout() -> (AnvilInstance, Url) { const ANVIL_TIMEOUT_MS: u64 = 60_000; // 60 seconds for CI @@ -278,9 +278,7 @@ mod tests { let anvil = Anvil::new() .timeout(ANVIL_TIMEOUT_MS) .try_spawn() - .expect(&format!( - "Could not spawn Anvil node after {ANVIL_TIMEOUT_MS}ms" - )); + .unwrap_or_else(|_| panic!("Could not spawn Anvil node after {ANVIL_TIMEOUT_MS}ms")); let url = Url::parse(&format!("http://{host}:{}", anvil.port())) .expect("Failed to parse Anvil URL"); From bdfb1e2f81999ff46de6255cf5096e3753ff1d66 Mon Sep 17 00:00:00 2001 From: grumbach Date: Wed, 25 Feb 2026 15:47:42 +0900 Subject: [PATCH 6/7] fix: handle missing tx hashes for zero-amount payments The wallet may not return transaction hashes for zero-amount payments. Updated pay() to skip zero-amount quotes when collecting tx hashes, and only error if a non-zero payment is missing a transaction hash. This fixes test_step4_complete_single_node_payment_flow. --- src/payment/single_node.rs | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/src/payment/single_node.rs b/src/payment/single_node.rs index af9fd3d2..013f6c17 100644 --- a/src/payment/single_node.rs +++ b/src/payment/single_node.rs @@ -155,18 +155,27 @@ impl SingleNodePayment { )?; // Collect transaction hashes for all quotes - let mut result_hashes = Vec::new(); - for quote_info in &self.quotes { - let tx_hash = tx_hashes.get("e_info.quote_hash).ok_or_else(|| { - Error::Payment(format!( - "Missing transaction hash for quote {}", - quote_info.quote_hash - )) - })?; - result_hashes.push(*tx_hash); - } + // Note: wallet may not return tx_hash for zero-amount payments + let result_hashes: Vec<_> = self + .quotes + .iter() + .filter_map(|quote_info| { + if let Some(&tx_hash) = tx_hashes.get("e_info.quote_hash) { + Some(Ok(tx_hash)) + } else if quote_info.amount != Amount::ZERO { + // Non-zero amount should have a transaction hash + Some(Err(Error::Payment(format!( + "Missing transaction hash for non-zero quote {} (amount: {})", + quote_info.quote_hash, quote_info.amount + )))) + } else { + // Zero-amount payments may not get a transaction + None + } + }) + .collect::>>()?; - info!("Payment successful: {} transactions", result_hashes.len()); + info!("Payment successful: {} transactions (expected 1-5)", result_hashes.len()); Ok(result_hashes) } From 299cb17ae8afffff1e6790a3d50c02ffa282580f Mon Sep 17 00:00:00 2001 From: grumbach Date: Wed, 25 Feb 2026 16:03:39 +0900 Subject: [PATCH 7/7] test: remove test_step2_three_payments - not relevant to SingleNodePayment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This test was attempting to verify 3 payments but the contract requires exactly 5. It was not testing SingleNodePayment functionality. All relevant SingleNodePayment tests now pass: - test_exact_copy_of_autonomi_verify_payment ✓ - test_step3_single_node_payment_pattern ✓ - test_step4_complete_single_node_payment_flow ✓ --- src/payment/single_node.rs | 82 ++------------------------------------ 1 file changed, 4 insertions(+), 78 deletions(-) diff --git a/src/payment/single_node.rs b/src/payment/single_node.rs index 013f6c17..fc4b504b 100644 --- a/src/payment/single_node.rs +++ b/src/payment/single_node.rs @@ -175,7 +175,10 @@ impl SingleNodePayment { }) .collect::>>()?; - info!("Payment successful: {} transactions (expected 1-5)", result_hashes.len()); + info!( + "Payment successful: {} transactions (expected 1-5)", + result_hashes.len() + ); Ok(result_hashes) } @@ -373,83 +376,6 @@ mod tests { println!("\n✅ Exact autonomi pattern works!"); } - /// Step 2: Change to 3 payments instead of 5 (matching `SingleNode` 3x) - #[tokio::test] - #[allow(clippy::expect_used)] - async fn test_step2_three_payments() { - let (node, rpc_url) = start_node_with_timeout(); - let network_token = deploy_network_token_contract(&rpc_url, &node).await; - let mut payment_vault = - deploy_data_payments_contract(&rpc_url, &node, *network_token.contract.address()).await; - - let transaction_config = TransactionConfig::default(); - - // CHANGE: Create 3 payments instead of 5 - let mut quote_payments = vec![]; - for _ in 0..3 { - let quote_hash = dummy_hash(); - let reward_address = dummy_address(); - let amount = Amount::from(1u64); - quote_payments.push((quote_hash, reward_address, amount)); - } - - // Approve tokens - network_token - .approve( - *payment_vault.contract.address(), - evmlib::common::U256::MAX, - &transaction_config, - ) - .await - .expect("Failed to approve"); - - println!("✓ Approved tokens"); - - // Set provider - payment_vault.set_provider(network_token.contract.provider().clone()); - - // Pay - let result = payment_vault - .pay_for_quotes(quote_payments.clone(), &transaction_config) - .await; - - assert!(result.is_ok(), "Payment failed: {:?}", result.err()); - println!("✓ Paid for 3 quotes"); - - // Verify with 3 payments - let payment_verifications: Vec<_> = quote_payments - .into_iter() - .map(|v| interface::IPaymentVault::PaymentVerification { - metrics: QuotingMetrics { - data_size: 0, - data_type: 0, - close_records_stored: 0, - records_per_type: vec![], - max_records: 0, - received_payment_count: 0, - live_time: 0, - network_density: None, - network_size: None, - } - .into(), - rewardsAddress: v.1, - quoteHash: v.0, - }) - .collect(); - - let results = payment_vault - .verify_payment(payment_verifications) - .await - .expect("Verify payment failed"); - - for result in results { - assert!(result.isValid, "Payment verification should be valid"); - } - - println!("✓ All 3 payments verified successfully"); - println!("\n✅ Step 2: Three payments work!"); - } - /// Step 3: Pay 3x for ONE quote and 0 for the other 4 (`SingleNode` mode) #[tokio::test] #[allow(clippy::expect_used)]