diff --git a/src/devnet.rs b/src/devnet.rs index 9bd0a1b..8d9e518 100644 --- a/src/devnet.rs +++ b/src/devnet.rs @@ -588,6 +588,7 @@ impl Devnet { evm: evm_config, cache_capacity: DEVNET_PAYMENT_CACHE_CAPACITY, local_rewards_address: rewards_address, + close_group_checker: None, }; let payment_verifier = PaymentVerifier::new(payment_config); let metrics_tracker = diff --git a/src/node.rs b/src/node.rs index 2a2e724..d79ccfc 100644 --- a/src/node.rs +++ b/src/node.rs @@ -358,6 +358,7 @@ impl NodeBuilder { }, cache_capacity: config.payment.cache_capacity, local_rewards_address: rewards_address, + close_group_checker: None, }; let payment_verifier = PaymentVerifier::new(payment_config); // Safe: 5GB fits in usize on all supported 64-bit platforms. diff --git a/src/payment/verifier.rs b/src/payment/verifier.rs index d134c26..426d74d 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::verify_merkle_candidate_signature; use evmlib::contract::merkle_payment_vault; use evmlib::merkle_batch_payment::PoolHash; use evmlib::merkle_payments::OnChainPaymentInfo; @@ -20,6 +21,7 @@ use lru::LruCache; use parking_lot::Mutex; use saorsa_core::identity::node_identity::peer_id_from_public_key_bytes; use std::num::NonZeroUsize; +use std::sync::Arc; use std::time::SystemTime; use tracing::{debug, info}; @@ -65,9 +67,11 @@ impl Default for EvmVerifierConfig { /// Configuration for the payment verifier. /// +/// Callback to check if the local node is in the close group for a given address. +pub type CloseGroupChecker = Arc Vec + Send + Sync>; + /// All new data requires EVM payment on Arbitrum. The cache stores /// previously verified payments to avoid redundant on-chain lookups. -#[derive(Debug, Clone)] pub struct PaymentVerifierConfig { /// EVM verifier configuration. pub evm: EvmVerifierConfig, @@ -76,6 +80,26 @@ pub struct PaymentVerifierConfig { /// Local node's rewards address. /// The verifier rejects payments that don't include this node as a recipient. pub local_rewards_address: RewardsAddress, + /// Optional close group checker for merkle payments. + /// + /// Given a data address, returns the rewards addresses of nodes in the local + /// close group view for that address. Used to verify the storing node is + /// actually responsible for the data. If `None`, the check is skipped. + pub close_group_checker: Option, +} + +impl std::fmt::Debug for PaymentVerifierConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PaymentVerifierConfig") + .field("evm", &self.evm) + .field("cache_capacity", &self.cache_capacity) + .field("local_rewards_address", &self.local_rewards_address) + .field( + "close_group_checker", + &self.close_group_checker.as_ref().map(|_| ""), + ) + .finish() + } } /// Status returned by payment verification. @@ -522,6 +546,17 @@ impl PaymentVerifier { let pool_hash = merkle_proof.winner_pool_hash(); + // Run cheap local checks BEFORE expensive on-chain queries. + // This prevents DoS via garbage proofs that trigger RPC lookups. + for candidate in &merkle_proof.winner_pool.candidate_nodes { + if !verify_merkle_candidate_signature(candidate) { + return Err(Error::Payment(format!( + "Invalid ML-DSA-65 signature on merkle candidate node (reward: {})", + candidate.reward_address + ))); + } + } + // Check pool cache first let cached_info = { let mut pool_cache = self.pool_cache.lock(); @@ -555,6 +590,31 @@ impl PaymentVerifier { paid_node_addresses, }; + // Verify cost units: ensure on-chain packed commitments match what + // the signed node metrics produce. This prevents clients from submitting + // inflated/deflated cost units to manipulate pricing. + let packed_commitments = merkle_payment_vault::get_merkle_payment_packed_commitments( + &self.config.evm.network, + pool_hash, + on_chain_info.merkle_payment_timestamp, + ) + .await + .map_err(|e| { + Error::Payment(format!( + "Failed to get packed commitments for cost unit verification: {e}" + )) + })?; + + merkle_proof + .winner_pool + .verify_cost_units(&packed_commitments, &pool_hash) + .map_err(|e| { + Error::Payment(format!( + "Cost unit verification failed for pool {}: {e}", + hex::encode(pool_hash) + )) + })?; + // Cache the pool info for subsequent chunks in the same batch { let mut pool_cache = self.pool_cache.lock(); @@ -572,20 +632,8 @@ impl PaymentVerifier { on_chain_info }; - // pool_hash was derived from merkle_proof.winner_pool and used to query - // the contract. The contract only returns data if a payment exists for that - // hash. The ML-DSA signature check below ensures the pool contents are - // authentic (nodes actually signed their candidate quotes). - - // Verify ML-DSA-65 signatures and timestamp/data_type consistency - // on all candidate nodes in the winner pool. + // Verify timestamp consistency (signatures already checked above before RPC). for candidate in &merkle_proof.winner_pool.candidate_nodes { - if !crate::payment::verify_merkle_candidate_signature(candidate) { - return Err(Error::Payment(format!( - "Invalid ML-DSA-65 signature on merkle candidate node (reward: {})", - candidate.reward_address - ))); - } if candidate.merkle_payment_timestamp != payment_info.merkle_payment_timestamp { return Err(Error::Payment(format!( "Candidate timestamp mismatch: expected {}, got {} (reward: {})", @@ -653,6 +701,18 @@ impl PaymentVerifier { } } + // Verify this node is in the close group for the data address. + // This prevents nodes from accepting data they're not responsible for. + if let Some(ref checker) = self.config.close_group_checker { + let close_group_addrs = checker(xorname); + if !close_group_addrs.contains(&self.config.local_rewards_address) { + return Err(Error::Payment(format!( + "This node is not in the close group for address {}", + hex::encode(xorname) + ))); + } + } + if tracing::enabled!(tracing::Level::INFO) { info!( "Merkle payment verified for {} (pool: {})", @@ -692,6 +752,7 @@ mod tests { evm: EvmVerifierConfig::default(), cache_capacity: 100, local_rewards_address: RewardsAddress::new([1u8; 20]), + close_group_checker: None, }; PaymentVerifier::new(config) } @@ -1255,6 +1316,7 @@ mod tests { }, cache_capacity: 100, local_rewards_address: local_addr, + close_group_checker: None, }; let verifier = PaymentVerifier::new(config); @@ -1643,6 +1705,7 @@ mod tests { evm: EvmVerifierConfig::default(), cache_capacity: 100, local_rewards_address: RewardsAddress::new([1u8; 20]), + close_group_checker: None, }; let verifier = PaymentVerifier::new(config); diff --git a/src/storage/handler.rs b/src/storage/handler.rs index 038f6c0..9b1a528 100644 --- a/src/storage/handler.rs +++ b/src/storage/handler.rs @@ -416,6 +416,7 @@ mod tests { evm: EvmVerifierConfig::default(), cache_capacity: 100_000, local_rewards_address: rewards_address, + close_group_checker: None, }; let payment_verifier = Arc::new(PaymentVerifier::new(payment_config)); let metrics_tracker = QuotingMetricsTracker::new(1000, 100); diff --git a/tests/e2e/data_types/chunk.rs b/tests/e2e/data_types/chunk.rs index 557892a..ba9b18a 100644 --- a/tests/e2e/data_types/chunk.rs +++ b/tests/e2e/data_types/chunk.rs @@ -442,6 +442,7 @@ mod tests { evm: EvmVerifierConfig { network }, cache_capacity: 100, local_rewards_address: rewards_address, + close_group_checker: None, }); let metrics_tracker = QuotingMetricsTracker::new(1000, 100); let quote_generator = QuoteGenerator::new(rewards_address, metrics_tracker); diff --git a/tests/e2e/testnet.rs b/tests/e2e/testnet.rs index ced13a3..9df5f34 100644 --- a/tests/e2e/testnet.rs +++ b/tests/e2e/testnet.rs @@ -1077,6 +1077,7 @@ impl TestNetwork { }, cache_capacity: TEST_PAYMENT_CACHE_CAPACITY, local_rewards_address: rewards_address, + close_group_checker: None, }; let payment_verifier = PaymentVerifier::new(payment_config);