diff --git a/Cargo.lock b/Cargo.lock index 3460d52..ae98151 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -900,8 +900,7 @@ dependencies = [ [[package]] name = "ant-node" version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bc2ac7c262accf6b4ab73448816662c921303c4a81932379b90edfdee3e4a5d" +source = "git+https://github.com/WithAutonomi/ant-node.git?branch=feat%2Fadapt-evmlib-payment-vault#41511ab2339a0e2bd3a1904c50e5cc63de9283bc" dependencies = [ "aes-gcm-siv", "blake3", @@ -920,6 +919,7 @@ dependencies = [ "lru", "objc2", "objc2-foundation", + "page_size", "parking_lot", "postcard", "rand 0.8.5", @@ -2415,9 +2415,9 @@ dependencies = [ [[package]] name = "evmlib" -version = "0.5.0" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d608fcd0976beee509fef7fa391735571cb2fffd715ddca174322180300b6615" +checksum = "af67fe5494790b75d91fed4c5dd3215098e5adf071f73f40a238d199116c75ac" dependencies = [ "alloy", "ant-merkle", @@ -3130,7 +3130,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core", ] [[package]] @@ -6629,7 +6629,7 @@ version = "0.58.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" dependencies = [ - "windows-core 0.58.0", + "windows-core", "windows-targets 0.52.6", ] @@ -6639,26 +6639,13 @@ version = "0.58.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" dependencies = [ - "windows-implement 0.58.0", - "windows-interface 0.58.0", + "windows-implement", + "windows-interface", "windows-result 0.2.0", "windows-strings 0.1.0", "windows-targets 0.52.6", ] -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement 0.60.2", - "windows-interface 0.59.3", - "windows-link", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - [[package]] name = "windows-implement" version = "0.58.0" @@ -6670,17 +6657,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "windows-interface" version = "0.58.0" @@ -6692,17 +6668,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "windows-link" version = "0.2.1" diff --git a/ant-cli/src/main.rs b/ant-cli/src/main.rs index 4094c7f..9c58512 100644 --- a/ant-cli/src/main.rs +++ b/ant-cli/src/main.rs @@ -215,24 +215,14 @@ fn resolve_evm_network( .payment_token_address .parse() .map_err(|e| anyhow::anyhow!("Invalid token address: {e}"))?; - let payments_addr: EvmAddress = evm - .data_payments_address + let vault_addr: EvmAddress = evm + .payment_vault_address .parse() - .map_err(|e| anyhow::anyhow!("Invalid payments address: {e}"))?; - let merkle_addr: Option = evm - .merkle_payments_address - .as_ref() - .map(|s| { - s.parse().map_err(|e| { - anyhow::anyhow!("Invalid merkle payments address: {e}") - }) - }) - .transpose()?; + .map_err(|e| anyhow::anyhow!("Invalid payment vault address: {e}"))?; return Ok(EvmNetwork::Custom(CustomNetwork { rpc_url_http: rpc_url, payment_token_address: token_addr, - data_payments_address: payments_addr, - merkle_payments_address: merkle_addr, + payment_vault_address: vault_addr, })); } } diff --git a/ant-core/Cargo.toml b/ant-core/Cargo.toml index 512dfd9..53eb188 100644 --- a/ant-core/Cargo.toml +++ b/ant-core/Cargo.toml @@ -24,7 +24,7 @@ zip = "2" tower-http = { version = "0.6.8", features = ["cors"] } # Data operations -evmlib = "0.5.0" +evmlib = "0.7" xor_name = "5" self_encryption = "0.35.0" futures = "0.3" @@ -35,7 +35,7 @@ tracing = "0.1" bytes = "1" lru = "0.16" rand = "0.8" -ant-node = "0.9.0" +ant-node = { git = "https://github.com/WithAutonomi/ant-node.git", branch = "feat/adapt-evmlib-payment-vault" } saorsa-pqc = "0.5" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/ant-core/src/data/client/batch.rs b/ant-core/src/data/client/batch.rs index 7c53361..0528d9a 100644 --- a/ant-core/src/data/client/batch.rs +++ b/ant-core/src/data/client/batch.rs @@ -13,7 +13,6 @@ use ant_node::core::{MultiAddr, PeerId}; use ant_node::payment::{serialize_single_node_proof, PaymentProof, SingleNodePayment}; use bytes::Bytes; use evmlib::common::{Amount, QuoteHash, TxHash}; -use evmlib::contract::payment_vault::get_market_price; use evmlib::wallet::PayForQuotesError; use evmlib::{EncodedPeerId, PaymentQuote, ProofOfPayment, RewardsAddress}; use futures::stream::{self, StreamExt}; @@ -144,7 +143,7 @@ pub fn finalize_batch_payment( impl Client { /// Prepare a single chunk for batch payment. /// - /// Collects quotes and fetches contract prices without making any + /// Collects quotes and uses node-reported prices without making any /// on-chain transaction. Returns `Ok(None)` if the chunk is already /// stored on the network. /// @@ -168,44 +167,21 @@ impl Client { Err(e) => return Err(e), }; - let evm_network = self.require_evm_network()?; - // Capture all quoted peers for close-group replication. let quoted_peers: Vec<(PeerId, Vec)> = quotes_with_peers .iter() .map(|(peer_id, addrs, _, _)| (*peer_id, addrs.clone())) .collect(); - // Fetch authoritative prices from the on-chain contract. - let metrics_batch: Vec<_> = quotes_with_peers - .iter() - .map(|(_, _, quote, _)| quote.quoting_metrics.clone()) - .collect(); - - let contract_prices = get_market_price(evm_network, metrics_batch) - .await - .map_err(|e| { - Error::Payment(format!("Failed to get market prices from contract: {e}")) - })?; - - if contract_prices.len() != quotes_with_peers.len() { - return Err(Error::Payment(format!( - "Contract returned {} prices for {} quotes", - contract_prices.len(), - quotes_with_peers.len() - ))); - } - // Build peer_quotes for ProofOfPayment + quotes for SingleNodePayment. + // Use node-reported prices directly — no contract price fetch needed. let mut peer_quotes = Vec::with_capacity(quotes_with_peers.len()); let mut quotes_for_payment = Vec::with_capacity(quotes_with_peers.len()); - for ((peer_id, _addrs, quote, _local_price), contract_price) in - quotes_with_peers.into_iter().zip(contract_prices) - { + for (peer_id, _addrs, quote, price) in quotes_with_peers { let encoded = peer_id_to_encoded(&peer_id)?; peer_quotes.push((encoded, quote.clone())); - quotes_for_payment.push((quote, contract_price)); + quotes_for_payment.push((quote, price)); } let payment = SingleNodePayment::from_quotes(quotes_for_payment) @@ -437,21 +413,6 @@ mod tests { use super::*; use ant_node::payment::single_node::QuotePaymentInfo; use ant_node::CLOSE_GROUP_SIZE; - use evmlib::quoting_metrics::QuotingMetrics; - - fn test_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, - } - } /// Helper: build a PreparedChunk with specified payment amounts. fn make_prepared_chunk(amounts: [u64; CLOSE_GROUP_SIZE]) -> PreparedChunk { @@ -460,7 +421,7 @@ mod tests { quote_hash: QuoteHash::from([i as u8 + 1; 32]), rewards_address: RewardsAddress::new([i as u8 + 10; 20]), amount: Amount::from(amounts[i]), - quoting_metrics: test_metrics(), + price: Amount::from(amounts[i]), }); PreparedChunk { diff --git a/ant-core/src/data/client/merkle.rs b/ant-core/src/data/client/merkle.rs index 0b28841..95c912a 100644 --- a/ant-core/src/data/client/merkle.rs +++ b/ant-core/src/data/client/merkle.rs @@ -376,12 +376,8 @@ impl Client { candidate_futures.push(fut); } - self.collect_validated_candidates( - &mut candidate_futures, - merkle_payment_timestamp, - data_type, - ) - .await + self.collect_validated_candidates(&mut candidate_futures, merkle_payment_timestamp) + .await } /// Collect and validate merkle candidate responses until we have enough. @@ -396,7 +392,6 @@ impl Client { >, >, merkle_payment_timestamp: u64, - expected_data_type: u32, ) -> Result<[MerklePaymentCandidateNode; CANDIDATES_PER_POOL]> { let mut candidates = Vec::with_capacity(CANDIDATES_PER_POOL); let mut failures: Vec = Vec::new(); @@ -414,14 +409,6 @@ impl Client { failures.push(format!("{peer_id}: timestamp mismatch")); continue; } - if candidate.quoting_metrics.data_type != expected_data_type { - warn!( - "Data type mismatch from {peer_id}: expected {expected_data_type}, got {}", - candidate.quoting_metrics.data_type - ); - failures.push(format!("{peer_id}: wrong data_type")); - continue; - } candidates.push(candidate); if candidates.len() >= CANDIDATES_PER_POOL { break; @@ -633,8 +620,8 @@ mod tests { #[test] fn test_merkle_proof_serialize_deserialize_roundtrip() { use ant_node::payment::{deserialize_merkle_proof, serialize_merkle_proof}; + use evmlib::common::Amount; use evmlib::merkle_payments::MerklePaymentCandidateNode; - use evmlib::quoting_metrics::QuotingMetrics; use evmlib::RewardsAddress; let addrs = make_test_addresses(4); @@ -654,17 +641,7 @@ mod tests { let candidate_nodes: [MerklePaymentCandidateNode; CANDIDATES_PER_POOL] = std::array::from_fn(|i| MerklePaymentCandidateNode { pub_key: vec![i as u8; 32], - quoting_metrics: QuotingMetrics { - data_size: 1024, - data_type: 0, - close_records_stored: 0, - records_per_type: vec![], - max_records: 100, - received_payment_count: 0, - live_time: 0, - network_density: None, - network_size: None, - }, + price: Amount::from(1024u64), reward_address: RewardsAddress::new([i as u8; 20]), merkle_payment_timestamp: timestamp, signature: vec![i as u8; 64], @@ -702,17 +679,7 @@ mod tests { // Simulates what collect_validated_candidates checks let candidate = MerklePaymentCandidateNode { pub_key: vec![0u8; 32], - quoting_metrics: evmlib::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: evmlib::common::Amount::ZERO, reward_address: evmlib::RewardsAddress::new([0u8; 20]), merkle_payment_timestamp: 1000, signature: vec![0u8; 64], @@ -722,30 +689,6 @@ mod tests { assert_ne!(candidate.merkle_payment_timestamp, 2000); } - #[test] - fn test_candidate_wrong_data_type_rejected() { - let candidate = MerklePaymentCandidateNode { - pub_key: vec![0u8; 32], - quoting_metrics: evmlib::quoting_metrics::QuotingMetrics { - data_size: 0, - data_type: 1, // scratchpad - close_records_stored: 0, - records_per_type: vec![], - max_records: 0, - received_payment_count: 0, - live_time: 0, - network_density: None, - network_size: None, - }, - reward_address: evmlib::RewardsAddress::new([0u8; 20]), - merkle_payment_timestamp: 1000, - signature: vec![0u8; 64], - }; - - // data_type check: 1 (scratchpad) != 0 (chunk) - assert_ne!(candidate.quoting_metrics.data_type, 0); - } - // ========================================================================= // Batch splitting edge cases // ========================================================================= diff --git a/ant-core/src/data/client/mod.rs b/ant-core/src/data/client/mod.rs index eaa9fb1..bbf9e52 100644 --- a/ant-core/src/data/client/mod.rs +++ b/ant-core/src/data/client/mod.rs @@ -118,7 +118,7 @@ impl Client { /// Set the wallet for payment operations. /// /// Also populates the EVM network from the wallet so that - /// price queries work without a separate `with_evm_network` call. + /// token approvals work without a separate `with_evm_network` call. #[must_use] pub fn with_wallet(mut self, wallet: Wallet) -> Self { self.evm_network = Some(wallet.network().clone()); @@ -126,9 +126,9 @@ impl Client { self } - /// Set the EVM network for price queries without requiring a wallet. + /// Set the EVM network without requiring a wallet. /// - /// This enables operations like quote collection and cost estimation + /// This enables token approval and contract interactions /// for external-signer flows where the private key lives outside Rust. #[must_use] pub fn with_evm_network(mut self, network: evmlib::Network) -> Self { diff --git a/ant-core/src/data/client/payment.rs b/ant-core/src/data/client/payment.rs index 39ba048..651bdb6 100644 --- a/ant-core/src/data/client/payment.rs +++ b/ant-core/src/data/client/payment.rs @@ -24,7 +24,7 @@ impl Client { /// /// This orchestrates the full payment flow: /// 1. Collect `CLOSE_GROUP_SIZE` quotes from closest peers - /// 2. Build `SingleNodePayment` (median 3x, others 0) + /// 2. Build `SingleNodePayment` using node-reported prices (median 3x, others 0) /// 3. Pay on-chain via the wallet /// 4. Serialize `PaymentProof` with transaction hashes /// @@ -41,7 +41,7 @@ impl Client { data_size: u64, data_type: u32, ) -> Result<(Vec, Vec<(PeerId, Vec)>)> { - // Wallet is required for the on-chain payment step (step 5 below). + // Wallet is required for the on-chain payment step (step 4 below). // Check early so we don't waste time collecting quotes for a misconfigured client. let wallet = self.require_wallet()?; @@ -56,50 +56,24 @@ impl Client { .map(|(peer_id, addrs, _, _)| (*peer_id, addrs.clone())) .collect(); - // 2. Fetch prices from the on-chain contract rather than using the - // locally-computed estimates. The contract's getQuote() is the authoritative - // price source — the verifyPayment() call recomputes prices from QuotingMetrics - // using the same formula, so we must use matching prices. - let metrics_batch: Vec<_> = quotes_with_peers - .iter() - .map(|(_, _, quote, _)| quote.quoting_metrics.clone()) - .collect(); - - let evm_network = self.require_evm_network()?; - let contract_prices = - evmlib::contract::payment_vault::get_market_price(evm_network, metrics_batch) - .await - .map_err(|e| { - Error::Payment(format!("Failed to get market prices from contract: {e}")) - })?; - - if contract_prices.len() != quotes_with_peers.len() { - return Err(Error::Payment(format!( - "Contract returned {} prices for {} quotes", - contract_prices.len(), - quotes_with_peers.len() - ))); - } - - // 3. Build peer_quotes for ProofOfPayment + quotes for SingleNodePayment + // 2. Build peer_quotes for ProofOfPayment + quotes for SingleNodePayment. + // Use node-reported prices directly — no contract price fetch needed. let mut peer_quotes = Vec::with_capacity(quotes_with_peers.len()); let mut quotes_for_payment = Vec::with_capacity(quotes_with_peers.len()); - for ((peer_id, _addrs, quote, _local_price), contract_price) in - quotes_with_peers.into_iter().zip(contract_prices) - { + for (peer_id, _addrs, quote, price) in quotes_with_peers { let encoded = peer_id_to_encoded(&peer_id)?; peer_quotes.push((encoded, quote.clone())); - quotes_for_payment.push((quote, contract_price)); + quotes_for_payment.push((quote, price)); } - // 4. Create SingleNodePayment (sorts by price, selects median) + // 3. Create SingleNodePayment (sorts by price, selects median) let payment = SingleNodePayment::from_quotes(quotes_for_payment) .map_err(|e| Error::Payment(format!("Failed to create payment: {e}")))?; info!("Payment total: {} atto", payment.total_amount()); - // 5. Pay on-chain + // 4. Pay on-chain let tx_hashes = payment .pay(wallet) .await @@ -110,7 +84,7 @@ impl Client { tx_hashes.len() ); - // 6. Build and serialize proof with version tag + // 5. Build and serialize proof with version tag let proof = PaymentProof { proof_of_payment: ProofOfPayment { peer_quotes }, tx_hashes, @@ -134,22 +108,12 @@ impl Client { let wallet = self.require_wallet()?; let evm_network = self.require_evm_network()?; - // Approve data payments contract - let data_payments_address = evm_network.data_payments_address(); + let vault_address = evm_network.payment_vault_address(); wallet - .approve_to_spend_tokens(*data_payments_address, evmlib::common::U256::MAX) + .approve_to_spend_tokens(*vault_address, evmlib::common::U256::MAX) .await .map_err(|e| Error::Payment(format!("Token approval failed: {e}")))?; - info!("Token spend approved for data payments contract"); - - // Approve merkle payments contract (if deployed) - if let Some(merkle_address) = evm_network.merkle_payments_address() { - wallet - .approve_to_spend_tokens(*merkle_address, evmlib::common::U256::MAX) - .await - .map_err(|e| Error::Payment(format!("Merkle token approval failed: {e}")))?; - info!("Token spend approved for merkle payments contract"); - } + info!("Token spend approved for payment vault contract"); Ok(()) } diff --git a/ant-core/src/data/client/quote.rs b/ant-core/src/data/client/quote.rs index 680c247..52b695b 100644 --- a/ant-core/src/data/client/quote.rs +++ b/ant-core/src/data/client/quote.rs @@ -10,7 +10,6 @@ use ant_node::ant_protocol::{ }; use ant_node::client::send_and_await_chunk_response; use ant_node::core::{MultiAddr, PeerId}; -use ant_node::payment::calculate_price; use ant_node::{CLOSE_GROUP_MAJORITY, CLOSE_GROUP_SIZE}; use evmlib::common::Amount; use evmlib::PaymentQuote; @@ -103,7 +102,7 @@ impl Client { } match rmp_serde::from_slice::("e) { Ok(payment_quote) => { - let price = calculate_price(&payment_quote.quoting_metrics); + let price = payment_quote.price; debug!("Received quote from {peer_id_clone}: price = {price}"); Some(Ok((payment_quote, price))) } diff --git a/ant-core/src/node/devnet.rs b/ant-core/src/node/devnet.rs index 04755a0..9d1f012 100644 --- a/ant-core/src/node/devnet.rs +++ b/ant-core/src/node/devnet.rs @@ -48,8 +48,7 @@ impl LocalDevnet { .default_wallet_private_key() .map_err(|e| Error::Config(format!("failed to get wallet key: {e}")))?; - let (rpc_url, token_addr, payments_addr, merkle_addr) = - extract_custom_network_info(&network)?; + let (rpc_url, token_addr, vault_addr) = extract_custom_network_info(&network)?; config.evm_network = Some(network.clone()); @@ -70,8 +69,7 @@ impl LocalDevnet { rpc_url, wallet_private_key: wallet_key.clone(), payment_token_address: token_addr, - data_payments_address: payments_addr, - merkle_payments_address: merkle_addr, + payment_vault_address: vault_addr, }; let manifest = DevnetManifest { @@ -192,21 +190,16 @@ impl LocalDevnet { } } -/// Extract RPC URL, token address, payments address, and merkle address from a Custom network. -fn extract_custom_network_info( - network: &EvmNetwork, -) -> Result<(String, String, String, Option)> { +/// Extract RPC URL, token address, and payment vault address from a Custom network. +fn extract_custom_network_info(network: &EvmNetwork) -> Result<(String, String, String)> { match network { EvmNetwork::Custom(custom) => { let token = custom.payment_token_address; - let payments = custom.data_payments_address; + let vault = custom.payment_vault_address; Ok(( custom.rpc_url_http.to_string(), format!("{token:?}"), - format!("{payments:?}"), - custom - .merkle_payments_address - .map(|addr| format!("{addr:?}")), + format!("{vault:?}"), )) } _ => Err(Error::Config( diff --git a/ant-core/tests/e2e_merkle.rs b/ant-core/tests/e2e_merkle.rs index dc848f1..85fe34b 100644 --- a/ant-core/tests/e2e_merkle.rs +++ b/ant-core/tests/e2e_merkle.rs @@ -13,7 +13,7 @@ mod support; use ant_core::data::client::merkle::PaymentMode; -use ant_core::data::{Client, ClientConfig}; +use ant_core::data::{compute_address, Client, ClientConfig}; use serial_test::serial; use std::io::Write; use std::sync::Arc; @@ -22,6 +22,9 @@ use tempfile::{NamedTempFile, TempDir}; const CLIENT_TIMEOUT_SECS: u64 = 120; +/// Chunk size for merkle security tests (small, fast to hash). +const TEST_CHUNK_SIZE: usize = 1024; + /// Create a 35-node testnet suitable for merkle payments. async fn setup_merkle_testnet() -> (Client, MiniTestnet) { eprintln!("Starting 35-node testnet..."); @@ -143,6 +146,139 @@ async fn test_merkle_data_upload_download() { testnet.teardown().await; } +// ─── Merkle Payment Security Tests ───────────────────────────────────────── +// +// Verify that nodes reject tampered merkle proofs. Unlike single-node payments +// where the client controls the amount, merkle payment amounts are determined +// by the smart contract. The cheating vectors for merkle are: +// - Using a proof from one chunk to store a different chunk (address mismatch) +// - Using a proof from a payment that didn't happen on-chain + +/// Use a valid merkle proof from chunk A to try storing chunk B. +/// +/// The merkle proof contains an address-binding commitment: the proof's +/// `address` field and sibling hashes bind it to a specific leaf in the tree. +/// Nodes must verify this binding rejects mismatched chunks. +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn test_attack_merkle_proof_for_wrong_chunk() { + let (client, testnet) = setup_merkle_testnet().await; + + // Create 4 small chunks for a minimal merkle tree + let chunks: Vec = (0..4u8) + .map(|i| bytes::Bytes::from(vec![i; TEST_CHUNK_SIZE])) + .collect(); + let addresses: Vec<[u8; 32]> = chunks.iter().map(|c| compute_address(c)).collect(); + + eprintln!("Paying for 4 chunks via merkle batch..."); + + // Pay for these chunks via merkle batch payment + let batch_result = client + .pay_for_merkle_batch(&addresses, 0, TEST_CHUNK_SIZE as u64) + .await + .expect("merkle batch payment should succeed"); + + assert_eq!( + batch_result.proofs.len(), + 4, + "should have proofs for all 4 chunks" + ); + + // Get the proof for chunk 0 + let proof_for_chunk_0 = batch_result + .proofs + .get(&addresses[0]) + .expect("should have proof for chunk 0") + .clone(); + + // Create a completely different chunk NOT in the merkle tree + let evil_content = bytes::Bytes::from("this content was NOT in the merkle tree"); + let evil_address = compute_address(&evil_content); + assert_ne!( + evil_address, addresses[0], + "evil chunk must have a different address" + ); + + // Find a peer close to the evil chunk's address to PUT to + let peers = client + .network() + .find_closest_peers(&evil_address, 1) + .await + .expect("should find peers"); + let (target_peer, target_addrs) = &peers[0]; + + eprintln!("Attempting PUT of wrong chunk with merkle proof for chunk 0..."); + + // Try to store the evil chunk using chunk 0's merkle proof + let result = client + .chunk_put_with_proof(evil_content, proof_for_chunk_0, target_peer, target_addrs) + .await; + + assert!( + result.is_err(), + "PUT with merkle proof for a different chunk should be rejected (address mismatch)" + ); + + drop(client); + testnet.teardown().await; +} + +/// Use a proof from chunk A to try storing chunk B where both are in the tree. +/// +/// Even when both chunks have valid merkle proofs from the same batch, the +/// proofs are NOT interchangeable — each proof binds to its specific leaf +/// via the address and sibling hash path. +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn test_attack_merkle_proof_swap_within_batch() { + let (client, testnet) = setup_merkle_testnet().await; + + let chunks: Vec = (0..4u8) + .map(|i| bytes::Bytes::from(vec![i; TEST_CHUNK_SIZE])) + .collect(); + let addresses: Vec<[u8; 32]> = chunks.iter().map(|c| compute_address(c)).collect(); + + eprintln!("Paying for 4 chunks via merkle batch..."); + + let batch_result = client + .pay_for_merkle_batch(&addresses, 0, TEST_CHUNK_SIZE as u64) + .await + .expect("merkle batch payment should succeed"); + + // Take chunk 0's proof and try to store chunk 1 with it + let proof_for_chunk_0 = batch_result + .proofs + .get(&addresses[0]) + .expect("should have proof for chunk 0") + .clone(); + + let peers = client + .network() + .find_closest_peers(&addresses[1], 1) + .await + .expect("should find peers"); + let (target_peer, target_addrs) = &peers[0]; + + eprintln!("Attempting to store chunk 1 using chunk 0's merkle proof..."); + + let result = client + .chunk_put_with_proof( + chunks[1].clone(), + proof_for_chunk_0, + target_peer, + target_addrs, + ) + .await; + + assert!( + result.is_err(), + "Swapping merkle proofs between chunks in the same batch should be rejected" + ); + + drop(client); + testnet.teardown().await; +} + // Single-node coexistence is tested in e2e_file.rs (6-node testnet). // The 35-node testnet's DHT can have sparse XOR regions where single-node // quotes can't find 5 peers for a random chunk address, making that test diff --git a/ant-core/tests/e2e_security.rs b/ant-core/tests/e2e_security.rs index cbb0ca3..9bf23c1 100644 --- a/ant-core/tests/e2e_security.rs +++ b/ant-core/tests/e2e_security.rs @@ -11,8 +11,8 @@ use ant_core::data::{compute_address, Client, ClientConfig}; use ant_node::core::PeerId; use ant_node::payment::{serialize_single_node_proof, PaymentProof, SingleNodePayment}; use bytes::Bytes; -use evmlib::common::TxHash; -use evmlib::{EncodedPeerId, ProofOfPayment}; +use evmlib::common::{Amount, TxHash}; +use evmlib::{EncodedPeerId, ProofOfPayment, RewardsAddress}; use serial_test::serial; use std::sync::Arc; use support::MiniTestnet; @@ -359,3 +359,173 @@ async fn test_attack_client_without_wallet() { drop(client); testnet.teardown().await; } + +// ─── Test 9: Underpayment — Single Node ───────────────────────────────────── +// +// Collects real quotes, builds a valid SingleNodePayment, then tampers with +// the median quote's amount (reducing it to 1 atto). Pays on-chain with the +// reduced amount. The node's on-chain verifyPayment check should detect that +// the paid amount is far below the expected 3× median price and reject the PUT. + +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn test_attack_underpayment_single_node() { + let (client, testnet) = setup().await; + + let content = Bytes::from("underpayment attack: client pays too little"); + let address = compute_address(&content); + let data_size = content.len() as u64; + + // 1. Collect real quotes from close group + let quotes = client + .get_store_quotes(&address, data_size, 0) + .await + .expect("quote collection should succeed"); + + // Save (PeerId, RewardsAddress) mapping before consuming quotes — needed + // to target the median peer after from_quotes() sorts internally. + let peer_by_rewards: Vec<(PeerId, RewardsAddress)> = quotes + .iter() + .map(|(pid, _, q, _)| (*pid, q.rewards_address)) + .collect(); + + // 2. Build SingleNodePayment normally (sorts by price, median gets 3×) + let mut peer_quotes = Vec::with_capacity(quotes.len()); + let mut quotes_for_payment = Vec::with_capacity(quotes.len()); + for (peer_id, _addrs, quote, price) in quotes { + let encoded = EncodedPeerId::new(*peer_id.as_bytes()); + peer_quotes.push((encoded, quote.clone())); + quotes_for_payment.push((quote, price)); + } + + let mut payment = SingleNodePayment::from_quotes(quotes_for_payment) + .expect("payment creation should succeed"); + + // Median is at index 2 (CLOSE_GROUP_SIZE=5, sorted by price). + // Target the median peer specifically — it's the only one that receives + // payment, so only it will detect the amount mismatch in verifyPayment(). + let original_amount = payment.quotes[2].amount; + let median_rewards = payment.quotes[2].rewards_address; + let target_peer = peer_by_rewards + .iter() + .find(|(_, addr)| *addr == median_rewards) + .expect("median rewards address must match a quoted peer") + .0; + assert!( + !original_amount.is_zero(), + "Median quote (index 2) should have non-zero payment amount" + ); + + // 3. Tamper: reduce median payment to 1 atto (should be 3× median price) + payment.quotes[2].amount = Amount::from(1u64); + + // 4. Pay on-chain with the reduced amount — the contract records whatever + // amount is sent, it only validates amounts in verifyPayment() + let wallet = client.wallet().expect("wallet should be set"); + let tx_hashes = payment + .pay(wallet) + .await + .expect("on-chain payment should succeed (contract accepts any amount in payForQuotes)"); + + assert!( + !tx_hashes.is_empty(), + "Should have at least one tx hash for the 1-atto payment" + ); + + // 5. Build proof with real ML-DSA-65 signed quotes but underpaid tx + let proof = PaymentProof { + proof_of_payment: ProofOfPayment { peer_quotes }, + tx_hashes, + }; + let proof_bytes = serialize_single_node_proof(&proof).expect("serialize proof"); + + // 6. PUT should be REJECTED — node's verifyPayment detects 1 atto < 3× expected + let result = client + .chunk_put_with_proof(content, proof_bytes, &target_peer, &[]) + .await; + + assert!( + result.is_err(), + "PUT with underpayment (1 atto instead of {original_amount}) should be rejected" + ); + + drop(client); + testnet.teardown().await; +} + +// ─── Test 10: Underpayment — Pay Half the Required Amount ─────────────────── +// +// Verifies the boundary more closely: pays roughly half the expected 3× median +// price. The contract should still reject this because the amount is below the +// 3× threshold, even though it's not trivially small like 1 atto. + +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn test_attack_underpayment_half_price() { + let (client, testnet) = setup().await; + + let content = Bytes::from("half-price underpayment attack test data"); + let address = compute_address(&content); + let data_size = content.len() as u64; + + let quotes = client + .get_store_quotes(&address, data_size, 0) + .await + .expect("quote collection should succeed"); + + let peer_by_rewards: Vec<(PeerId, RewardsAddress)> = quotes + .iter() + .map(|(pid, _, q, _)| (*pid, q.rewards_address)) + .collect(); + + let mut peer_quotes = Vec::with_capacity(quotes.len()); + let mut quotes_for_payment = Vec::with_capacity(quotes.len()); + for (peer_id, _addrs, quote, price) in quotes { + let encoded = EncodedPeerId::new(*peer_id.as_bytes()); + peer_quotes.push((encoded, quote.clone())); + quotes_for_payment.push((quote, price)); + } + + let mut payment = SingleNodePayment::from_quotes(quotes_for_payment) + .expect("payment creation should succeed"); + + // Halve the median payment (3× → ~1.5×). + // Target the median peer — only it verifies the amount it received. + let original_amount = payment.quotes[2].amount; + let median_rewards = payment.quotes[2].rewards_address; + let target_peer = peer_by_rewards + .iter() + .find(|(_, addr)| *addr == median_rewards) + .expect("median rewards address must match a quoted peer") + .0; + let half_amount = original_amount / Amount::from(2u64); + assert!( + !half_amount.is_zero(), + "Half of original amount should still be non-zero" + ); + payment.quotes[2].amount = half_amount; + + let wallet = client.wallet().expect("wallet should be set"); + let tx_hashes = payment + .pay(wallet) + .await + .expect("on-chain payment should succeed"); + + let proof = PaymentProof { + proof_of_payment: ProofOfPayment { peer_quotes }, + tx_hashes, + }; + let proof_bytes = serialize_single_node_proof(&proof).expect("serialize proof"); + + let result = client + .chunk_put_with_proof(content, proof_bytes, &target_peer, &[]) + .await; + + assert!( + result.is_err(), + "PUT with half-price payment ({half_amount} instead of {original_amount}) should be rejected" + ); + + drop(client); + testnet.teardown().await; +} diff --git a/ant-core/tests/support/mod.rs b/ant-core/tests/support/mod.rs index 5748a61..c12137c 100644 --- a/ant-core/tests/support/mod.rs +++ b/ant-core/tests/support/mod.rs @@ -44,8 +44,6 @@ const STABILIZATION_TIMEOUT_SECS: u64 = 180; const TEST_REWARDS_ADDRESS: [u8; 20] = [0x01; 20]; /// Max records for quoting metrics. const TEST_MAX_RECORDS: usize = 1280; -/// Initial records for quoting metrics. -const TEST_INITIAL_RECORDS: usize = 1000; pub struct TestNode { pub p2p_node: Option>, @@ -153,20 +151,12 @@ impl MiniTestnet { sleep(Duration::from_millis(500)).await; } - // Approve token spend for data payments contract - let data_payments_address = evm_network.data_payments_address(); + // Approve token spend for the unified payment vault contract + let vault_address = evm_network.payment_vault_address(); wallet - .approve_to_spend_tokens(*data_payments_address, evmlib::common::U256::MAX) + .approve_to_spend_tokens(*vault_address, evmlib::common::U256::MAX) .await - .expect("approve data payment token spend"); - - // Approve token spend for merkle payments contract (if deployed) - if let Some(merkle_address) = evm_network.merkle_payments_address() { - wallet - .approve_to_spend_tokens(*merkle_address, evmlib::common::U256::MAX) - .await - .expect("approve merkle payment token spend"); - } + .expect("approve payment vault token spend"); Self { nodes, @@ -225,8 +215,8 @@ impl MiniTestnet { let storage_config = LmdbStorageConfig { root_dir: data_dir.to_path_buf(), verify_on_read: true, - max_chunks: 0, max_map_size: 0, + disk_reserve: 0, }; let storage = Arc::new( LmdbStorage::new(storage_config) @@ -250,7 +240,7 @@ impl MiniTestnet { local_rewards_address: rewards_address, }; let payment_verifier = Arc::new(PaymentVerifier::new(payment_config)); - let metrics_tracker = QuotingMetricsTracker::new(TEST_MAX_RECORDS, TEST_INITIAL_RECORDS); + let metrics_tracker = QuotingMetricsTracker::new(TEST_MAX_RECORDS); let mut quote_generator = QuoteGenerator::new(rewards_address, metrics_tracker); // Wire ML-DSA-65 signing so quotes are properly signed and verifiable