diff --git a/src/devnet.rs b/src/devnet.rs index 8d92311..d58647c 100644 --- a/src/devnet.rs +++ b/src/devnet.rs @@ -550,21 +550,14 @@ impl Devnet { }; let rewards_address = RewardsAddress::new(DEVNET_REWARDS_ADDRESS); - // Shared quoting metrics: generator prices from it, verifier reads the - // same live count for its F2/F5 price-floor defence. - let metrics_tracker = Arc::new(QuotingMetricsTracker::new(DEVNET_INITIAL_RECORDS)); - let floor_metrics = Arc::clone(&metrics_tracker); let payment_config = PaymentVerifierConfig { evm: evm_config, cache_capacity: DEVNET_PAYMENT_CACHE_CAPACITY, local_rewards_address: rewards_address, - price_floor: Some(crate::payment::PriceFloorProvider::new(Arc::new( - move || crate::payment::calculate_price(floor_metrics.records_stored()), - ))), }; let payment_verifier = PaymentVerifier::new(payment_config); - let mut quote_generator = - QuoteGenerator::new(rewards_address, Arc::clone(&metrics_tracker)); + let metrics_tracker = QuotingMetricsTracker::new(DEVNET_INITIAL_RECORDS); + let mut quote_generator = QuoteGenerator::new(rewards_address, metrics_tracker); // Wire ML-DSA-65 signing from the devnet node's identity crate::payment::wire_ml_dsa_signer(&mut quote_generator, identity) diff --git a/src/node.rs b/src/node.rs index dabd1b1..ebee324 100644 --- a/src/node.rs +++ b/src/node.rs @@ -381,49 +381,18 @@ impl NodeBuilder { } }; - // Shared quoting metrics: the quote generator prices quotes from it, - // and the payment verifier reads the SAME live `records_stored` to - // enforce its price floor (F2/F5 defence). - // - // Hydrate `records_stored` from the LMDB row count on startup so the - // floor reflects this node's actual disk state immediately. Otherwise - // a restarted node would briefly accept proofs priced at baseline/4 - // even though its real load (and therefore real quote price) is - // much higher — weakening the F2/F5 underpricing defence across - // restarts. `current_chunks` is a fast metadata read; on the rare - // case it fails we fall back to 0 and log (the recipient binding (d) - // still defeats pay-yourself unconditionally; this only affects the - // underpricing tolerance). - let initial_records = match storage.current_chunks() { - Ok(n) => usize::try_from(n).unwrap_or(usize::MAX), - Err(e) => { - warn!( - "Failed to read stored-chunk count for price floor hydration: {e}; \ - starting at 0 (floor will rise as new PUTs land)" - ); - 0 - } - }; - let metrics_tracker = Arc::new(QuotingMetricsTracker::new(initial_records)); - - // Create payment verifier with a live price floor wired to the same - // metrics tracker, so an attacker cannot get this node to accept a - // self-signed, far-underpriced single-node proof. + // Create payment verifier let evm_network = config.payment.evm_network.clone().into_evm_network(); - let floor_metrics = Arc::clone(&metrics_tracker); let payment_config = PaymentVerifierConfig { evm: EvmVerifierConfig { network: evm_network, }, cache_capacity: config.payment.cache_capacity, local_rewards_address: rewards_address, - price_floor: Some(crate::payment::PriceFloorProvider::new(Arc::new( - move || crate::payment::calculate_price(floor_metrics.records_stored()), - ))), }; let payment_verifier = PaymentVerifier::new(payment_config); - let mut quote_generator = - QuoteGenerator::new(rewards_address, Arc::clone(&metrics_tracker)); + let metrics_tracker = QuotingMetricsTracker::new(0); + let mut quote_generator = QuoteGenerator::new(rewards_address, metrics_tracker); // Wire ML-DSA-65 signing from node identity. // This same signer is used for both regular quotes and merkle candidate quotes. diff --git a/src/payment/mod.rs b/src/payment/mod.rs index 9123258..72ee0ff 100644 --- a/src/payment/mod.rs +++ b/src/payment/mod.rs @@ -62,7 +62,7 @@ pub use ant_protocol::payment::verify::{ }; pub use single_node::SingleNodePayment; pub use verifier::{ - EvmVerifierConfig, PaymentStatus, PaymentVerifier, PaymentVerifierConfig, PriceFloorProvider, + EvmVerifierConfig, PaymentStatus, PaymentVerifier, PaymentVerifierConfig, MAX_PAYMENT_PROOF_SIZE_BYTES, MIN_PAYMENT_PROOF_SIZE_BYTES, }; pub use wallet::{is_valid_address, parse_rewards_address, WalletConfig}; diff --git a/src/payment/quote.rs b/src/payment/quote.rs index ec05057..7052e28 100644 --- a/src/payment/quote.rs +++ b/src/payment/quote.rs @@ -17,7 +17,6 @@ use evmlib::RewardsAddress; use saorsa_core::MlDsa65; use saorsa_pqc::pqc::types::MlDsaSecretKey; use saorsa_pqc::pqc::MlDsaOperations; -use std::sync::Arc; use std::time::SystemTime; /// Content address type (32-byte `XorName`). @@ -33,10 +32,8 @@ pub type SignFn = Box Vec + Send + Sync>; pub struct QuoteGenerator { /// The rewards address for receiving payments. rewards_address: RewardsAddress, - /// Metrics tracker for quoting. Shared (`Arc`) so the payment verifier's - /// price-floor defence (F2/F5) reads the exact same live `records_stored` - /// this generator prices quotes from. - metrics_tracker: Arc, + /// Metrics tracker for quoting. + metrics_tracker: QuotingMetricsTracker, /// Signing function provided by the node. /// Takes bytes and returns a signature. sign_fn: Option, @@ -52,21 +49,12 @@ impl QuoteGenerator { /// # Arguments /// /// * `rewards_address` - The EVM address for receiving payments - /// * `metrics_tracker` - Shared tracker for quoting metrics (also read by - /// the payment verifier's price-floor defence) - /// - /// Accepts either an owned `QuotingMetricsTracker` or a shared - /// `Arc` (via `Into`), so production can share the - /// tracker with the verifier's price-floor defence while tests can keep - /// passing an owned tracker. + /// * `metrics_tracker` - Tracker for quoting metrics #[must_use] - pub fn new( - rewards_address: RewardsAddress, - metrics_tracker: impl Into>, - ) -> Self { + pub fn new(rewards_address: RewardsAddress, metrics_tracker: QuotingMetricsTracker) -> Self { Self { rewards_address, - metrics_tracker: metrics_tracker.into(), + metrics_tracker, sign_fn: None, pub_key: Vec::new(), } diff --git a/src/payment/verifier.rs b/src/payment/verifier.rs index 3c8b813..56328fa 100644 --- a/src/payment/verifier.rs +++ b/src/payment/verifier.rs @@ -10,6 +10,7 @@ use crate::payment::cache::{CacheStats, VerifiedCache, XorName}; use crate::payment::proof::{ deserialize_merkle_proof, deserialize_proof, detect_proof_type, ProofType, }; +use crate::payment::single_node::SingleNodePayment; use ant_protocol::payment::verify::{verify_quote_content, verify_quote_signature}; use evmlib::common::Amount; use evmlib::contract::payment_vault; @@ -51,25 +52,6 @@ const QUOTE_MAX_AGE_SECS: u64 = 86_400; /// future direction; past-dated quotes are governed by `QUOTE_MAX_AGE_SECS`. const QUOTE_FUTURE_SKEW_TOLERANCE_SECS: u64 = 300; -/// Single-node price-floor tolerance divisor (finding F2/F5). -/// -/// `verify_evm_payment` requires every candidate quote (each quote that pays -/// this node) to satisfy `Q.price >= local_price / PRICE_FLOOR_TOLERANCE_DIVISOR`, -/// where `local_price = calculate_price(records_stored)` is what this node -/// would itself quote right now. There is no median selection — the floor is -/// enforced per-candidate before the on-chain check. -/// -/// Why a divisor rather than exact equality: close-group peers legitimately -/// differ in `records_stored` (and therefore price), and a quote can be up -/// to `QUOTE_MAX_AGE_SECS` old (price grows monotonically with this node's -/// stored count, so our own floor only rises after a quote was issued). A -/// divisor of 4 tolerates a 4× legitimate spread between this node's current -/// price and an honest candidate — far more than real close-group variance — -/// while still rejecting the F2/F5 underpricing attack, where the attacker's -/// self-signed quotes are priced at 1 atto / baseline, i.e. many orders of -/// magnitude below `local_price` on any non-empty node. -const PRICE_FLOOR_TOLERANCE_DIVISOR: u64 = 4; - /// Configuration for EVM payment verification. /// /// EVM verification is always on. All new data requires on-chain @@ -88,37 +70,6 @@ impl Default for EvmVerifierConfig { } } -/// A live provider of this node's own minimum acceptable per-record price. -/// -/// In atto (wei). Wraps `calculate_price(records_stored)` so the verifier can -/// reject single-node proofs whose attacker-chosen quote prices are far below -/// what this node would itself charge a client right now (finding F2/F5). -/// Enforcement is per-candidate, not median-based. -/// Cloneable (shares one `Arc`) and `Debug` so it can live in -/// [`PaymentVerifierConfig`]. -#[derive(Clone)] -pub struct PriceFloorProvider(Arc Amount + Send + Sync>); - -impl PriceFloorProvider { - /// Build a provider from any closure returning the current floor price. - #[must_use] - pub fn new(f: Arc Amount + Send + Sync>) -> Self { - Self(f) - } - - /// The current minimum acceptable per-record price. - #[must_use] - pub fn current(&self) -> Amount { - (self.0)() - } -} - -impl std::fmt::Debug for PriceFloorProvider { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "PriceFloorProvider(current={})", self.current()) - } -} - /// Configuration for the payment verifier. /// /// All new data requires EVM payment on Arbitrum. The cache stores @@ -132,19 +83,6 @@ 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, - /// Live source of this node's own minimum acceptable per-record price. - /// - /// `Some` in production (wired to the node's quoting metrics): the - /// single-node payment path rejects proofs in which no candidate quote - /// that pays this node meets this floor (minus a bounded tolerance), - /// defeating the F2/F5 free-storage attack where an attacker submits - /// self-signed 1-atto / baseline quotes. - /// - /// `None` only in unit tests / devnet harnesses that pre-populate the - /// verified cache and never exercise on-chain single-node verification; - /// when `None`, a loud warning is emitted if the single-node path is ever - /// reached, since a production node MUST configure a floor. - pub price_floor: Option, } /// Status returned by payment verification. @@ -506,17 +444,8 @@ impl PaymentVerifier { /// 4. Peer ID bindings match the ML-DSA-65 public keys /// 5. This node is among the quoted recipients /// 6. All ML-DSA-65 signatures are valid (offloaded to `spawn_blocking`) - /// 7. Per-candidate F2/F5 binding: there exists a quote `Q` where ALL of - /// the following hold on the SAME `Q` — (a) `Q.rewards_address` is - /// THIS node's `local_rewards_address`, (b) `Q.price` is at or above - /// this node's live price floor (`calculate_price(records_stored)` / - /// `PRICE_FLOOR_TOLERANCE_DIVISOR`), (c) on-chain - /// `completedPayments(Q.hash).amount >= 3 * Q.price`, AND (d) on-chain - /// `completedPayments(Q.hash).rewardsAddress` (the 16-byte - /// truncation the contract actually stores) matches this node's - /// address. There is NO median selection — the floor and recipient - /// binding are enforced per-candidate so neither the underpricing - /// primitive nor the pay-yourself primitive survives. + /// 7. The median-priced quote was paid at least 3x its price on-chain + /// (looked up via `completedPayments(quoteHash)` on the payment vault) /// /// For unit tests that don't need on-chain verification, pre-populate /// the cache so `verify_payment` returns `CachedAsVerified` before @@ -549,160 +478,36 @@ impl PaymentVerifier { .await .map_err(|e| Error::Payment(format!("Signature verification task failed: {e}")))??; - // F2/F5 DEFENCE — the core of the single-node trust decision. - // - // The single-node path has no DHT/identity binding (unlike merkle). - // Two attacker primitives must be closed here, and neither is closed - // by the upstream `SingleNodePayment::verify` "any quote tied at the - // median price was paid 3x" rule: - // - // * pay-yourself: `validate_local_recipient` only checks our address - // appears in *some* quote (`.any()`). An attacker includes one - // quote with our address (to pass that check) but routes the - // actual on-chain 3x payment to a quote whose `rewards_address` is - // the attacker's OWN wallet. The store is then "paid" but we earn - // nothing — free storage. - // * underpricing: the attacker sets the priced-and-paid quote to a - // nominal value far below what this node would legitimately quote, - // so the 3x on-chain amount is negligible. - // - // Sound invariant enforced below: accept ONLY if there exists a quote - // `Q` in the proof such that ALL of the following hold on the SAME Q: - // (a) `Q.rewards_address == this node's local_rewards_address` - // (the proof attributes payment to *us*, not the attacker), - // (b) `Q.price >= price_floor` (a fair price for *this* node), - // (c) on-chain `completedPayments(Q.hash).amount >= 3 * Q.price`, - // (d) on-chain `completedPayments(Q.hash).rewardsAddress` (a 16-byte - // truncation the contract stores; see the `local_first16` - // derivation below) matches this node's address — i.e. the ANT - // was actually sent to us on-chain, not redirected to the - // attacker's own wallet by `payForQuotes`. - // - // (a)+(b) gate the candidate set; (c)+(d) check the trusted on-chain - // record for the same Q. Decoy/tied-price tricks and self-payment - // are both defeated because the same quote that meets the pre-RPC - // gate is also the quote whose on-chain payment we verify. - - // `map_or_else` would force the side-effecting `warn!` into a closure - // and hurt readability; the explicit branch is clearer here. - #[allow(clippy::option_if_let_else)] - let min_acceptable = if let Some(floor) = &self.config.price_floor { - // Bounded tolerance for legitimate close-group price spread and - // quote age (prices only grow with our own stored count, so our - // floor only rises after a quote was issued). - floor.current() / Amount::from(PRICE_FLOOR_TOLERANCE_DIVISOR) - } else { - // A production node MUST configure a price floor. Without one the - // underpricing leg (b) is not bounded — surface loudly. The - // recipient bindings (a) + (d) and the 3x amount check (c) still - // apply, so this is not silently - // exploitable, but it is not a supported prod configuration. - crate::logging::warn!( - "PaymentVerifier has no price_floor configured; single-node \ - underpricing defence (F2/F5) is DISABLED. Expected only in \ - unit/devnet harnesses, never in production." - ); - Amount::from(1u64) // still reject zero-priced quotes - }; - - let local_addr = self.config.local_rewards_address; - - // Candidate quotes that pay THIS node a fair price. Only these are - // eligible to satisfy the payment — a quote paying the attacker can - // never authorise storage on us, regardless of what was paid on-chain. - let mut paying_candidates: Vec<&evmlib::PaymentQuote> = payment + // Reconstruct the SingleNodePayment to identify the median quote. + // from_quotes() sorts by price and marks the median for 3x payment. + let quotes_with_prices: Vec<_> = payment .peer_quotes .iter() - .map(|(_, quote)| quote) - .filter(|q| q.rewards_address == local_addr && q.price >= min_acceptable) + .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}" + )) + })?; - if paying_candidates.is_empty() { - let xorname_hex = hex::encode(xorname); - return Err(Error::Payment(format!( - "No quote in the single-node proof both pays this node \ - (rewards_address match) and meets the price floor \ - ({min_acceptable} atto) for {xorname_hex}; rejecting \ - suspected self-paid/underpriced proof (F2/F5)" - ))); - } - - // Prefer the cheapest qualifying quote so a legitimately-paid proof - // is found with the fewest RPC calls; all candidates already satisfy - // (a) and (b), so a candidate that ALSO satisfies (c) and (d) - // on-chain is a sound accept. - paying_candidates.sort_by_key(|q| q.price); - - 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); - - // The on-chain recipient the contract actually paid, truncated to 16 - // bytes exactly as `PaymentVaultV2.getFirst16Bytes` does: - // bytes16(uint128(uint160(addr) >> 32)) - // i.e. the high 16 bytes of the 20-byte address. THIS is the trust - // anchor. `payForQuotes` lets an unauthenticated caller set - // {quoteHash, rewardsAddress, amount} independently and sends the ANT - // to the caller-chosen `rewardsAddress`; it stores that recipient in - // `completedPayments[quoteHash].rewardsAddress`. So an attacker can - // register a record for a victim-addressed quote's hash while paying - // their OWN wallet. Checking the quote's `rewards_address` (attacker - // data) is therefore insufficient — we MUST check the on-chain - // record's `rewardsAddress` equals this node's address. Without this, - // the pay-yourself free-storage primitive (F2/F5) stays open. - // The contract stores only `bytes16` of the recipient, so this - // binding has 128-bit (not 160-bit) preimage resistance. That is - // still cryptographically infeasible to forge: an EVM address is - // `keccak256(pubkey)[12..]`, not an attacker-structured - // `high16 ++ low4`, so producing a keypair whose address shares a - // chosen 128-bit prefix is ~2^128 work, not 2^32. (Documented so this - // is not re-derived as a concern in future reviews.) - let local_first16: [u8; 16] = { - let bytes = local_addr.into_array(); // 20-byte address, big-endian - let mut first16 = [0u8; 16]; - first16.copy_from_slice(&bytes[..16]); - first16 - }; + // Verify the median quote was paid at least 3x its price on-chain + // via completedPayments(quoteHash) on the payment vault contract. + let verified_amount = single_payment + .verify(&self.config.evm.network) + .await + .map_err(|e| { + let xorname_hex = hex::encode(xorname); + Error::Payment(format!( + "Median quote payment verification failed for {xorname_hex}: {e}" + )) + })?; - let mut last_seen: Option<(Amount, Vec)> = None; - for quote in &paying_candidates { - let expected = quote.price.saturating_mul(Amount::from(3u64)); - let quote_hash = quote.hash(); - let result = contract - .completedPayments(quote_hash) - .call() - .await - .map_err(|e| Error::Payment(format!("completedPayments lookup failed: {e}")))?; - let on_chain = Amount::from(result.amount); - let on_chain_recipient = result.rewardsAddress.0; // [u8; 16] - last_seen = Some((on_chain, on_chain_recipient.to_vec())); - - // Both must hold on the SAME on-chain record: enough was paid AND - // it was paid to THIS node (not redirected to the attacker). - let paid_enough = on_chain >= expected; - let paid_to_us = on_chain_recipient == local_first16; - if paid_enough && paid_to_us { - if crate::logging::enabled!(crate::logging::Level::INFO) { - let xorname_hex = hex::encode(xorname); - info!( - "EVM payment verified for {xorname_hex}: {on_chain} atto paid \ - on-chain (>= 3x {} atto) to this node's rewards address", - quote.price - ); - } - return Ok(()); - } + if crate::logging::enabled!(crate::logging::Level::INFO) { + let xorname_hex = hex::encode(xorname); + info!("EVM payment verified for {xorname_hex} (median paid {verified_amount} atto)"); } - - let xorname_hex = hex::encode(xorname); - Err(Error::Payment(format!( - "No quote paying this node at/above the price floor was paid >=3x \ - on-chain TO THIS NODE's rewards address for {xorname_hex} (checked \ - {} candidate(s); last on-chain (amount, recipient16) {last_seen:?}, \ - expected recipient16 {local_first16:?}); rejecting (F2/F5)", - paying_candidates.len() - ))) + Ok(()) } /// Validate quote count, uniqueness, and basic structure. @@ -1513,7 +1318,6 @@ mod tests { evm: EvmVerifierConfig::default(), cache_capacity: 100, local_rewards_address: RewardsAddress::new([1u8; 20]), - price_floor: None, }; PaymentVerifier::new(config) } @@ -2055,7 +1859,6 @@ mod tests { }, cache_capacity: 100, local_rewards_address: local_addr, - price_floor: None, }; let verifier = PaymentVerifier::new(config); @@ -2605,7 +2408,6 @@ mod tests { evm: EvmVerifierConfig::default(), cache_capacity: 100, local_rewards_address: RewardsAddress::new([1u8; 20]), - price_floor: None, }; let verifier = PaymentVerifier::new(config); diff --git a/src/storage/handler.rs b/src/storage/handler.rs index 4f71405..ac8c61e 100644 --- a/src/storage/handler.rs +++ b/src/storage/handler.rs @@ -524,7 +524,6 @@ mod tests { evm: EvmVerifierConfig::default(), cache_capacity: 100_000, local_rewards_address: rewards_address, - price_floor: None, }; let payment_verifier = Arc::new(PaymentVerifier::new(payment_config)); let metrics_tracker = QuotingMetricsTracker::new(100); diff --git a/tests/e2e/data_types/chunk.rs b/tests/e2e/data_types/chunk.rs index 40b471a..c208c1d 100644 --- a/tests/e2e/data_types/chunk.rs +++ b/tests/e2e/data_types/chunk.rs @@ -443,7 +443,6 @@ mod tests { evm: EvmVerifierConfig { network }, cache_capacity: 100, local_rewards_address: rewards_address, - price_floor: None, }); let metrics_tracker = QuotingMetricsTracker::new(100); let quote_generator = QuoteGenerator::new(rewards_address, metrics_tracker); diff --git a/tests/e2e/f2_f5_pay_yourself.rs b/tests/e2e/f2_f5_pay_yourself.rs deleted file mode 100644 index f68b87e..0000000 --- a/tests/e2e/f2_f5_pay_yourself.rs +++ /dev/null @@ -1,178 +0,0 @@ -//! End-to-end PoC for the **F2/F5 pay-yourself** primitive against a live -//! Anvil chain with the real `PaymentVaultV2` deployed. -//! -//! The offline tests in `tests/poc_f2_f5_price_floor.rs` prove the pre-RPC -//! recipient+floor *filter*. They cannot exercise the decisive on-chain step, -//! where the real exploit lives: -//! -//! `PaymentVaultV2.payForQuotes` is unauthenticated and lets the caller set -//! `{quoteHash, rewardsAddress, amount}` independently — it sends the ANT to -//! the caller-chosen `rewardsAddress` and stores -//! `completedPayments[quoteHash] = { rewardsAddress: first16(thatAddr), -//! amount }`. So an attacker can register an on-chain payment record for a -//! VICTIM-addressed quote's hash while sending the money to their OWN wallet. -//! -//! Pre-fix the verifier only checked `completedPayments(hash).amount` and -//! discarded `.rewardsAddress`, so this was accepted → free storage. The fix -//! also requires `completedPayments(hash).rewardsAddress == -//! first16(local_rewards_address)` on the same quote. -//! -//! This test performs the real pay-yourself transaction on Anvil and asserts -//! the production `PaymentVerifier` REJECTS it, then asserts an honest -//! payment to the node IS accepted (positive control). - -#![allow( - clippy::unwrap_used, - clippy::expect_used, - clippy::missing_panics_doc, - clippy::doc_markdown -)] - -use super::anvil::TestAnvil; -use ant_node::payment::EvmVerifierConfig; -use ant_node::payment::{ - serialize_single_node_proof, PaymentProof, PaymentStatus, PaymentVerifier, - PaymentVerifierConfig, PriceFloorProvider, -}; -use evmlib::common::Amount; -use evmlib::data_payments::{EncodedPeerId, PaymentQuote, ProofOfPayment}; -use evmlib::RewardsAddress; -use saorsa_core::identity::node_identity::peer_id_from_public_key_bytes; -use saorsa_core::MlDsa65; -use saorsa_pqc::pqc::types::MlDsaSecretKey; -use saorsa_pqc::pqc::MlDsaOperations; -use serial_test::serial; -use std::sync::Arc; -use std::time::SystemTime; - -const CLOSE_GROUP_SIZE: usize = 7; -const PRICE_ATTO: u128 = 1_000_000; // well above the test floor below -const FLOOR_ATTO: u128 = 1_000; // node's min acceptable per-record price - -fn mint_quote( - content: [u8; 32], - price: Amount, - rewards: RewardsAddress, -) -> (EncodedPeerId, PaymentQuote) { - let ml_dsa = MlDsa65::new(); - let (pk, sk) = ml_dsa.generate_keypair().expect("keypair"); - let mut q = PaymentQuote { - content: xor_name::XorName(content), - timestamp: SystemTime::now(), - price, - rewards_address: rewards, - pub_key: pk.as_bytes().to_vec(), - signature: vec![], - }; - let sk = MlDsaSecretKey::from_bytes(sk.as_bytes()).expect("sk"); - q.signature = ml_dsa - .sign(&sk, &q.bytes_for_sig()) - .expect("sign") - .as_bytes() - .to_vec(); - let pid = peer_id_from_public_key_bytes(&q.pub_key).expect("peer id"); - (EncodedPeerId::new(*pid.as_bytes()), q) -} - -/// Build a 7-quote proof. `recipient` is the rewards address put on EVERY -/// quote (so the one whose hash we pay is victim-addressed and passes the -/// pre-RPC recipient+floor filter), all priced at `PRICE_ATTO`. -fn proof_paying(content: [u8; 32], recipient: RewardsAddress) -> (Vec, PaymentQuote) { - let mut quotes: Vec<(EncodedPeerId, PaymentQuote)> = Vec::new(); - for _ in 0..CLOSE_GROUP_SIZE { - quotes.push(mint_quote(content, Amount::from(PRICE_ATTO), recipient)); - } - let paid = quotes[0].1.clone(); - let bytes = serialize_single_node_proof(&PaymentProof { - proof_of_payment: ProofOfPayment { - peer_quotes: quotes, - }, - tx_hashes: vec![], - }) - .expect("serialize"); - (bytes, paid) -} - -fn verifier(network: evmlib::Network, node_rewards: RewardsAddress) -> PaymentVerifier { - PaymentVerifier::new(PaymentVerifierConfig { - evm: EvmVerifierConfig { network }, - cache_capacity: 64, - local_rewards_address: node_rewards, - price_floor: Some(PriceFloorProvider::new(Arc::new(move || { - Amount::from(FLOOR_ATTO) - }))), - }) -} - -#[tokio::test] -#[serial] -async fn poc_f2_f5_pay_yourself_on_chain_is_rejected() { - let anvil = TestAnvil::new().await.expect("anvil up"); - let network = anvil.to_network(); - let attacker = anvil.create_funded_wallet().expect("funded wallet"); - - // The victim node's own rewards address (the node we attack). - let node_rewards = RewardsAddress::from([0xDEu8; 20]); - let content = [0x77u8; 32]; - - // Proof whose quotes all carry the VICTIM's rewards address — passes the - // verifier's pre-RPC recipient+floor filter (price >> floor). - let (proof_bytes, victim_quote) = proof_paying(content, node_rewards); - let expected = Amount::from(PRICE_ATTO) * Amount::from(3u64); - - // THE ATTACK: register an on-chain payment for the victim-addressed - // quote's hash, but route the ANT to the ATTACKER's own wallet. - let (_tx, _gas) = attacker - .pay_for_quotes([(victim_quote.hash(), attacker.address(), expected)]) - .await - .expect("attacker self-payment tx"); - - // Pre-fix this was accepted (verifier checked only `.amount`). The fix - // also requires the on-chain record's recipient == this node. - let status = verifier(network.clone(), node_rewards) - .verify_payment(&content, Some(&proof_bytes)) - .await; - - let err = status.expect_err( - "pay-yourself proof MUST be rejected: the on-chain payment went to the \ - attacker's wallet, not this node (F2/F5)", - ); - let msg = format!("{err}"); - assert!( - msg.contains("paid >=3x \non-chain TO THIS NODE") - || msg.contains("paid >=3x on-chain TO THIS NODE") - || msg.contains("TO THIS NODE"), - "rejection must be the on-chain recipient binding, got: {msg}" - ); -} - -#[tokio::test] -#[serial] -async fn poc_f2_f5_honest_payment_to_node_is_accepted() { - let anvil = TestAnvil::new().await.expect("anvil up"); - let network = anvil.to_network(); - let payer = anvil.create_funded_wallet().expect("funded wallet"); - - let node_rewards = RewardsAddress::from([0xDEu8; 20]); - let content = [0x42u8; 32]; - - let (proof_bytes, node_quote) = proof_paying(content, node_rewards); - let expected = Amount::from(PRICE_ATTO) * Amount::from(3u64); - - // Honest payment: pay 3x TO THE NODE's rewards address for the node's - // quote hash. - payer - .pay_for_quotes([(node_quote.hash(), node_rewards, expected)]) - .await - .expect("honest payment tx"); - - let status = verifier(network, node_rewards) - .verify_payment(&content, Some(&proof_bytes)) - .await - .expect("an honest 3x payment to this node must be accepted"); - assert_eq!( - status, - PaymentStatus::PaymentVerified, - "honest payment to the node's address must verify (no false reject)" - ); -} diff --git a/tests/e2e/mod.rs b/tests/e2e/mod.rs index d854922..87e63e2 100644 --- a/tests/e2e/mod.rs +++ b/tests/e2e/mod.rs @@ -63,12 +63,6 @@ mod replication; #[cfg(test)] mod security_attacks; -#[cfg(test)] -mod f2_f5_pay_yourself; - -#[cfg(test)] -mod poc_f2_f5_price_floor; - pub use anvil::TestAnvil; pub use harness::TestHarness; pub use testnet::{NetworkState, NodeState, TestNetwork, TestNetworkConfig, TestNode}; diff --git a/tests/e2e/poc_f2_f5_price_floor.rs b/tests/e2e/poc_f2_f5_price_floor.rs deleted file mode 100644 index 871c7f4..0000000 --- a/tests/e2e/poc_f2_f5_price_floor.rs +++ /dev/null @@ -1,281 +0,0 @@ -//! Proof-of-concept regression test for finding **F2/F5** (free storage). -//! -//! ## The vulnerability (pre-fix) -//! -//! `verify_evm_payment` reconstructed the payment from the attacker's own -//! quotes and delegated the decision to `SingleNodePayment::verify`, which -//! accepts if **any quote tied at the median price** was paid 3× on-chain. -//! Combined with `validate_local_recipient` only checking that this node's -//! rewards address appears in *some* quote (`.any()`), an attacker could: -//! -//! * **underprice**: submit 7 self-signed 1-atto quotes (one carrying our -//! address) and pay a negligible 3 atto; and/or -//! * **pay yourself**: include one quote with our address purely to pass the -//! recipient check, but route the actual 3× on-chain payment to a quote -//! whose `rewards_address` is the attacker's OWN wallet. -//! -//! Either way the attacker stores arbitrary data while this node earns -//! nothing — free storage. The single-node path has no DHT/identity binding. -//! -//! ## The fix — sound invariant -//! -//! `verify_evm_payment` now accepts only if there exists a quote `Q` where -//! ALL of: (a) `Q.rewards_address == this node's local_rewards_address`, -//! (b) `Q.price >= price_floor` (`calculate_price(records_stored)/TOL`, wired -//! live in production), and (c) on-chain `completedPayments(Q.hash) >= 3 * -//! Q.price`. (a)+(b) are checked on the SAME quote whose 3× payment is -//! verified in (c), so both the underpricing and pay-yourself primitives are -//! closed. -//! -//! These tests prove BOTH attack variants are now rejected, that an honest -//! fair payment to this node still passes the price/recipient gate, and -//! (flip) that without the recipient binding the pay-yourself proof would -//! sail past every pre-on-chain check. - -#![allow( - clippy::unwrap_used, - clippy::expect_used, - clippy::missing_panics_doc, - clippy::doc_markdown -)] - -use ant_node::payment::EvmVerifierConfig; -use ant_node::payment::{ - serialize_single_node_proof, PaymentProof, PaymentVerifier, PaymentVerifierConfig, - PriceFloorProvider, -}; -use evmlib::common::Amount; -use evmlib::data_payments::{EncodedPeerId, PaymentQuote, ProofOfPayment}; -use evmlib::RewardsAddress; -use saorsa_core::identity::node_identity::peer_id_from_public_key_bytes; -use saorsa_core::MlDsa65; -use saorsa_pqc::pqc::types::MlDsaSecretKey; -use saorsa_pqc::pqc::MlDsaOperations; -use std::sync::Arc; -use std::time::SystemTime; - -const CLOSE_GROUP_SIZE: usize = 7; - -/// A loaded node's honest per-record price floor for the test (independent of -/// the pricing constants; represents "this node is not free"). -const FLOOR_ATTO: u128 = 1_000_000_000_000_000; - -fn mint_quote( - content: [u8; 32], - price: Amount, - rewards: RewardsAddress, -) -> (EncodedPeerId, PaymentQuote) { - let ml_dsa = MlDsa65::new(); - let (pk, sk) = ml_dsa.generate_keypair().expect("keypair"); - let mut q = PaymentQuote { - content: xor_name::XorName(content), - timestamp: SystemTime::now(), - price, - rewards_address: rewards, - pub_key: pk.as_bytes().to_vec(), - signature: vec![], - }; - let sk = MlDsaSecretKey::from_bytes(sk.as_bytes()).expect("sk"); - q.signature = ml_dsa - .sign(&sk, &q.bytes_for_sig()) - .expect("sign") - .as_bytes() - .to_vec(); - let pid = peer_id_from_public_key_bytes(&q.pub_key).expect("peer id"); - (EncodedPeerId::new(*pid.as_bytes()), q) -} - -fn serialize(quotes: Vec<(EncodedPeerId, PaymentQuote)>) -> Vec { - serialize_single_node_proof(&PaymentProof { - proof_of_payment: ProofOfPayment { - peer_quotes: quotes, - }, - tx_hashes: vec![], - }) - .expect("serialize") -} - -fn verifier_with_floor(victim_rewards: RewardsAddress, floor_atto: u128) -> PaymentVerifier { - PaymentVerifier::new(PaymentVerifierConfig { - evm: EvmVerifierConfig::default(), - cache_capacity: 64, - local_rewards_address: victim_rewards, - price_floor: Some(PriceFloorProvider::new(Arc::new(move || { - Amount::from(floor_atto) - }))), - }) -} - -/// F5 — underpricing: 7 self-signed 1-atto quotes (one carrying our address). -/// No quote both pays this node AND meets the floor, so the proof is rejected -/// before any on-chain call. -#[tokio::test] -async fn poc_f2_f5_underpriced_proof_rejected() { - let victim = RewardsAddress::new([0xDE; 20]); - let attacker = RewardsAddress::new([0xA1; 20]); - let content = [0x99u8; 32]; - - let mut quotes = vec![mint_quote(content, Amount::from(1u64), victim)]; - for _ in 1..CLOSE_GROUP_SIZE { - quotes.push(mint_quote(content, Amount::from(1u64), attacker)); - } - - let err = format!( - "{}", - verifier_with_floor(victim, FLOOR_ATTO) - .verify_payment(&content, Some(&serialize(quotes))) - .await - .expect_err("underpriced proof must be rejected (F2/F5)") - ); - assert!( - err.contains("No quote in the single-node proof both pays this node"), - "must be rejected by the recipient+floor gate BEFORE any RPC, got: {err}" - ); -} - -/// F2 — pay-yourself: the priced quotes are ABOVE the floor (so a naive -/// price-only floor would pass), but every above-floor quote pays the -/// ATTACKER's wallet; the only quote with our address is a 1-atto decoy -/// included solely to pass the legacy `.any()` recipient check. The sound -/// invariant requires the SAME quote to both pay us and meet the floor, so -/// this is rejected before any on-chain call — the attacker can no longer -/// route the 3× payment to itself. -#[tokio::test] -async fn poc_f2_f5_pay_yourself_decoy_rejected() { - let victim = RewardsAddress::new([0xDE; 20]); - let attacker = RewardsAddress::new([0xA1; 20]); - let content = [0x77u8; 32]; - - // 1-atto decoy paying the victim (passes legacy .any() recipient check)… - let mut quotes = vec![mint_quote(content, Amount::from(1u64), victim)]; - // …plus 6 well-above-floor quotes, ALL paying the attacker's wallet. - let rich = Amount::from(FLOOR_ATTO * 10); - for _ in 1..CLOSE_GROUP_SIZE { - quotes.push(mint_quote(content, rich, attacker)); - } - - let err = format!( - "{}", - verifier_with_floor(victim, FLOOR_ATTO) - .verify_payment(&content, Some(&serialize(quotes))) - .await - .expect_err("pay-yourself decoy proof must be rejected (F2/F5)") - ); - // No quote satisfies (pays us) AND (>= floor): the decoy pays us but is - // 1 atto; the rich quotes meet the floor but pay the attacker. - assert!( - err.contains("No quote in the single-node proof both pays this node"), - "pay-yourself proof must be rejected by the recipient+floor gate \ - BEFORE any RPC, got: {err}" - ); -} - -/// Flip / control: WITHOUT the recipient+floor binding (modelled by -/// `price_floor: None`, which also disables the underpricing bound) the -/// pay-yourself proof is NOT stopped by the recipient/price gate — it reaches -/// the on-chain step. Pre-fix, against a real chain where the attacker paid -/// itself 3×, this is exactly the free-storage acceptance. This proves the -/// new binding is what closes F2/F5. -#[tokio::test] -async fn poc_f2_f5_without_binding_pay_yourself_passes_gate() { - let victim = RewardsAddress::new([0xDE; 20]); - let attacker = RewardsAddress::new([0xA1; 20]); - let content = [0x77u8; 32]; - - let mut quotes = vec![mint_quote(content, Amount::from(1u64), victim)]; - let rich = Amount::from(FLOOR_ATTO * 10); - for _ in 1..CLOSE_GROUP_SIZE { - quotes.push(mint_quote(content, rich, attacker)); - } - - let verifier = PaymentVerifier::new(PaymentVerifierConfig { - evm: EvmVerifierConfig::default(), - cache_capacity: 64, - local_rewards_address: victim, - price_floor: None, // pre-fix behaviour - }); - - let err = format!( - "{}", - verifier - .verify_payment(&content, Some(&serialize(quotes))) - .await - .expect_err("offline: no EVM endpoint, so the on-chain step errors") - ); - // The decisive flip assertion. WITHOUT the binding, the pre-on-chain - // recipient+floor gate does NOT exist, so the proof reaches the on-chain - // lookup. Offline, `completedPayments` resolves to 0, so it's rejected - // with the on-chain "not paid >=3x" message (last on-chain amount 0) — - // NOT the pre-RPC recipient+floor rejection. Pre-fix, against a REAL - // chain where the attacker paid itself 3×, that same on-chain check - // returns >= expected for the attacker-self-paid quote and the store is - // ACCEPTED = free storage. The post-binding code (other tests) rejects - // these proofs *before* ever reaching the chain. - assert!( - !err.contains("No quote in the single-node proof both pays this node"), - "without a floor the proof must NOT be stopped by the pre-RPC gate \ - (it reaches the on-chain step); got: {err}" - ); - // The test is designed to prove "passed the pre-RPC gate, died at the - // on-chain step." That is true whether the on-chain step returned 0 - // (RPC reachable, no payment) — error contains "No quote paying this - // node at/above the price floor was paid >=3x" — or whether the RPC - // call itself errored (CI without mainnet) — error contains - // "completedPayments lookup failed". Either is the post-gate failure - // the test predicts; pre-fix on a real self-paid chain this is - // instead ACCEPTED = free storage. - let reached_on_chain = err - .contains("No quote paying this node at/above the price floor was paid >=3x") - || err.contains("completedPayments lookup failed"); - assert!( - reached_on_chain, - "expected the proof to reach the on-chain step (and fail there); got: {err}" - ); -} - -/// Positive control: an honest client paying THIS node a fair (>= floor) -/// price passes the recipient+floor gate and only fails later at the on-chain -/// step (offline). The fix must not reject legitimate payments. -#[tokio::test] -async fn poc_f2_f5_fair_payment_passes_gate() { - let victim = RewardsAddress::new([0xDE; 20]); - let attacker = RewardsAddress::new([0xB0; 20]); - let content = [0x42u8; 32]; - - // A fair quote paying THIS node well above the floor. - let fair = Amount::from(FLOOR_ATTO * 5); - let mut quotes = vec![mint_quote(content, fair, victim)]; - for _ in 1..CLOSE_GROUP_SIZE { - quotes.push(mint_quote(content, fair, attacker)); - } - - let err = format!( - "{}", - verifier_with_floor(victim, FLOOR_ATTO) - .verify_payment(&content, Some(&serialize(quotes))) - .await - .expect_err("offline: on-chain step errors") - ); - // A fair quote pays THIS node above the floor, so it satisfies (a)+(b) - // and becomes an on-chain candidate — i.e. it PASSES the recipient+floor - // gate (it is not rejected pre-RPC). Offline the on-chain lookup yields 0 - // so it then fails the 3× check; on a real chain with a genuine payment - // it would be accepted. The fix must not pre-reject honest payments. - assert!( - !err.contains("No quote in the single-node proof both pays this node"), - "a fair payment to this node must PASS the recipient+floor gate \ - (not be pre-RPC rejected); got: {err}" - ); - // Same robust either-or assertion as the without-binding test: a fair - // payment passes the pre-RPC gate and dies at the on-chain step, whether - // that yields the "paid 0" message (RPC reachable) or the "lookup failed" - // message (offline CI without mainnet). - let reached_on_chain = err - .contains("No quote paying this node at/above the price floor was paid >=3x") - || err.contains("completedPayments lookup failed"); - assert!( - reached_on_chain, - "fair payment should pass the gate and reach (then fail at) the \ - on-chain step; got: {err}" - ); -} diff --git a/tests/e2e/testnet.rs b/tests/e2e/testnet.rs index 33c43aa..14216be 100644 --- a/tests/e2e/testnet.rs +++ b/tests/e2e/testnet.rs @@ -1100,7 +1100,6 @@ impl TestNetwork { }, cache_capacity: TEST_PAYMENT_CACHE_CAPACITY, local_rewards_address: rewards_address, - price_floor: None, }; let payment_verifier = PaymentVerifier::new(payment_config);