Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/devnet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
1 change: 1 addition & 0 deletions src/node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
91 changes: 77 additions & 14 deletions src/payment/verifier.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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};

Expand Down Expand Up @@ -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<dyn Fn(&[u8; 32]) -> Vec<RewardsAddress> + 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,
Expand All @@ -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<CloseGroupChecker>,
}

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(|_| "<fn>"),
)
.finish()
}
}

/// Status returned by payment verification.
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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)
))
})?;
Comment on lines +593 to +616
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_merkle_payment_packed_commitments() is an additional on-chain RPC call that currently happens immediately after fetching get_merkle_payment_info(), before running other local validations that might fail (e.g., verify_merkle_proof, paid-node index checks). To minimize RPC amplification from invalid proofs, consider deferring the packed-commitments RPC until after the local proof validations succeed, and only then run verify_cost_units() + cache the pool.

Copilot uses AI. Check for mistakes.

// Cache the pool info for subsequent chunks in the same batch
{
let mut pool_cache = self.pool_cache.lock();
Expand All @@ -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: {})",
Expand Down Expand Up @@ -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)
)));
}
}
Comment on lines +704 to +714
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The close-group membership check is performed after the on-chain queries and other verification steps. Since it only depends on xorname and local state, consider moving it earlier (right after the address match, or at least before any RPC calls) so nodes can reject out-of-responsibility PUTs without spending resources on chain lookups.

Copilot uses AI. Check for mistakes.
Comment on lines +704 to +714
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New behavior when close_group_checker is Some(_) is currently untested. Consider adding unit tests covering both acceptance (local rewards address is in returned close group) and rejection (not in close group), using the existing merkle-proof helpers + pre-populated pool_cache to avoid RPC calls.

Copilot uses AI. Check for mistakes.

if tracing::enabled!(tracing::Level::INFO) {
info!(
"Merkle payment verified for {} (pool: {})",
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -1255,6 +1316,7 @@ mod tests {
},
cache_capacity: 100,
local_rewards_address: local_addr,
close_group_checker: None,
};
let verifier = PaymentVerifier::new(config);

Expand Down Expand Up @@ -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);

Expand Down
1 change: 1 addition & 0 deletions src/storage/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions tests/e2e/data_types/chunk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions tests/e2e/testnet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Loading