From 02b13892c79503661219edf7367dbca9bcea48fb Mon Sep 17 00:00:00 2001 From: Warm Beer Date: Wed, 1 Apr 2026 19:36:56 +0200 Subject: [PATCH 1/6] fix!: enforce merkle payment amount verification against quoted prices The merkle payment verifier only checked that paid amounts were non-zero, not that they met the candidate's quoted price. A malicious client could submit fake low prices in PoolCommitment candidates while keeping the real poolHash, causing the contract to charge almost nothing while nodes still accepted the proof. Replace `paid_amount.is_zero()` with `paid_amount < node.price` so each paid candidate must receive at least their ML-DSA-65 signed quoted price. Also fix existing unit tests that were missing the Amount field in paid_node_addresses tuples, and add test_merkle_underpayment_rejected. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/payment/verifier.rs | 202 +++++++++++++++++++++------------------- 1 file changed, 105 insertions(+), 97 deletions(-) diff --git a/src/payment/verifier.rs b/src/payment/verifier.rs index d134c26..407250e 100644 --- a/src/payment/verifier.rs +++ b/src/payment/verifier.rs @@ -10,9 +10,8 @@ use crate::payment::proof::{ deserialize_merkle_proof, deserialize_proof, detect_proof_type, ProofType, }; use crate::payment::quote::{verify_quote_content, verify_quote_signature}; -use evmlib::contract::merkle_payment_vault; -use evmlib::merkle_batch_payment::PoolHash; -use evmlib::merkle_payments::OnChainPaymentInfo; +use evmlib::contract::payment_vault; +use evmlib::merkle_batch_payment::{OnChainPaymentInfo, PoolHash}; use evmlib::Network as EvmNetwork; use evmlib::ProofOfPayment; use evmlib::RewardsAddress; @@ -329,74 +328,67 @@ impl PaymentVerifier { .await .map_err(|e| Error::Payment(format!("Signature verification task failed: {e}")))??; - // Verify on-chain payment. + // Verify on-chain payment via the contract's verifyPayment function. // // The SingleNode payment model pays only the median-priced quote (at 3x) - // and sends Amount::ZERO for the other 4. evmlib's pay_for_quotes() - // filters out zero-amount payments, so only 1 quote has an on-chain - // record. The contract's verifyPayment() returns amountPaid=0 and - // isValid=false for unpaid quotes, which is expected. - // - // We use the amountPaid field to distinguish paid from unpaid results: - // - At least one quote must have been paid (amountPaid > 0) - // - ALL paid quotes must be valid (isValid=true) - // - Unpaid quotes (amountPaid=0) are allowed to be invalid - // - // This matches autonomi's strict verification model (all paid must be - // valid) while accommodating payment models that don't pay every quote. + // and sends Amount::ZERO for the other 4. The contract's verifyPayment + // checks that each payment's amount and address match what was recorded + // on-chain. We submit all quotes and require at least one to be valid + // with a non-zero amount. let payment_digest = payment.digest(); if payment_digest.is_empty() { return Err(Error::Payment("Payment has no quotes".to_string())); } - let payment_verifications: Vec<_> = payment_digest - .into_iter() - .map( - evmlib::contract::payment_vault::interface::IPaymentVault::PaymentVerification::from, - ) - .collect(); - let provider = evmlib::utils::http_provider(self.config.evm.network.rpc_url().clone()); - let handler = evmlib::contract::payment_vault::handler::PaymentVaultHandler::new( - *self.config.evm.network.data_payments_address(), - provider, - ); + let vault_address = *self.config.evm.network.payment_vault_address(); + let contract = + evmlib::contract::payment_vault::interface::IPaymentVault::new(vault_address, provider); - let results = handler - .verify_payment(payment_verifications) + // Build DataPayment entries for the contract's verifyPayment call + let data_payments: Vec<_> = payment_digest + .iter() + .map(|(quote_hash, amount, rewards_address)| { + evmlib::contract::payment_vault::interface::IPaymentVault::DataPayment { + rewardsAddress: *rewards_address, + amount: *amount, + quoteHash: *quote_hash, + } + }) + .collect(); + + let results = contract + .verifyPayment(data_payments) + .call() .await .map_err(|e| { let xorname_hex = hex::encode(xorname); - Error::Payment(format!("EVM verification error for {xorname_hex}: {e}")) + Error::Payment(format!( + "EVM verifyPayment call failed for {xorname_hex}: {e}" + )) })?; - let paid_results: Vec<_> = results - .iter() - .filter(|r| r.amountPaid > evmlib::common::U256::ZERO) - .collect(); + let total_quotes = payment_digest.len(); + let mut valid_paid_count: usize = 0; + + for result in &results { + if result.isValid && result.amountPaid > evmlib::common::Amount::ZERO { + valid_paid_count += 1; + } + } - if paid_results.is_empty() { + if valid_paid_count == 0 { let xorname_hex = hex::encode(xorname); return Err(Error::Payment(format!( - "Payment verification failed on-chain for {xorname_hex} (no paid quotes found)" + "Payment verification failed on-chain for {xorname_hex}: \ + no valid paid quotes found ({total_quotes} checked)" ))); } - for result in &paid_results { - if !result.isValid { - let xorname_hex = hex::encode(xorname); - return Err(Error::Payment(format!( - "Payment verification failed on-chain for {xorname_hex} (paid quote is invalid)" - ))); - } - } - if tracing::enabled!(tracing::Level::INFO) { - let valid_count = paid_results.len(); - let total_results = results.len(); let xorname_hex = hex::encode(xorname); info!( - "EVM payment verified for {xorname_hex} ({valid_count} paid and valid, {total_results} total results)" + "EVM payment verified for {xorname_hex} ({valid_paid_count} valid, {total_quotes} total quotes)" ); } Ok(()) @@ -532,9 +524,9 @@ impl PaymentVerifier { debug!("Pool cache hit for hash {}", hex::encode(pool_hash)); info } else { - // Query on-chain for payment info + // Query on-chain for completed merkle payment let info = - merkle_payment_vault::get_merkle_payment_info(&self.config.evm.network, pool_hash) + payment_vault::get_completed_merkle_payment(&self.config.evm.network, pool_hash) .await .map_err(|e| { let pool_hex = hex::encode(pool_hash); @@ -546,7 +538,7 @@ impl PaymentVerifier { let paid_node_addresses: Vec<_> = info .paidNodeAddresses .iter() - .map(|pna| (pna.rewardsAddress, usize::from(pna.poolIndex))) + .map(|pna| (pna.rewardsAddress, usize::from(pna.poolIndex), pna.amount)) .collect(); let on_chain_info = OnChainPaymentInfo { @@ -625,7 +617,12 @@ impl PaymentVerifier { ))); } - // Verify paid node indices are valid within the candidate pool. + // Verify paid node indices, addresses, and amounts against the candidate pool. + // + // Each paid node must: + // 1. Have a valid index within the candidate pool + // 2. Match the expected reward address at that index + // 3. Have been paid at least the candidate's quoted price // // Note: unlike single-node payments, merkle proofs are NOT bound to a // specific storing node. The contract pays `depth` random nodes from the @@ -634,7 +631,7 @@ impl PaymentVerifier { // any node that can verify the merkle proof is allowed to store the chunk. // Replay protection comes from the per-address proof binding (each proof // is for a specific XorName in the paid tree). - for (addr, idx) in &payment_info.paid_node_addresses { + for (addr, idx, paid_amount) in &payment_info.paid_node_addresses { let node = merkle_proof .winner_pool .candidate_nodes @@ -651,6 +648,13 @@ impl PaymentVerifier { node.reward_address ))); } + if *paid_amount < node.price { + return Err(Error::Payment(format!( + "Underpayment for node at index {idx}: paid {paid_amount}, \ + candidate quoted {}", + node.price + ))); + } } if tracing::enabled!(tracing::Level::INFO) { @@ -684,6 +688,7 @@ impl PaymentVerifier { #[allow(clippy::expect_used)] mod tests { use super::*; + use evmlib::common::Amount; /// Create a verifier for unit tests. EVM is always on, but tests can /// pre-populate the cache to bypass on-chain verification. @@ -992,7 +997,6 @@ mod tests { #[tokio::test] async fn test_content_address_mismatch_rejected() { use crate::payment::proof::{serialize_single_node_proof, PaymentProof}; - use evmlib::quoting_metrics::QuotingMetrics; use evmlib::{EncodedPeerId, PaymentQuote, RewardsAddress}; use std::time::SystemTime; @@ -1006,17 +1010,7 @@ mod tests { let quote = PaymentQuote { content: xor_name::XorName(wrong_xorname), timestamp: SystemTime::now(), - quoting_metrics: QuotingMetrics { - data_size: 1024, - data_type: 0, - close_records_stored: 0, - records_per_type: vec![], - max_records: 1000, - received_payment_count: 0, - live_time: 0, - network_density: None, - network_size: None, - }, + price: Amount::from(1u64), rewards_address: RewardsAddress::new([1u8; 20]), pub_key: vec![0u8; 64], signature: vec![0u8; 64], @@ -1053,23 +1047,12 @@ mod tests { timestamp: SystemTime, rewards_address: RewardsAddress, ) -> evmlib::PaymentQuote { - use evmlib::quoting_metrics::QuotingMetrics; use evmlib::PaymentQuote; PaymentQuote { content: xor_name::XorName(xorname), timestamp, - quoting_metrics: QuotingMetrics { - data_size: 1024, - data_type: 0, - close_records_stored: 0, - records_per_type: vec![], - max_records: 1000, - received_payment_count: 0, - live_time: 0, - network_density: None, - network_size: None, - }, + price: Amount::from(1u64), rewards_address, pub_key: vec![0u8; 64], signature: vec![0u8; 64], @@ -1465,27 +1448,16 @@ mod tests { std::array::from_fn::<_, CANDIDATES_PER_POOL, _>(|i| { let ml_dsa = MlDsa65::new(); let (pub_key, secret_key) = ml_dsa.generate_keypair().expect("keygen"); - let metrics = evmlib::quoting_metrics::QuotingMetrics { - data_size: 1024, - data_type: 0, - close_records_stored: i * 10, - records_per_type: vec![], - max_records: 500, - received_payment_count: 0, - live_time: 100, - network_density: None, - network_size: None, - }; + let price = evmlib::common::Amount::from(1024u64); #[allow(clippy::cast_possible_truncation)] let reward_address = RewardsAddress::new([i as u8; 20]); - let msg = - MerklePaymentCandidateNode::bytes_to_sign(&metrics, &reward_address, timestamp); + let msg = MerklePaymentCandidateNode::bytes_to_sign(&price, &reward_address, timestamp); let sk = MlDsaSecretKey::from_bytes(secret_key.as_bytes()).expect("sk"); let signature = ml_dsa.sign(&sk, &msg).expect("sign").as_bytes().to_vec(); MerklePaymentCandidateNode { pub_key: pub_key.as_bytes().to_vec(), - quoting_metrics: metrics, + price, reward_address, merkle_payment_timestamp: timestamp, signature, @@ -1814,10 +1786,10 @@ mod tests { depth: 2, merkle_payment_timestamp: ts, paid_node_addresses: vec![ - // First paid node: valid (matches candidate 0) - (RewardsAddress::new([0u8; 20]), 0), + // First paid node: valid (matches candidate 0, price >= 1024) + (RewardsAddress::new([0u8; 20]), 0, Amount::from(1024u64)), // Second paid node: index 999 is way beyond CANDIDATES_PER_POOL (16) - (RewardsAddress::new([1u8; 20]), 999), + (RewardsAddress::new([1u8; 20]), 999, Amount::from(1024u64)), ], }; verifier.pool_cache.lock().put(pool_hash, info); @@ -1849,9 +1821,9 @@ mod tests { merkle_payment_timestamp: ts, paid_node_addresses: vec![ // Index 0 with matching address [0x00; 20] - (RewardsAddress::new([0u8; 20]), 0), + (RewardsAddress::new([0u8; 20]), 0, Amount::from(1024u64)), // Index 1 with WRONG address — candidate 1's address is [0x01; 20] - (RewardsAddress::new([0xFF; 20]), 1), + (RewardsAddress::new([0xFF; 20]), 1, Amount::from(1024u64)), ], }; verifier.pool_cache.lock().put(pool_hash, info); @@ -1878,7 +1850,11 @@ mod tests { let info = evmlib::merkle_payments::OnChainPaymentInfo { depth: 3, merkle_payment_timestamp: ts, - paid_node_addresses: vec![(RewardsAddress::new([0u8; 20]), 0)], + paid_node_addresses: vec![( + RewardsAddress::new([0u8; 20]), + 0, + Amount::from(1024u64), + )], }; verifier.pool_cache.lock().put(pool_hash, info); } @@ -1896,4 +1872,36 @@ mod tests { "Error should mention depth/count mismatch: {err_msg}" ); } + + #[tokio::test] + async fn test_merkle_underpayment_rejected() { + let verifier = create_test_verifier(); + let (xorname, tagged_proof, pool_hash, ts) = make_valid_merkle_proof_bytes(); + + // Tree depth=2, so 2 paid nodes required. Candidates have price=1024. + // Pay only 1 wei per node — far below the candidate's quoted price. + { + let info = evmlib::merkle_payments::OnChainPaymentInfo { + depth: 2, + merkle_payment_timestamp: ts, + paid_node_addresses: vec![ + (RewardsAddress::new([0u8; 20]), 0, Amount::from(1u64)), + (RewardsAddress::new([1u8; 20]), 1, Amount::from(1u64)), + ], + }; + verifier.pool_cache.lock().put(pool_hash, info); + } + + let result = verifier.verify_payment(&xorname, Some(&tagged_proof)).await; + + assert!( + result.is_err(), + "Should reject merkle payment where paid amount < candidate's quoted price" + ); + let err_msg = format!("{}", result.expect_err("should fail")); + assert!( + err_msg.contains("Underpayment"), + "Error should mention underpayment: {err_msg}" + ); + } } From 94116139d8e6c04322cc525c5c6e938e457b8f39 Mon Sep 17 00:00:00 2001 From: Warm Beer Date: Wed, 1 Apr 2026 20:48:40 +0200 Subject: [PATCH 2/6] feat!: adapt to evmlib PaymentVault API and simplify pricing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate from the old QuotingMetrics-based pricing and split DataPayments/MerklePayments contracts to the unified PaymentVault API in evmlib. Key changes: - Replace QuotingMetrics with a single `price: Amount` field on quotes - Replace logarithmic pricing with simple quadratic formula (n/6000)² - Unify data_payments_address + merkle_payments_address into payment_vault_address - Verify payments via completedPayments mapping instead of verify_data_payment batch call Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 2 - Cargo.toml | 2 +- src/bin/ant-devnet/main.rs | 10 +- src/devnet.rs | 7 +- src/payment/pricing.rs | 336 +++++++++++------------------------- src/payment/proof.rs | 30 +--- src/payment/quote.rs | 116 ++++--------- src/payment/single_node.rs | 232 +++++++++---------------- src/storage/handler.rs | 4 +- tests/e2e/merkle_payment.rs | 18 +- 10 files changed, 238 insertions(+), 519 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fb387cf..cfc5f6f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2285,8 +2285,6 @@ dependencies = [ [[package]] name = "evmlib" version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d608fcd0976beee509fef7fa391735571cb2fffd715ddca174322180300b6615" dependencies = [ "alloy", "ant-merkle", diff --git a/Cargo.toml b/Cargo.toml index d6e2e2c..9185bee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ saorsa-core = "0.21.0" saorsa-pqc = "0.5" # Payment verification - autonomi network lookup + EVM payment -evmlib = "0.5.0" +evmlib = { path = "../evmlib" } xor_name = "5" # Caching - LRU cache for verified XorNames diff --git a/src/bin/ant-devnet/main.rs b/src/bin/ant-devnet/main.rs index b3326b0..3103902 100644 --- a/src/bin/ant-devnet/main.rs +++ b/src/bin/ant-devnet/main.rs @@ -64,14 +64,11 @@ async fn main() -> color_eyre::Result<()> { .default_wallet_private_key() .map_err(|e| color_eyre::eyre::eyre!("Failed to get wallet key: {e}"))?; - let (rpc_url, token_addr, payments_addr, merkle_addr) = match &network { + let (rpc_url, token_addr, vault_addr) = match &network { evmlib::Network::Custom(custom) => ( custom.rpc_url_http.to_string(), format!("{:?}", custom.payment_token_address), - format!("{:?}", custom.data_payments_address), - custom - .merkle_payments_address - .map(|addr| format!("{addr:?}")), + format!("{:?}", custom.payment_vault_address), ), _ => { return Err(color_eyre::eyre::eyre!( @@ -93,8 +90,7 @@ async fn main() -> color_eyre::Result<()> { rpc_url, wallet_private_key: wallet_key, payment_token_address: token_addr, - data_payments_address: payments_addr, - merkle_payments_address: merkle_addr, + payment_vault_address: vault_addr, }) } else { None diff --git a/src/devnet.rs b/src/devnet.rs index 9bd0a1b..3b6da0c 100644 --- a/src/devnet.rs +++ b/src/devnet.rs @@ -244,11 +244,8 @@ pub struct DevnetEvmInfo { pub wallet_private_key: String, /// Payment token contract address. pub payment_token_address: String, - /// Data payments contract address. - pub data_payments_address: String, - /// Merkle payments contract address (for batch payments). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub merkle_payments_address: Option, + /// Unified payment vault contract address (handles both single-node and merkle payments). + pub payment_vault_address: String, } /// Network state for devnet startup lifecycle. diff --git a/src/payment/pricing.rs b/src/payment/pricing.rs index 2290d5c..503661f 100644 --- a/src/payment/pricing.rs +++ b/src/payment/pricing.rs @@ -1,297 +1,159 @@ -//! Local fullness-based pricing algorithm for ant-node. +//! Simple quadratic pricing algorithm for ant-node. //! -//! Mirrors the logarithmic pricing curve from autonomi's `MerklePaymentVault` contract: -//! - Empty node → price ≈ `MIN_PRICE` (floor) -//! - Filling up → price increases logarithmically -//! - Nearly full → price spikes (ln(x) as x→0) -//! - At capacity → returns `u64::MAX` (effectively refuses new data) +//! Uses the formula `(close_records_stored / 6000)^2` to calculate storage price. +//! Integer division means nodes with fewer than 6000 records get a ratio of 0, +//! but a minimum floor of 1 prevents free storage. //! -//! ## Design Rationale: Capacity-Based Pricing +//! ## Design Rationale //! -//! Pricing is based on node **fullness** (percentage of storage capacity used), -//! not on a fixed cost-per-byte. This design mirrors the autonomi -//! `MerklePaymentVault` on-chain contract and creates natural load balancing: -//! -//! - **Empty nodes** charge the minimum floor price, attracting new data -//! - **Nearly full nodes** charge exponentially more via the logarithmic curve -//! - **This pushes clients toward emptier nodes**, distributing data across the network -//! -//! A flat cost-per-byte model would not incentivize distribution — all nodes would -//! charge the same regardless of remaining capacity. The logarithmic curve ensures -//! the network self-balances as nodes fill up. +//! The quadratic curve creates natural load balancing: +//! - **Lightly loaded nodes** (< 6000 records) charge the minimum floor price +//! - **Moderately loaded nodes** charge proportionally more as records grow +//! - **Heavily loaded nodes** charge quadratically more, pushing clients elsewhere use evmlib::common::Amount; -use evmlib::quoting_metrics::QuotingMetrics; -/// Minimum price floor (matches contract's `minPrice = 3`). -const MIN_PRICE: u64 = 3; +/// Divisor for the pricing formula. +const PRICING_DIVISOR: u64 = 6000; -/// Scaling factor for the logarithmic pricing curve. -/// In the contract this is 1e18; we normalize to 1.0 for f64 arithmetic. -const SCALING_FACTOR: f64 = 1.0; +/// PRICING_DIVISOR², precomputed to avoid repeated multiplication. +const DIVISOR_SQUARED: u64 = PRICING_DIVISOR * PRICING_DIVISOR; -/// ANT price constant (normalized to 1.0, matching contract's 1e18/1e18 ratio). -const ANT_PRICE: f64 = 1.0; +/// 1 token = 10^18 wei. +const WEI_PER_TOKEN: u128 = 1_000_000_000_000_000_000; -/// Calculate a local price estimate from node quoting metrics. +/// Minimum price in wei (1 wei) to prevent free storage. +const MIN_PRICE_WEI: u128 = 1; + +/// Calculate storage price in wei from the number of close records stored. /// -/// Implements the autonomi pricing formula: -/// ```text -/// price = (-s/ANT) * (ln|rUpper - 1| - ln|rLower - 1|) + pMin*(rUpper - rLower) - (rUpper - rLower)/ANT -/// ``` +/// Formula: `price_wei = n² × 10¹⁸ / 6000²` /// -/// where: -/// - `rLower = total_cost_units / max_cost_units` (current fullness ratio) -/// - `rUpper = (total_cost_units + cost_unit) / max_cost_units` (fullness after storing) -/// - `s` = scaling factor, `ANT` = ANT price, `pMin` = minimum price -#[allow( - clippy::cast_precision_loss, - clippy::cast_possible_truncation, - clippy::cast_sign_loss -)] +/// This is equivalent to `(n / 6000)²` in tokens, converted to wei, but +/// preserves sub-token precision by scaling before dividing. U256 arithmetic +/// prevents overflow for large record counts. #[must_use] -pub fn calculate_price(metrics: &QuotingMetrics) -> Amount { - let min_price = Amount::from(MIN_PRICE); - - // Edge case: zero or very small capacity - if metrics.max_records == 0 { - return min_price; +pub fn calculate_price(close_records_stored: usize) -> Amount { + let n = Amount::from(close_records_stored); + let n_squared = n.saturating_mul(n); + let price_wei = + n_squared.saturating_mul(Amount::from(WEI_PER_TOKEN)) / Amount::from(DIVISOR_SQUARED); + if price_wei.is_zero() { + Amount::from(MIN_PRICE_WEI) + } else { + price_wei } +} - // Use close_records_stored as the authoritative record count for pricing. - let total_records = metrics.close_records_stored as u64; - - let max_records = metrics.max_records as f64; - - // Normalize to [0, 1) range (matching contract's _getBound) - let r_lower = total_records as f64 / max_records; - // Adding one record (cost_unit = 1 normalized) - let r_upper = (total_records + 1) as f64 / max_records; - - // At capacity: return maximum price to effectively refuse new data - if r_lower >= 1.0 || r_upper >= 1.0 { - return Amount::from(u64::MAX); - } - if (r_upper - r_lower).abs() < f64::EPSILON { - return min_price; - } +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; - // Calculate |r - 1| for logarithm inputs - let upper_diff = (r_upper - 1.0).abs(); - let lower_diff = (r_lower - 1.0).abs(); + const WEI: u128 = WEI_PER_TOKEN; - // Avoid log(0) - if upper_diff < f64::EPSILON || lower_diff < f64::EPSILON { - return min_price; + /// Helper: expected price for n records = n² * 10^18 / 6000² + fn expected_price(n: u64) -> Amount { + let n = Amount::from(n); + n * n * Amount::from(WEI) / Amount::from(DIVISOR_SQUARED) } - let log_upper = upper_diff.ln(); - let log_lower = lower_diff.ln(); - let log_diff = log_upper - log_lower; - - let linear_part = r_upper - r_lower; - - // Formula: price = (-s/ANT) * logDiff + pMin * linearPart - linearPart/ANT - let part_one = (-SCALING_FACTOR / ANT_PRICE) * log_diff; - let part_two = MIN_PRICE as f64 * linear_part; - let part_three = linear_part / ANT_PRICE; - - let price = part_one + part_two - part_three; - - if price <= 0.0 || !price.is_finite() { - return min_price; + #[test] + fn test_zero_records_gets_min_price() { + let price = calculate_price(0); + assert_eq!(price, Amount::from(MIN_PRICE_WEI)); } - // Scale by data_size (larger data costs proportionally more) - let data_size_factor = metrics.data_size.max(1) as f64; - let scaled_price = price * data_size_factor; - - if !scaled_price.is_finite() { - return min_price; + #[test] + fn test_one_record_nonzero() { + // 1² * 1e18 / 36e6 = 1e18 / 36e6 ≈ 27_777_777_777 + let price = calculate_price(1); + assert_eq!(price, expected_price(1)); + assert!(price > Amount::ZERO); } - // Convert to Amount (U256), floor at MIN_PRICE - let price_u64 = if scaled_price > u64::MAX as f64 { - u64::MAX - } else { - (scaled_price as u64).max(MIN_PRICE) - }; - - Amount::from(price_u64) -} - -#[cfg(test)] -#[allow(clippy::unwrap_used, clippy::expect_used)] -mod tests { - use super::*; - - fn make_metrics( - records_stored: usize, - max_records: usize, - data_size: usize, - data_type: u32, - ) -> QuotingMetrics { - let records_per_type = if records_stored > 0 { - vec![(data_type, u32::try_from(records_stored).unwrap_or(u32::MAX))] - } else { - vec![] - }; - QuotingMetrics { - data_type, - data_size, - close_records_stored: records_stored, - records_per_type, - max_records, - received_payment_count: 0, - live_time: 0, - network_density: None, - network_size: Some(500), - } + #[test] + fn test_at_divisor_gets_one_token() { + // 6000² * 1e18 / 6000² = 1e18 + let price = calculate_price(6000); + assert_eq!(price, Amount::from(WEI)); } #[test] - fn test_empty_node_gets_min_price() { - let metrics = make_metrics(0, 1000, 1, 0); - let price = calculate_price(&metrics); - // Empty node should return approximately MIN_PRICE - assert_eq!(price, Amount::from(MIN_PRICE)); + fn test_double_divisor_gets_four_tokens() { + // 12000² * 1e18 / 6000² = 4e18 + let price = calculate_price(12000); + assert_eq!(price, Amount::from(4 * WEI)); } #[test] - fn test_half_full_node_costs_more() { - let empty = make_metrics(0, 1000, 1024, 0); - let half = make_metrics(500, 1000, 1024, 0); - let price_empty = calculate_price(&empty); - let price_half = calculate_price(&half); - assert!( - price_half > price_empty, - "Half-full price ({price_half}) should exceed empty price ({price_empty})" - ); + fn test_triple_divisor_gets_nine_tokens() { + // 18000² * 1e18 / 6000² = 9e18 + let price = calculate_price(18000); + assert_eq!(price, Amount::from(9 * WEI)); } #[test] - fn test_nearly_full_node_costs_much_more() { - let half = make_metrics(500, 1000, 1024, 0); - let nearly_full = make_metrics(900, 1000, 1024, 0); - let price_half = calculate_price(&half); - let price_nearly_full = calculate_price(&nearly_full); + fn test_smooth_pricing_no_staircase() { + // With the old integer-division approach, 6000 and 11999 gave the same price. + // Now 11999 should give a higher price than 6000. + let price_6k = calculate_price(6000); + let price_11k = calculate_price(11999); assert!( - price_nearly_full > price_half, - "Nearly-full price ({price_nearly_full}) should far exceed half-full price ({price_half})" + price_11k > price_6k, + "11999 records ({price_11k}) should cost more than 6000 ({price_6k})" ); } #[test] - fn test_full_node_returns_max_price() { - // At capacity (r_lower >= 1.0), effectively refuse new data with max price - let metrics = make_metrics(1000, 1000, 1024, 0); - let price = calculate_price(&metrics); - assert_eq!(price, Amount::from(u64::MAX)); + fn test_price_increases_with_records() { + let price_low = calculate_price(6000); + let price_mid = calculate_price(12000); + let price_high = calculate_price(18000); + assert!(price_mid > price_low); + assert!(price_high > price_mid); } #[test] fn test_price_increases_monotonically() { - let max_records = 1000; - let data_size = 1024; let mut prev_price = Amount::ZERO; - - // Check from 0% to 99% full - for pct in 0..100 { - let records = pct * max_records / 100; - let metrics = make_metrics(records, max_records, data_size, 0); - let price = calculate_price(&metrics); + for records in (0..60000).step_by(100) { + let price = calculate_price(records); assert!( price >= prev_price, - "Price at {pct}% ({price}) should be >= price at previous step ({prev_price})" + "Price at {records} records ({price}) should be >= previous ({prev_price})" ); prev_price = price; } } #[test] - fn test_zero_max_records_returns_min_price() { - let metrics = make_metrics(0, 0, 1024, 0); - let price = calculate_price(&metrics); - assert_eq!(price, Amount::from(MIN_PRICE)); + fn test_large_value_no_overflow() { + let price = calculate_price(usize::MAX); + assert!(price > Amount::ZERO); } #[test] - fn test_different_data_sizes_same_fullness() { - let small = make_metrics(500, 1000, 100, 0); - let large = make_metrics(500, 1000, 10000, 0); - let price_small = calculate_price(&small); - let price_large = calculate_price(&large); - assert!( - price_large > price_small, - "Larger data ({price_large}) should cost more than smaller data ({price_small})" - ); - } - - #[test] - fn test_price_with_multiple_record_types() { - // 300 type-0 records + 200 type-1 records = 500 total out of 1000 - let metrics = QuotingMetrics { - data_type: 0, - data_size: 1024, - close_records_stored: 500, - records_per_type: vec![(0, 300), (1, 200)], - max_records: 1000, - received_payment_count: 0, - live_time: 0, - network_density: None, - network_size: Some(500), - }; - let price_multi = calculate_price(&metrics); - - // Compare with single-type equivalent (500 of type 0) - let metrics_single = make_metrics(500, 1000, 1024, 0); - let price_single = calculate_price(&metrics_single); - - // Same total records → same price - assert_eq!(price_multi, price_single); - } - - #[test] - fn test_price_at_95_percent() { - let metrics = make_metrics(950, 1000, 1024, 0); - let price = calculate_price(&metrics); - let min = Amount::from(MIN_PRICE); - assert!( - price > min, - "Price at 95% should be above minimum, got {price}" - ); - } - - #[test] - fn test_price_at_99_percent() { - let metrics = make_metrics(990, 1000, 1024, 0); - let price = calculate_price(&metrics); - let price_95 = calculate_price(&make_metrics(950, 1000, 1024, 0)); - assert!( - price > price_95, - "Price at 99% ({price}) should exceed price at 95% ({price_95})" - ); + fn test_price_deterministic() { + let price1 = calculate_price(12000); + let price2 = calculate_price(12000); + assert_eq!(price1, price2); } #[test] - fn test_over_capacity_returns_max_price() { - // 1100 records stored but max is 1000 — over capacity - let metrics = make_metrics(1100, 1000, 1024, 0); - let price = calculate_price(&metrics); - assert_eq!( - price, - Amount::from(u64::MAX), - "Over-capacity should return max price" - ); + fn test_quadratic_growth() { + // price at 4x records should be 16x price at 1x + let price_1x = calculate_price(6000); + let price_4x = calculate_price(24000); + assert_eq!(price_1x, Amount::from(WEI)); + assert_eq!(price_4x, Amount::from(16 * WEI)); } #[test] - fn test_price_deterministic() { - let metrics = make_metrics(500, 1000, 1024, 0); - let price1 = calculate_price(&metrics); - let price2 = calculate_price(&metrics); - let price3 = calculate_price(&metrics); - assert_eq!(price1, price2); - assert_eq!(price2, price3); + fn test_small_record_counts_are_cheap() { + // 100 records: 100² * 1e18 / 36e6 ≈ 277_777_777_777_777 wei ≈ 0.000278 tokens + let price = calculate_price(100); + assert_eq!(price, expected_price(100)); + assert!(price < Amount::from(WEI)); // well below 1 token } } diff --git a/src/payment/proof.rs b/src/payment/proof.rs index 03dfc8f..0925bd5 100644 --- a/src/payment/proof.rs +++ b/src/payment/proof.rs @@ -116,11 +116,11 @@ pub fn deserialize_merkle_proof(bytes: &[u8]) -> std::result::Result QuotingMetrics { - self.metrics_tracker.get_metrics(0, 0) + pub fn records_stored(&self) -> usize { + self.metrics_tracker.records_stored() } /// Record a payment received (delegates to metrics tracker). @@ -214,11 +210,11 @@ impl QuoteGenerator { .as_ref() .ok_or_else(|| Error::Payment("Quote signing not configured".to_string()))?; - let quoting_metrics = self.metrics_tracker.get_metrics(data_size, data_type); + let price = calculate_price(self.metrics_tracker.records_stored()); // Compute the same bytes_to_sign used by the upstream library let msg = MerklePaymentCandidateNode::bytes_to_sign( - "ing_metrics, + &price, &self.rewards_address, merkle_payment_timestamp, ); @@ -233,7 +229,7 @@ impl QuoteGenerator { let candidate = MerklePaymentCandidateNode { pub_key: self.pub_key.clone(), - quoting_metrics, + price, reward_address: self.rewards_address, merkle_payment_timestamp, signature, @@ -355,7 +351,7 @@ pub fn verify_merkle_candidate_signature(candidate: &MerklePaymentCandidateNode) }; let msg = MerklePaymentCandidateNode::bytes_to_sign( - &candidate.quoting_metrics, + &candidate.price, &candidate.reward_address, candidate.merkle_payment_timestamp, ); @@ -414,6 +410,7 @@ pub fn wire_ml_dsa_signer( mod tests { use super::*; use crate::payment::metrics::QuotingMetricsTracker; + use evmlib::common::Amount; use saorsa_pqc::pqc::types::MlDsaSecretKey; fn create_test_generator() -> QuoteGenerator { @@ -532,29 +529,12 @@ mod tests { } #[test] - fn test_current_metrics() { + fn test_records_stored() { let rewards_address = RewardsAddress::new([1u8; 20]); let metrics_tracker = QuotingMetricsTracker::new(500, 50); let generator = QuoteGenerator::new(rewards_address, metrics_tracker); - let metrics = generator.current_metrics(); - assert_eq!(metrics.max_records, 500); - assert_eq!(metrics.close_records_stored, 50); - assert_eq!(metrics.data_size, 0); - assert_eq!(metrics.data_type, 0); - } - - #[test] - fn test_record_payment_delegation() { - let rewards_address = RewardsAddress::new([1u8; 20]); - let metrics_tracker = QuotingMetricsTracker::new(1000, 0); - let generator = QuoteGenerator::new(rewards_address, metrics_tracker); - - generator.record_payment(); - generator.record_payment(); - - let metrics = generator.current_metrics(); - assert_eq!(metrics.received_payment_count, 2); + assert_eq!(generator.records_stored(), 50); } #[test] @@ -567,8 +547,7 @@ mod tests { generator.record_store(1); generator.record_store(0); - let metrics = generator.current_metrics(); - assert_eq!(metrics.close_records_stored, 3); + assert_eq!(generator.records_stored(), 3); } #[test] @@ -576,17 +555,15 @@ mod tests { let generator = create_test_generator(); let content = [10u8; 32]; - // Data type 0 (chunk) + // All data types produce the same price (price depends on records_stored, not data_type) let q0 = generator.create_quote(content, 1024, 0).expect("type 0"); - assert_eq!(q0.quoting_metrics.data_type, 0); - - // Data type 1 let q1 = generator.create_quote(content, 512, 1).expect("type 1"); - assert_eq!(q1.quoting_metrics.data_type, 1); - - // Data type 2 let q2 = generator.create_quote(content, 256, 2).expect("type 2"); - assert_eq!(q2.quoting_metrics.data_type, 2); + + // All quotes should have a valid price (minimum floor of 1) + assert!(q0.price >= Amount::from(1u64)); + assert!(q1.price >= Amount::from(1u64)); + assert!(q2.price >= Amount::from(1u64)); } #[test] @@ -594,8 +571,9 @@ mod tests { let generator = create_test_generator(); let content = [11u8; 32]; + // Price depends on records_stored, not data size let quote = generator.create_quote(content, 0, 0).expect("zero size"); - assert_eq!(quote.quoting_metrics.data_size, 0); + assert!(quote.price >= Amount::from(1u64)); } #[test] @@ -603,10 +581,11 @@ mod tests { let generator = create_test_generator(); let content = [12u8; 32]; + // Price depends on records_stored, not data size let quote = generator .create_quote(content, 10_000_000, 0) .expect("large size"); - assert_eq!(quote.quoting_metrics.data_size, 10_000_000); + assert!(quote.price >= Amount::from(1u64)); } #[test] @@ -614,17 +593,7 @@ mod tests { let quote = PaymentQuote { content: xor_name::XorName([0u8; 32]), timestamp: SystemTime::now(), - quoting_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, - }, + price: Amount::from(1u64), rewards_address: RewardsAddress::new([0u8; 20]), pub_key: vec![], signature: vec![], @@ -722,11 +691,8 @@ mod tests { // Verify the timestamp was set correctly assert_eq!(candidate.merkle_payment_timestamp, timestamp); - // Verify metrics match what the tracker would produce - assert_eq!(candidate.quoting_metrics.data_size, 2048); - assert_eq!(candidate.quoting_metrics.data_type, 0); - assert_eq!(candidate.quoting_metrics.max_records, 800); - assert_eq!(candidate.quoting_metrics.close_records_stored, 50); + // Verify price was calculated from records_stored using the pricing formula + assert_eq!(candidate.price, calculate_price(50)); // Verify the public key is the ML-DSA-65 public key (not ed25519) assert_eq!( @@ -763,25 +729,15 @@ mod tests { .duration_since(std::time::UNIX_EPOCH) .expect("system time") .as_secs(); - let metrics = QuotingMetrics { - data_size: 4096, - data_type: 0, - close_records_stored: 10, - records_per_type: vec![], - max_records: 500, - received_payment_count: 3, - live_time: 600, - network_density: None, - network_size: None, - }; + let price = Amount::from(42u64); - let msg = MerklePaymentCandidateNode::bytes_to_sign(&metrics, &rewards_address, timestamp); + let msg = MerklePaymentCandidateNode::bytes_to_sign(&price, &rewards_address, timestamp); let sk = MlDsaSecretKey::from_bytes(secret_key.as_bytes()).expect("sk"); let signature = ml_dsa.sign(&sk, &msg).expect("sign").as_bytes().to_vec(); MerklePaymentCandidateNode { pub_key: public_key.as_bytes().to_vec(), - quoting_metrics: metrics, + price, reward_address: rewards_address, merkle_payment_timestamp: timestamp, signature, @@ -821,12 +777,12 @@ mod tests { } #[test] - fn test_verify_merkle_candidate_tampered_metrics() { + fn test_verify_merkle_candidate_tampered_price() { let mut candidate = make_valid_merkle_candidate(); - candidate.quoting_metrics.data_size = 999_999; + candidate.price = Amount::from(999_999u64); assert!( !verify_merkle_candidate_signature(&candidate), - "Tampered quoting_metrics must invalidate the signature" + "Tampered price must invalidate the signature" ); } diff --git a/src/payment/single_node.rs b/src/payment/single_node.rs index 627e1ff..2b22e65 100644 --- a/src/payment/single_node.rs +++ b/src/payment/single_node.rs @@ -13,31 +13,12 @@ use crate::ant_protocol::CLOSE_GROUP_SIZE; use crate::error::{Error, Result}; use evmlib::common::{Amount, QuoteHash}; -use evmlib::contract::payment_vault; -use evmlib::quoting_metrics::QuotingMetrics; use evmlib::wallet::Wallet; use evmlib::Network as EvmNetwork; use evmlib::PaymentQuote; use evmlib::RewardsAddress; use tracing::info; -/// Create zero-valued `QuotingMetrics` for payment verification. -/// -/// The contract doesn't validate metric values, so we use zeroes. -fn zero_quoting_metrics() -> QuotingMetrics { - 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, - } -} - /// Index of the median-priced node after sorting, derived from `CLOSE_GROUP_SIZE`. const MEDIAN_INDEX: usize = CLOSE_GROUP_SIZE / 2; @@ -63,8 +44,6 @@ pub struct QuotePaymentInfo { 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 { @@ -120,7 +99,6 @@ impl SingleNodePayment { } else { Amount::ZERO }, - quoting_metrics: quote.quoting_metrics, }) .collect(); @@ -220,49 +198,65 @@ impl SingleNodePayment { network: &EvmNetwork, owned_quote_hash: Option, ) -> Result { - // Build payment digest for all 5 quotes - // Each quote needs an owned QuotingMetrics (tuple requires ownership) - let payment_digest: Vec<_> = self - .quotes - .iter() - .map(|q| (q.quote_hash, zero_quoting_metrics(), 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() + "Verifying {} payments via completedPayments mapping", + self.quotes.len() ); - let verified_amount = - payment_vault::verify_data_payment(network, owned_quote_hashes.clone(), payment_digest) + let provider = evmlib::utils::http_provider(network.rpc_url().clone()); + let vault_address = *network.payment_vault_address(); + let contract = + evmlib::contract::payment_vault::interface::IPaymentVault::new(vault_address, provider); + + let mut total_verified = Amount::ZERO; + let mut owned_on_chain = Amount::ZERO; + + for quote_info in &self.quotes { + let result = contract + .completedPayments(quote_info.quote_hash) + .call() .await - .map_err(|e| Error::Payment(format!("Payment verification failed: {e}")))?; + .map_err(|e| Error::Payment(format!("completedPayments lookup 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 on_chain_amount = Amount::from(result.amount); + if on_chain_amount > Amount::ZERO { + total_verified = total_verified.checked_add(on_chain_amount).ok_or_else(|| { + Error::Payment("Overflow summing verified amounts".to_string()) + })?; + + if owned_quote_hash == Some(quote_info.quote_hash) { + owned_on_chain = on_chain_amount; + } + } + } + + if total_verified == Amount::ZERO { + return Err(Error::Payment( + "No payments found on-chain for any quote".to_string(), + )); + } + + // If we own a quote, verify the amount matches + if let Some(owned_hash) = owned_quote_hash { let expected = self .quotes .iter() - .find(|q| Some(q.quote_hash) == owned_quote_hash) + .find(|q| q.quote_hash == owned_hash) .ok_or_else(|| Error::Payment("Owned quote hash not found in payment".to_string()))? .amount; - if verified_amount != expected { + if owned_on_chain != expected { return Err(Error::Payment(format!( - "Payment amount mismatch: expected {expected}, verified {verified_amount}" + "Payment amount mismatch: expected {expected}, on-chain {owned_on_chain}" ))); } - info!("Payment verified: {verified_amount} atto received"); + info!("Payment verified: {owned_on_chain} atto received"); + } else { + info!("Payment verified as valid on-chain"); } - Ok(verified_amount) + Ok(total_verified) } } @@ -270,9 +264,7 @@ impl SingleNodePayment { 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, Testnet}; + use evmlib::testnet::{deploy_network_token_contract, deploy_payment_vault_contract, Testnet}; use evmlib::transaction_config::TransactionConfig; use evmlib::utils::{dummy_address, dummy_hash}; use evmlib::wallet::Wallet; @@ -285,17 +277,7 @@ mod tests { PaymentQuote { content: XorName::random(&mut rand::thread_rng()), timestamp: SystemTime::now(), - quoting_metrics: QuotingMetrics { - data_size: 1024, - data_type: 0, - close_records_stored: 0, - records_per_type: vec![], - max_records: 1000, - received_payment_count: 0, - live_time: 0, - network_density: None, - network_size: None, - }, + price: Amount::from(1u64), rewards_address: RewardsAddress::new([rewards_addr_seed; 20]), pub_key: vec![], signature: vec![], @@ -337,7 +319,7 @@ mod tests { .await .expect("deploy network token"); let mut payment_vault = - deploy_data_payments_contract(&rpc_url, &node, *network_token.contract.address()) + deploy_payment_vault_contract(&rpc_url, &node, *network_token.contract.address()) .await .expect("deploy data payments"); @@ -375,23 +357,20 @@ mod tests { 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: zero_quoting_metrics().into(), - rewardsAddress: v.1, - quoteHash: v.0, - }) - .collect(); - - let results = payment_vault - .verify_payment(payment_verifications) - .await - .expect("Verify payment failed"); + // Verify payments via completedPayments mapping + for (quote_hash, _reward_address, amount) in "e_payments { + let result = payment_vault + .contract + .completedPayments(*quote_hash) + .call() + .await + .expect("completedPayments lookup failed"); - for result in results { - assert!(result.isValid, "Payment verification should be valid"); + let on_chain_amount = result.amount; + assert!( + on_chain_amount >= u128::try_from(*amount).expect("amount fits u128"), + "On-chain amount should be >= paid amount" + ); } println!("✓ All 5 payments verified successfully"); @@ -408,7 +387,7 @@ mod tests { .await .expect("deploy network token"); let mut payment_vault = - deploy_data_payments_contract(&rpc_url, &node, *network_token.contract.address()) + deploy_payment_vault_contract(&rpc_url, &node, *network_token.contract.address()) .await .expect("deploy data payments"); @@ -452,31 +431,32 @@ mod tests { 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: zero_quoting_metrics().into(), - rewardsAddress: v.1, - quoteHash: v.0, - }) - .collect(); + // Verify via completedPayments mapping - let results = payment_vault - .verify_payment(payment_verifications) + // Check that real payment is recorded on-chain + let real_result = payment_vault + .contract + .completedPayments(real_quote_hash) + .call() .await - .expect("Verify payment failed"); + .expect("completedPayments lookup failed"); - // Check that real payment is valid assert!( - results.first().is_some_and(|r| r.isValid), - "Real payment should be valid" + real_result.amount > 0, + "Real payment should have non-zero amount on-chain" ); 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); + // Check dummy payments (should have 0 amount) + for (i, (hash, _, _)) in quote_payments.iter().skip(1).enumerate() { + let result = payment_vault + .contract + .completedPayments(*hash) + .call() + .await + .expect("completedPayments lookup failed"); + + println!(" Dummy payment {}: amount={}", i + 1, result.amount); } println!("\n✅ SingleNode payment strategy works!"); @@ -492,17 +472,7 @@ mod tests { let quote = PaymentQuote { content: XorName::random(&mut rand::thread_rng()), timestamp: SystemTime::now(), - quoting_metrics: QuotingMetrics { - data_size: 1024, - data_type: 0, - close_records_stored: 0, - records_per_type: vec![(0, 10)], - max_records: 1000, - received_payment_count: 5, - live_time: 3600, - network_density: None, - network_size: Some(100), - }, + price: Amount::from(*price), rewards_address: RewardsAddress::new([1u8; 20]), pub_key: vec![], signature: vec![], @@ -633,63 +603,33 @@ mod tests { // Approve tokens wallet - .approve_to_spend_tokens(*network.data_payments_address(), evmlib::common::U256::MAX) + .approve_to_spend_tokens(*network.payment_vault_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 + // Create 5 quotes with prices calculated from record counts let chunk_xor = XorName::random(&mut rand::thread_rng()); - let chunk_size = 1024usize; let mut quotes_with_prices = Vec::new(); for i in 0..CLOSE_GROUP_SIZE { - let quoting_metrics = QuotingMetrics { - data_size: chunk_size, - data_type: 0, - close_records_stored: 10 + i, - 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, - network_density: None, - network_size: Some(100), - }; - - // Get market price for this quote - // PERF-004: Clone required - payment_vault::get_market_price (external API from evmlib) - // takes ownership of Vec. We need quoting_metrics again below for - // PaymentQuote construction, so the clone is unavoidable. - 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(format!( - "Empty price list from get_market_price for quote {}: expected at least 1 price but got {} elements", - i, - prices.len() - )) - })?; + let records_stored = 10 + i; + let price = crate::payment::pricing::calculate_price(records_stored); let quote = PaymentQuote { content: chunk_xor, timestamp: SystemTime::now(), - quoting_metrics, + price, rewards_address: wallet.address(), pub_key: vec![], signature: vec![], }; - quotes_with_prices.push((quote, *price)); + quotes_with_prices.push((quote, price)); } - println!("✓ Got 5 real quotes from contract"); + println!("✓ Got 5 quotes with calculated prices"); // Create SingleNode payment (will sort internally and select median) let payment = SingleNodePayment::from_quotes(quotes_with_prices)?; diff --git a/src/storage/handler.rs b/src/storage/handler.rs index 038f6c0..68a0358 100644 --- a/src/storage/handler.rs +++ b/src/storage/handler.rs @@ -827,8 +827,8 @@ mod tests { ); assert_eq!(candidate.merkle_payment_timestamp, timestamp); - assert_eq!(candidate.quoting_metrics.data_size, 4096); - assert_eq!(candidate.quoting_metrics.data_type, DATA_TYPE_CHUNK); + // Node-calculated price based on records stored + assert!(candidate.price >= evmlib::common::Amount::ZERO); } other => panic!("expected MerkleCandidateQuoteResponse::Success, got: {other:?}"), } diff --git a/tests/e2e/merkle_payment.rs b/tests/e2e/merkle_payment.rs index ee59522..e9bf6bb 100644 --- a/tests/e2e/merkle_payment.rs +++ b/tests/e2e/merkle_payment.rs @@ -22,11 +22,11 @@ use ant_node::compute_address; use ant_node::payment::{ serialize_merkle_proof, MAX_PAYMENT_PROOF_SIZE_BYTES, MIN_PAYMENT_PROOF_SIZE_BYTES, }; +use evmlib::common::Amount; use evmlib::merkle_payments::{ MerklePaymentCandidateNode, MerklePaymentCandidatePool, MerklePaymentProof, MerkleTree, CANDIDATES_PER_POOL, }; -use evmlib::quoting_metrics::QuotingMetrics; use evmlib::testnet::Testnet; use evmlib::RewardsAddress; use rand::Rng; @@ -178,26 +178,16 @@ fn build_candidate_nodes(timestamp: u64) -> [MerklePaymentCandidateNode; CANDIDA std::array::from_fn(|i| { let ml_dsa = MlDsa65::new(); let (pub_key, secret_key) = ml_dsa.generate_keypair().expect("keygen"); - let metrics = QuotingMetrics { - data_size: 1024, - data_type: 0, - close_records_stored: i * 10, - records_per_type: vec![], - max_records: 500, - received_payment_count: 0, - live_time: 100, - network_density: None, - network_size: None, - }; + let price = Amount::from(1024u64); #[allow(clippy::cast_possible_truncation)] let reward_address = RewardsAddress::new([i as u8; 20]); - let msg = MerklePaymentCandidateNode::bytes_to_sign(&metrics, &reward_address, timestamp); + let msg = MerklePaymentCandidateNode::bytes_to_sign(&price, &reward_address, timestamp); let sk = MlDsaSecretKey::from_bytes(secret_key.as_bytes()).expect("sk"); let signature = ml_dsa.sign(&sk, &msg).expect("sign").as_bytes().to_vec(); MerklePaymentCandidateNode { pub_key: pub_key.as_bytes().to_vec(), - quoting_metrics: metrics, + price, reward_address, merkle_payment_timestamp: timestamp, signature, From 08bb16f5e240d0c963c36afebd1e1b6c43368d81 Mon Sep 17 00:00:00 2001 From: Warm Beer Date: Wed, 1 Apr 2026 22:42:11 +0200 Subject: [PATCH 3/6] chore: switch evmlib dependency to Git-based source on refactor/unify-payment-vault-v2 branch --- Cargo.lock | 1 + Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index cfc5f6f..ca3700c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2285,6 +2285,7 @@ dependencies = [ [[package]] name = "evmlib" version = "0.5.0" +source = "git+https://github.com/WithAutonomi/evmlib/?branch=refactor/unify-payment-vault-v2#f4cdd45ea76a98ced0a416794c92b9b4bc2da224" dependencies = [ "alloy", "ant-merkle", diff --git a/Cargo.toml b/Cargo.toml index 9185bee..a7a65b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ saorsa-core = "0.21.0" saorsa-pqc = "0.5" # Payment verification - autonomi network lookup + EVM payment -evmlib = { path = "../evmlib" } +evmlib = { git = "https://github.com/WithAutonomi/evmlib/", branch = "refactor/unify-payment-vault-v2" } xor_name = "5" # Caching - LRU cache for verified XorNames From 7998ab42594e0be73cf3196f64854ce5af119192 Mon Sep 17 00:00:00 2001 From: Warm Beer Date: Wed, 1 Apr 2026 23:49:26 +0200 Subject: [PATCH 4/6] fix: verify merkle payment amounts against contract formula The verifier checked `paid_amount >= node.price` (individual quote) but the contract pays each winner `median16(quotes) * 2^depth / depth`. A winner quoting above the median could be paid less than their quote, causing the node to incorrectly reject a valid payment. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/payment/verifier.rs | 52 ++++++++++++++++++++++++++++++----------- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/src/payment/verifier.rs b/src/payment/verifier.rs index 407250e..b2acab5 100644 --- a/src/payment/verifier.rs +++ b/src/payment/verifier.rs @@ -10,6 +10,7 @@ use crate::payment::proof::{ deserialize_merkle_proof, deserialize_proof, detect_proof_type, ProofType, }; use crate::payment::quote::{verify_quote_content, verify_quote_signature}; +use evmlib::common::Amount; use evmlib::contract::payment_vault; use evmlib::merkle_batch_payment::{OnChainPaymentInfo, PoolHash}; use evmlib::Network as EvmNetwork; @@ -372,7 +373,7 @@ impl PaymentVerifier { let mut valid_paid_count: usize = 0; for result in &results { - if result.isValid && result.amountPaid > evmlib::common::Amount::ZERO { + if result.isValid && result.amountPaid > Amount::ZERO { valid_paid_count += 1; } } @@ -617,12 +618,32 @@ impl PaymentVerifier { ))); } + // Compute expected per-node payment using the contract formula: + // totalAmount = median16(candidate_prices) * (1 << depth) + // amountPerNode = totalAmount / depth + let expected_per_node = if payment_info.depth > 0 { + let mut candidate_prices: Vec = merkle_proof + .winner_pool + .candidate_nodes + .iter() + .map(|c| c.price) + .collect(); + candidate_prices.sort_unstable(); // ascending + // Upper median (index 8 of 16) — matches Solidity's median16 (k = 8) + let median_price = candidate_prices[candidate_prices.len() / 2]; + let total_amount = median_price * Amount::from(1u64 << payment_info.depth); + total_amount / Amount::from(u64::from(payment_info.depth)) + } else { + Amount::ZERO + }; + // Verify paid node indices, addresses, and amounts against the candidate pool. // // Each paid node must: // 1. Have a valid index within the candidate pool // 2. Match the expected reward address at that index - // 3. Have been paid at least the candidate's quoted price + // 3. Have been paid at least the expected per-node amount from the + // contract formula: median16(prices) * 2^depth / depth // // Note: unlike single-node payments, merkle proofs are NOT bound to a // specific storing node. The contract pays `depth` random nodes from the @@ -648,11 +669,12 @@ impl PaymentVerifier { node.reward_address ))); } - if *paid_amount < node.price { + if *paid_amount < expected_per_node { return Err(Error::Payment(format!( "Underpayment for node at index {idx}: paid {paid_amount}, \ - candidate quoted {}", - node.price + expected at least {expected_per_node} \ + (median16 formula, depth={})", + payment_info.depth ))); } } @@ -688,7 +710,6 @@ impl PaymentVerifier { #[allow(clippy::expect_used)] mod tests { use super::*; - use evmlib::common::Amount; /// Create a verifier for unit tests. EVM is always on, but tests can /// pre-populate the cache to bypass on-chain verification. @@ -1786,10 +1807,11 @@ mod tests { depth: 2, merkle_payment_timestamp: ts, paid_node_addresses: vec![ - // First paid node: valid (matches candidate 0, price >= 1024) - (RewardsAddress::new([0u8; 20]), 0, Amount::from(1024u64)), + // First paid node: valid (matches candidate 0, amount matches formula) + // Expected per-node: median(1024) * 2^2 / 2 = 2048 + (RewardsAddress::new([0u8; 20]), 0, Amount::from(2048u64)), // Second paid node: index 999 is way beyond CANDIDATES_PER_POOL (16) - (RewardsAddress::new([1u8; 20]), 999, Amount::from(1024u64)), + (RewardsAddress::new([1u8; 20]), 999, Amount::from(2048u64)), ], }; verifier.pool_cache.lock().put(pool_hash, info); @@ -1821,9 +1843,10 @@ mod tests { merkle_payment_timestamp: ts, paid_node_addresses: vec![ // Index 0 with matching address [0x00; 20] - (RewardsAddress::new([0u8; 20]), 0, Amount::from(1024u64)), + // Expected per-node: median(1024) * 2^2 / 2 = 2048 + (RewardsAddress::new([0u8; 20]), 0, Amount::from(2048u64)), // Index 1 with WRONG address — candidate 1's address is [0x01; 20] - (RewardsAddress::new([0xFF; 20]), 1, Amount::from(1024u64)), + (RewardsAddress::new([0xFF; 20]), 1, Amount::from(2048u64)), ], }; verifier.pool_cache.lock().put(pool_hash, info); @@ -1878,8 +1901,9 @@ mod tests { let verifier = create_test_verifier(); let (xorname, tagged_proof, pool_hash, ts) = make_valid_merkle_proof_bytes(); - // Tree depth=2, so 2 paid nodes required. Candidates have price=1024. - // Pay only 1 wei per node — far below the candidate's quoted price. + // Tree depth=2, so 2 paid nodes required. Candidates all quote price=1024. + // Expected per-node: median(1024) * 2^2 / 2 = 2048. + // Pay only 1 wei per node — far below the expected amount. { let info = evmlib::merkle_payments::OnChainPaymentInfo { depth: 2, @@ -1896,7 +1920,7 @@ mod tests { assert!( result.is_err(), - "Should reject merkle payment where paid amount < candidate's quoted price" + "Should reject merkle payment where paid amount < expected per-node amount" ); let err_msg = format!("{}", result.expect_err("should fail")); assert!( From b224ad3ed59e8a80c629264669cf5b3f4c1574f7 Mon Sep 17 00:00:00 2001 From: Warm Beer Date: Thu, 2 Apr 2026 00:16:54 +0200 Subject: [PATCH 5/6] fix: add backticks in doc comment to fix clippy::doc_markdown warning Co-Authored-By: Claude Opus 4.6 (1M context) --- src/payment/pricing.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/payment/pricing.rs b/src/payment/pricing.rs index 503661f..96c5bd8 100644 --- a/src/payment/pricing.rs +++ b/src/payment/pricing.rs @@ -16,7 +16,7 @@ use evmlib::common::Amount; /// Divisor for the pricing formula. const PRICING_DIVISOR: u64 = 6000; -/// PRICING_DIVISOR², precomputed to avoid repeated multiplication. +/// `PRICING_DIVISOR²`, precomputed to avoid repeated multiplication. const DIVISOR_SQUARED: u64 = PRICING_DIVISOR * PRICING_DIVISOR; /// 1 token = 10^18 wei. From f283b9ac333b1a4a9251a3ece57c9302b24078f9 Mon Sep 17 00:00:00 2001 From: Warm Beer Date: Thu, 2 Apr 2026 00:51:21 +0200 Subject: [PATCH 6/6] fix: use SingleNodePayment to reconstruct paid amounts for verification Integrate `SingleNodePayment::from_quotes` to derive correct on-chain payment amounts. This ensures exact-match checks in the contract's `verifyPayment` function pass by reconstructing amounts as used by the client. --- src/payment/verifier.rs | 47 +++++++++++++++++++++++++---------------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/src/payment/verifier.rs b/src/payment/verifier.rs index b2acab5..92d51c3 100644 --- a/src/payment/verifier.rs +++ b/src/payment/verifier.rs @@ -10,6 +10,7 @@ use crate::payment::proof::{ deserialize_merkle_proof, deserialize_proof, detect_proof_type, ProofType, }; use crate::payment::quote::{verify_quote_content, verify_quote_signature}; +use crate::payment::single_node::SingleNodePayment; use evmlib::common::Amount; use evmlib::contract::payment_vault; use evmlib::merkle_batch_payment::{OnChainPaymentInfo, PoolHash}; @@ -332,30 +333,40 @@ impl PaymentVerifier { // Verify on-chain payment via the contract's verifyPayment function. // // The SingleNode payment model pays only the median-priced quote (at 3x) - // and sends Amount::ZERO for the other 4. The contract's verifyPayment - // checks that each payment's amount and address match what was recorded - // on-chain. We submit all quotes and require at least one to be valid - // with a non-zero amount. - let payment_digest = payment.digest(); - if payment_digest.is_empty() { - return Err(Error::Payment("Payment has no quotes".to_string())); - } + // and sends Amount::ZERO for the other 4. We must reconstruct the same + // payment amounts the client used so the contract's exact-match check + // (`completedPayments[hash].amount == expected`) passes. + // + // ProofOfPayment::digest() returns raw quote prices, NOT the actual paid + // amounts. We use SingleNodePayment::from_quotes() — the same function + // the client uses — to derive the correct on-chain amounts. + let quotes_with_prices: Vec<_> = payment + .peer_quotes + .iter() + .map(|(_, quote)| (quote.clone(), quote.price)) + .collect(); + let single_payment = SingleNodePayment::from_quotes(quotes_with_prices).map_err(|e| { + Error::Payment(format!( + "Failed to reconstruct payment for verification: {e}" + )) + })?; let provider = evmlib::utils::http_provider(self.config.evm.network.rpc_url().clone()); let vault_address = *self.config.evm.network.payment_vault_address(); let contract = evmlib::contract::payment_vault::interface::IPaymentVault::new(vault_address, provider); - // Build DataPayment entries for the contract's verifyPayment call - let data_payments: Vec<_> = payment_digest + // Build DataPayment entries with the actual paid amounts (3x median, 0 others) + let data_payments: Vec<_> = single_payment + .quotes .iter() - .map(|(quote_hash, amount, rewards_address)| { - evmlib::contract::payment_vault::interface::IPaymentVault::DataPayment { - rewardsAddress: *rewards_address, - amount: *amount, - quoteHash: *quote_hash, - } - }) + .map( + |q| evmlib::contract::payment_vault::interface::IPaymentVault::DataPayment { + rewardsAddress: q.rewards_address, + amount: q.amount, + quoteHash: q.quote_hash, + }, + ) .collect(); let results = contract @@ -369,7 +380,7 @@ impl PaymentVerifier { )) })?; - let total_quotes = payment_digest.len(); + let total_quotes = single_payment.quotes.len(); let mut valid_paid_count: usize = 0; for result in &results {