diff --git a/Cargo.lock b/Cargo.lock index 13b5520..4ca1a6b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -809,7 +809,7 @@ dependencies = [ [[package]] name = "ant-node" -version = "0.11.5" +version = "0.11.6" dependencies = [ "alloy", "ant-protocol", @@ -861,9 +861,9 @@ dependencies = [ [[package]] name = "ant-protocol" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4d0ba3f671c08a1d52291b601ec35a7353f4f4e10f221b5f0ee372d154636dd" +checksum = "9e950d12c9f6d08d0ea560573729d93f15e105d53b669defa682f5e6f92da4b1" dependencies = [ "blake3", "bytes", @@ -1316,9 +1316,9 @@ dependencies = [ [[package]] name = "bitflags" -version = "2.11.1" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" dependencies = [ "serde_core", ] @@ -1490,9 +1490,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.62" +version = "1.2.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", "jobserver", @@ -2961,9 +2961,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.10.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb92f162bf56536459fc83c79b974bb12837acfed43d6bc370a7916d0ae15ecc" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", @@ -3012,7 +3012,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2 0.6.4", "tokio", "tower-service", "tracing", @@ -3159,9 +3159,9 @@ dependencies = [ [[package]] name = "igd-next" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bac9a3c8278f43b4cd8463380f4a25653ac843e5b177e1d3eaf849cc9ba10d4d" +checksum = "de7238d487a9aff61f81b5ab41c0a841532a115a398b5fa92a2fadd0885e2581" dependencies = [ "attohttpc", "bytes", @@ -3520,9 +3520,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.30" +version = "0.4.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" +checksum = "113b30b4cd05f7c06868fdb2854f66a7b9fece9a48425351cd532e810d74024f" [[package]] name = "lru" @@ -3622,9 +3622,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi", @@ -4229,7 +4229,7 @@ dependencies = [ "quinn-udp 0.5.14", "rustc-hash", "rustls", - "socket2 0.6.3", + "socket2 0.6.4", "thiserror 2.0.18", "tokio", "tracing", @@ -4267,7 +4267,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.3", + "socket2 0.6.4", "tracing", "windows-sys 0.60.2", ] @@ -4280,7 +4280,7 @@ checksum = "76150b617afc75e6e21ac5f39bc196e80b65415ae48d62dbef8e2519d040ce42" dependencies = [ "cfg_aliases", "libc", - "socket2 0.6.3", + "socket2 0.6.4", "tracing", "windows-sys 0.61.2", ] @@ -4722,9 +4722,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -4866,9 +4866,9 @@ dependencies = [ [[package]] name = "saorsa-core" -version = "0.24.4" +version = "0.24.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c1267928da1bcc91748c314f95f7952bc01c0359a4ac70a0b111b0386898934" +checksum = "3c0f8952fc5a4d37eb0bca7de0740830f40347f9da663effde3ddd6b68bcd2fb" dependencies = [ "anyhow", "async-trait", @@ -5039,15 +5039,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "scc" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" -dependencies = [ - "sdd", -] - [[package]] name = "schannel" version = "0.1.29" @@ -5087,12 +5078,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "sdd" -version = "3.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" - [[package]] name = "sec1" version = "0.7.3" @@ -5325,24 +5310,23 @@ dependencies = [ [[package]] name = "serial_test" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f" +checksum = "699f4197115b8a7e7ff19c9a315a4bd6fffec26cc4626ef45ecaea389e081c6d" dependencies = [ "futures-executor", "futures-util", "log", "once_cell", "parking_lot", - "scc", "serial_test_derive", ] [[package]] name = "serial_test_derive" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9" +checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" dependencies = [ "proc-macro2", "quote", @@ -5412,9 +5396,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook-registry" @@ -5491,9 +5475,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", "windows-sys 0.61.2", @@ -5820,7 +5804,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.3", + "socket2 0.6.4", "tokio-macros", "windows-sys 0.61.2", ] @@ -6106,9 +6090,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "ucd-trie" @@ -6142,9 +6126,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-width" @@ -6207,9 +6191,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.1" +version = "1.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -7105,18 +7089,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.49" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bce33a6288fa3f072a8c2c7d0f2fdbb90e28298f0135c1f99b96c3db2efcc60b" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.49" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd425244944f4ab65ccff928e7323354c5a018c75838362fdce749dfad2ee1e" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 0ef01ea..e89ab45 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ant-node" -version = "0.11.5" +version = "0.11.6" edition = "2021" authors = ["David Irvine "] description = "Pure quantum-proof network node for the Autonomi decentralized network" @@ -39,10 +39,10 @@ mimalloc = "0.1" # Until then, the git pin tracks the matching saorsa-core lineage # (the rc-2026.4.2 branch) so Cargo can unify the wire types here # with ant-protocol's re-exports. -ant-protocol = "2.1.1" +ant-protocol = "2.1.2" # Core (provides EVERYTHING: networking, DHT, security, trust, storage) -saorsa-core = "0.24.4" +saorsa-core = "0.24.5" saorsa-pqc = "0.5" # Payment verification - autonomi network lookup + EVM payment diff --git a/src/payment/quote.rs b/src/payment/quote.rs index 6fd4025..5a1a44d 100644 --- a/src/payment/quote.rs +++ b/src/payment/quote.rs @@ -11,12 +11,15 @@ use crate::error::{Error, Result}; use crate::logging::debug; use crate::payment::metrics::QuotingMetricsTracker; use crate::payment::pricing::calculate_price; +use crate::storage::lmdb::LmdbStorage; use evmlib::merkle_payments::MerklePaymentCandidateNode; use evmlib::PaymentQuote; use evmlib::RewardsAddress; +use parking_lot::RwLock; 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`). @@ -32,8 +35,24 @@ pub type SignFn = Box Vec + Send + Sync>; pub struct QuoteGenerator { /// The rewards address for receiving payments. rewards_address: RewardsAddress, - /// Metrics tracker for quoting. + /// Fallback in-memory record counter for pricing. + /// + /// Only consulted when no [`LmdbStorage`] is attached (unit tests, or a + /// mis-configured startup). In production the price is derived from the + /// attached store's `current_chunks()` instead — see [`Self::storage`]. metrics_tracker: QuotingMetricsTracker, + /// Authoritative on-disk record-count source for pricing. + /// + /// When attached, quote prices are computed from + /// [`LmdbStorage::current_chunks()`] — the **same** count the + /// [`PaymentVerifier`](crate::payment::PaymentVerifier) freshness gate + /// compares the quote against. Keeping pricing and freshness on one source + /// means a quote priced at record count `N` is later checked against a + /// current count that differs only by genuine in-flight growth, instead of + /// by the standing client-PUT-vs-replication gap that rejected every + /// payment when pricing read the side counter and freshness read the store. + /// `None` until [`Self::attach_storage`] is called. + storage: RwLock>>, /// Signing function provided by the node. /// Takes bytes and returns a signature. sign_fn: Option, @@ -55,11 +74,47 @@ impl QuoteGenerator { Self { rewards_address, metrics_tracker, + storage: RwLock::new(None), sign_fn: None, pub_key: Vec::new(), } } + /// Attach the node's [`LmdbStorage`] so quote prices reflect the + /// authoritative on-disk record count. + /// + /// This MUST be wired to the same `LmdbStorage` the + /// [`PaymentVerifier`](crate::payment::PaymentVerifier) freshness gate reads + /// via `current_chunks()`; otherwise pricing and freshness diverge and the + /// gate rejects healthy payments. Idempotent: calling twice replaces the + /// handle. Uses interior mutability so it can be called on an `Arc`. + pub fn attach_storage(&self, storage: Arc) { + *self.storage.write() = Some(storage); + debug!("QuoteGenerator: LmdbStorage attached for current-records pricing"); + } + + /// Record count used to price quotes. + /// + /// Prefers the attached `LmdbStorage` count (authoritative — counts client + /// PUTs, replication stores, and repair fetches alike, exactly matching the + /// verifier's freshness source). Falls back to the in-memory + /// `metrics_tracker` when no storage is attached or the read fails, so + /// pricing never panics or stalls. + fn pricing_records_stored(&self) -> usize { + if let Some(storage) = self.storage.read().as_ref() { + match storage.current_chunks() { + Ok(n) => return usize::try_from(n).unwrap_or(usize::MAX), + Err(e) => { + debug!( + "QuoteGenerator: current_chunks() failed ({e}); \ + falling back to metrics_tracker for pricing" + ); + } + } + } + self.metrics_tracker.records_stored() + } + /// Set the signing function for quote generation. /// /// # Arguments @@ -128,8 +183,10 @@ impl QuoteGenerator { let timestamp = SystemTime::now(); - // Calculate price from current record count - let price = calculate_price(self.metrics_tracker.records_stored()); + // Calculate price from the authoritative current record count (the same + // count the verifier's freshness gate reads), falling back to the + // in-memory counter only when no storage is attached. + let price = calculate_price(self.pricing_records_stored()); // Convert XorName to xor_name::XorName let xor_name = xor_name::XorName(content); @@ -205,7 +262,7 @@ impl QuoteGenerator { .as_ref() .ok_or_else(|| Error::Payment("Quote signing not configured".to_string()))?; - let price = calculate_price(self.metrics_tracker.records_stored()); + let price = calculate_price(self.pricing_records_stored()); // Compute the same bytes_to_sign used by the upstream library let msg = MerklePaymentCandidateNode::bytes_to_sign( @@ -313,6 +370,81 @@ mod tests { generator } + /// Regression test for the STG-01 quote-freshness rejection: pricing must + /// read the attached store's `current_chunks()`, NOT the side counter. + /// + /// Before the fix, the price came from `metrics_tracker` (client-PUT count + /// only) while the verifier's freshness gate read `current_chunks()` (all + /// records, including replicated ones). On a replicating network the store + /// count ran far ahead of the side counter, so every quote looked "stale". + /// Here we attach a store, write records WITHOUT touching the side counter + /// (mimicking replication stores), and assert the quote prices off the + /// store count — i.e. the two sources now agree. + #[tokio::test] + async fn test_pricing_tracks_attached_storage_not_side_counter() { + use crate::payment::pricing::derive_records_stored_from_price; + use crate::storage::{LmdbStorage, LmdbStorageConfig}; + use tempfile::TempDir; + + let temp_dir = TempDir::new().expect("temp dir"); + let storage = Arc::new( + LmdbStorage::new(LmdbStorageConfig { + root_dir: temp_dir.path().to_path_buf(), + ..LmdbStorageConfig::test_default() + }) + .await + .expect("create storage"), + ); + + // Side counter deliberately starts well BELOW the store count to model + // a node whose records arrived mostly via replication (which never + // increments the side counter). + let metrics_tracker = QuotingMetricsTracker::new(3); + let mut generator = QuoteGenerator::new(RewardsAddress::new([1u8; 20]), metrics_tracker); + generator.set_signer(vec![0u8; 64], |bytes| { + let mut sig = vec![0u8; 64]; + for (i, b) in bytes.iter().take(64).enumerate() { + sig[i] = *b; + } + sig + }); + generator.attach_storage(Arc::clone(&storage)); + + // Write 25 distinct records straight to the store, as a replication + // store would — the side counter stays at 3. + for i in 0..25u32 { + let content = format!("replicated-record-{i}"); + let address = LmdbStorage::compute_address(content.as_bytes()); + storage + .put(&address, content.as_bytes()) + .await + .expect("put"); + } + assert_eq!( + generator.records_stored(), + 3, + "side counter must be untouched" + ); + assert_eq!(storage.current_chunks().expect("count"), 25); + + let quote = generator + .create_quote([42u8; 32], 1024, 0) + .expect("create quote"); + + // Price must encode 25 (the store count), not 3 (the side counter). + assert_eq!( + quote.price, + calculate_price(25), + "price must be derived from current_chunks(), not metrics_tracker" + ); + assert_eq!( + derive_records_stored_from_price(quote.price), + 25, + "verifier's price-inverse must recover the store count, keeping the \ + freshness delta at ~0 for a freshly issued quote" + ); + } + #[test] fn test_create_quote() { let generator = create_test_generator(); diff --git a/src/payment/verifier.rs b/src/payment/verifier.rs index 8d151e7..6774fba 100644 --- a/src/payment/verifier.rs +++ b/src/payment/verifier.rs @@ -7,7 +7,7 @@ use crate::ant_protocol::CLOSE_GROUP_SIZE; use crate::error::{Error, Result}; use crate::logging::{debug, info, warn}; use crate::payment::cache::{CacheStats, VerifiedCache, XorName}; -use crate::payment::pricing::derive_records_stored_from_price; +use crate::payment::pricing::{calculate_price, derive_records_stored_from_price}; use crate::payment::proof::{ deserialize_merkle_proof, deserialize_proof, detect_proof_type, ProofType, }; @@ -42,15 +42,25 @@ pub const MIN_PAYMENT_PROOF_SIZE_BYTES: usize = 32; /// 256 KB provides headroom while still capping memory during verification. pub const MAX_PAYMENT_PROOF_SIZE_BYTES: usize = 262_144; -/// Minimum absolute tolerance for the storage-delta freshness check. -/// A quote is accepted if the difference between the quoted record count inferred from price -/// and the node's current `records_stored` is at most this many records. -const QUOTE_STORAGE_DELTA_MIN_TOLERANCE: u64 = 5; - -/// Percentage tolerance for the storage-delta freshness check. -/// The effective tolerance is the larger of the percentage and -/// [`QUOTE_STORAGE_DELTA_MIN_TOLERANCE`]. -const QUOTE_STORAGE_DELTA_PCT_TOLERANCE: u64 = 5; +/// Maximum percentage by which a quote's paid price may fall *below* the node's +/// current price before the quote is rejected as stale. +/// +/// The freshness gate is one-directional and price-based, not a symmetric +/// record-count delta: +/// +/// - **Over-payment is always accepted.** If the client paid at least the +/// node's current price (e.g. the node pruned records and is now cheaper), +/// the quote is fine — a node has no reason to reject money. +/// - **Only meaningful under-payment is rejected.** A quote priced below the +/// current price by more than this percentage is rejected as stale. +/// +/// Comparing prices instead of raw record counts makes the tolerance +/// self-scaling against the quadratic pricing curve: at low/moderate fill the +/// curve is nearly flat, so normal in-flight churn (the node storing a handful +/// of replicated records between quoting and verifying) is a negligible price +/// change and passes; at high fill the curve is steep, so the same percentage +/// still catches genuinely stale, underpriced quotes. +const QUOTE_PRICE_STALENESS_PCT_TOLERANCE: u64 = 25; /// Configuration for EVM payment verification. /// @@ -609,43 +619,60 @@ impl PaymentVerifier { Ok(()) } - /// Verify quote freshness using storage-delta inferred from price, not wall-clock time. + /// Verify quote freshness by price staleness, not wall-clock time and not a + /// symmetric record-count delta. /// /// The quote price encodes the quoting node's record count via the quadratic - /// pricing formula. Comparing that inferred count to this node's current - /// record count removes the platform clock dependency that caused Windows/UTC - /// false rejections. Quote timestamps are deliberately not used here. + /// pricing formula. We compute the price the node would charge *now* for its + /// current fullness and reject the quote only if the client under-paid that + /// current price by more than [`QUOTE_PRICE_STALENESS_PCT_TOLERANCE`]. This: + /// + /// - removes the platform clock dependency that caused Windows/UTC false + /// rejections (timestamps are deliberately unused); + /// - never rejects an over-payment (the previous symmetric `abs_diff` check + /// rejected quotes where the node had *fewer* records than when it quoted, + /// i.e. the client paid for a fuller, pricier node — nonsensical to + /// reject); and + /// - self-scales with the pricing curve, so benign in-flight churn (a node + /// storing a few replicated records between quoting and verifying) — a + /// negligible price move where the curve is flat — no longer rejects an + /// otherwise-valid payment. On a fresh, rapidly-filling testnet that churn + /// routinely exceeded the old fixed 5-record tolerance and rejected ~100% + /// of uploads via the multiplicative per-chunk effect. /// - /// The verifier reads the current record count from the attached - /// [`LmdbStorage`] via `current_chunks()` — that's an O(1) B-tree page- - /// header read and is the authoritative count regardless of which path - /// stored the record (client PUT, replication store, repair fetch) or - /// removed it (prune delete). If no storage source is available (mis- - /// configured production startup, or a unit test that didn't set a test - /// override), the storage-delta gate is skipped entirely rather than + /// The current record count comes from the attached [`LmdbStorage`] via + /// `current_chunks()` — an O(1) B-tree page-header read, authoritative + /// regardless of which path stored the record (client PUT, replication + /// store, repair fetch) or removed it (prune delete). If no storage source + /// is available (mis-configured production startup, or a unit test that + /// didn't set a test override), the gate is skipped entirely rather than /// rejecting every quote — see [`Self::current_records_stored`]. fn validate_quote_freshness(&self, payment: &ProofOfPayment) -> Result<()> { let Some(current_records) = self.current_records_stored() else { debug!( "PaymentVerifier: no record-count source attached; skipping \ - storage-delta freshness check" + quote price-staleness check" ); return Ok(()); }; - for (encoded_peer_id, quote) in &payment.peer_quotes { - let quoted_records = derive_records_stored_from_price(quote.price); - - let delta = quoted_records.abs_diff(current_records); - let pct_tolerance = quoted_records - .saturating_mul(QUOTE_STORAGE_DELTA_PCT_TOLERANCE) - .saturating_div(100); - let tolerance = QUOTE_STORAGE_DELTA_MIN_TOLERANCE.max(pct_tolerance); + // The price the node would charge right now for its current fullness, + // and the floor a quote may not drop below (one-directional: paying at + // or above `current_price` is always accepted). + let current_price = calculate_price(usize::try_from(current_records).unwrap_or(usize::MAX)); + let min_acceptable_price = current_price.saturating_mul(Amount::from( + 100u64.saturating_sub(QUOTE_PRICE_STALENESS_PCT_TOLERANCE), + )) / Amount::from(100u64); - if delta > tolerance { + for (encoded_peer_id, quote) in &payment.peer_quotes { + if quote.price < min_acceptable_price { + let quoted_records = derive_records_stored_from_price(quote.price); return Err(Error::Payment(format!( - "Quote from peer {encoded_peer_id:?} stale by {delta} records \ - (quoted {quoted_records} vs current {current_records}, tolerance {tolerance})" + "Quote from peer {encoded_peer_id:?} stale: paid price encodes \ + {quoted_records} records but node currently holds {current_records} \ + (paid {}, minimum acceptable {min_acceptable_price} at \ + {QUOTE_PRICE_STALENESS_PCT_TOLERANCE}% under-payment tolerance)", + quote.price ))); } } @@ -670,24 +697,30 @@ impl PaymentVerifier { Ok(()) } - /// Minimum number of candidate `pub_keys` (out of 16) whose derived `PeerId` - /// must match the DHT's actual closest peers to the pool midpoint address. + /// Minimum number of candidate `pub_keys` (out of 16) whose derived + /// `PeerId` must be among the DHT's actual closest peers to the pool + /// midpoint address for the pool to be accepted. /// - /// Set below 16/16 to absorb normal routing-table skew between the - /// payer's view and this node's view — on a well-connected network the - /// divergence between two nodes' closest-set views is typically 1-2 - /// peers, occasionally 3 during churn. 13/16 tolerates 3 divergent - /// peers while still limiting how many candidates an attacker can - /// fabricate before the check bites. A lower threshold (e.g. 9/16) - /// would let an attacker who controls 7 real neighbourhood peers plant - /// 7 fabricated candidates and still pass. + /// Set to a simple majority (9/16). Two nodes' views of the closest set + /// to a midpoint diverge on a young, high-churn, NAT-heavy network — by + /// more than a near-unanimous threshold tolerates — so a stricter bar + /// rejected honest pools whose candidates are genuinely drawn from the + /// midpoint's close group but don't all reappear in this storer's own + /// lookup. A majority absorbs that divergence while still requiring most + /// candidates to be real peers the live DHT lists as closest. /// - /// This is the pure "fabricated key" defence; it does not stop an - /// attacker who can grind the pool midpoint address to land near 13 - /// pre-chosen keys AND run those keys as Sybil DHT participants. That - /// requires an orthogonal Sybil-resistance layer and is out of scope - /// for this check. - const CANDIDATE_CLOSENESS_REQUIRED: usize = 13; + /// Security cost: a lower threshold widens the room for the "pay-yourself" + /// attack — an attacker running real neighbourhood peers needs fewer of + /// them to clear a majority than to clear a near-unanimous bar. No theft + /// of funds is possible regardless (payment binds on-chain to the rewards + /// address); the cost is that grinding storage payments back to your own + /// nodes gets cheaper. Each counted candidate must still be a peer the + /// live DHT actually returns as closest — a fabricated off-network key + /// cannot satisfy this — so the floor is "run N real top-K Sybil nodes + /// AND grind the midpoint", just with a smaller N. Pairs with the planned + /// pool-midpoint consensus-anchor work, which removes the midpoint + /// grinding freedom that makes a low threshold dangerous. + const CANDIDATE_CLOSENESS_REQUIRED: usize = 9; /// Timeout for the authoritative network lookup used by the closeness /// check. @@ -744,11 +777,12 @@ impl PaymentVerifier { /// timeout bump above. /// /// Security: the pay-yourself attack still requires the attacker's - /// fabricated `PeerId`s to land in the storer's authoritative top-K. - /// K=32 doubles the window vs K=16 (≈1 extra bit of grinding), but - /// the dominant cost is still Sybil-grinding midpoint addresses or - /// running real nodes near the target — same security floor. - /// `CANDIDATE_CLOSENESS_REQUIRED` (13/16) is unchanged. + /// fabricated `PeerId`s to land in the storer's authoritative top-K, so + /// the dominant cost is Sybil-grinding midpoint addresses or running real + /// nodes near the target. The leniency for honest divergence comes from + /// the `CANDIDATE_CLOSENESS_REQUIRED` majority threshold, not from this + /// window; widening the window further was measured as too heavy on the + /// lookup path. const CLOSENESS_LOOKUP_WIDTH: usize = 2 * evmlib::merkle_payments::CANDIDATES_PER_POOL; /// Maximum waiter → leader retries when the leader's future was cancelled @@ -811,8 +845,8 @@ impl PaymentVerifier { /// **Known limitation — Sybil-grinding**: `midpoint_proof.address()` is a /// BLAKE3 hash of attacker-controllable inputs (leaf bytes, tree root, /// timestamp). A determined attacker who *also* runs Sybil DHT nodes can - /// grind the midpoint until it lands in a region where 13 of their - /// Sybil keys are the true network-closest — at which point this check + /// grind the midpoint until it lands in a region where a majority of + /// their Sybil keys are the true network-closest — at which point this check /// passes for the attacker. Closing that gap requires binding the /// midpoint to an attacker-uncontrolled value (e.g. a block hash at /// payment time or an on-chain VRF) or a Sybil-resistant identity @@ -964,10 +998,19 @@ impl PaymentVerifier { } /// Pure-logic closeness check: given the pool's candidate peer IDs and - /// the storer's authoritative network view (top-K closest peers to the - /// pool midpoint), decide whether the pool passes the + /// the storer's authoritative network view (closest peers to the pool + /// midpoint), decide whether the pool passes the /// `CANDIDATE_CLOSENESS_REQUIRED`-of-N threshold. /// + /// A candidate counts only if its `PeerId` is one of the peers the + /// storer's own network lookup returned (exact set membership). This is + /// the property that makes the gate meaningful: a passing candidate must + /// be a real, reachable peer the live DHT actually routes to and lists + /// among the closest — it cannot be a key fabricated off-network. The + /// leniency in this check is purely the lowered threshold (a majority + /// rather than near-unanimity), which tolerates the closest-set + /// divergence between two nodes' views without admitting fabricated keys. + /// /// Extracted from `verify_merkle_candidate_closeness_inner` so tests /// can exercise the matching logic without standing up a real DHT. /// Mirrors the runtime path exactly: same sparse-network short-circuit, @@ -1000,7 +1043,7 @@ impl PaymentVerifier { ))); } - // Set-membership check against the returned closest-peers list. + // Exact-match membership against the returned closest peers. // Candidate `PeerId`s are deduplicated upstream, so each match // corresponds to a distinct peer. let network_set: std::collections::HashSet = @@ -1759,15 +1802,20 @@ mod tests { quote } + /// A small upward record drift between quoting and verifying — the normal + /// in-flight churn on a busy network — must pass. The old fixed 5-record + /// tolerance rejected a drift of 10 as "stale by 10 records"; the + /// price-based gate sees a negligible price move on the near-flat curve and + /// accepts it. #[test] - fn test_storage_delta_within_tolerance_accepted() { + fn test_small_record_drift_accepted() { use evmlib::{EncodedPeerId, RewardsAddress}; let verifier = create_test_verifier(); - verifier.set_records_stored_for_tests(105); - let xorname = [0xE0u8; 32]; + // Node gained 10 records since quoting (100 -> 110). + verifier.set_records_stored_for_tests(110); let quote = make_fake_quote_at_records( - xorname, + [0xE0u8; 32], SystemTime::now(), RewardsAddress::new([1u8; 20]), 100, @@ -1778,18 +1826,47 @@ mod tests { verifier .validate_quote_freshness(&payment) - .expect("delta within tolerance should pass"); + .expect("benign in-flight drift should pass"); } + /// Over-payment must always be accepted: the node had MORE records when it + /// quoted than it does now (e.g. it pruned), so the client paid for a + /// fuller, pricier node. The old symmetric `abs_diff` gate wrongly rejected + /// this; ~36% of STG-01 rejections were exactly this case. #[test] - fn test_storage_delta_exceeds_tolerance_rejected() { + fn test_overpayment_accepted() { use evmlib::{EncodedPeerId, RewardsAddress}; let verifier = create_test_verifier(); - verifier.set_records_stored_for_tests(107); - let xorname = [0xE1u8; 32]; + // Quote priced at 6000 records, but node now holds only 100. + verifier.set_records_stored_for_tests(100); let quote = make_fake_quote_at_records( - xorname, + [0xE2u8; 32], + SystemTime::now(), + RewardsAddress::new([1u8; 20]), + 6000, + ); + let payment = ProofOfPayment { + peer_quotes: vec![(EncodedPeerId::new(rand::random()), quote)], + }; + + verifier + .validate_quote_freshness(&payment) + .expect("over-payment must never be rejected"); + } + + /// Genuine staleness — a quote that under-prices the node's current fullness + /// by far more than the tolerance — is still rejected. Quote encodes 100 + /// records but the node now holds 6000, so the quadratic curve makes the + /// paid price a small fraction of the current price. + #[test] + fn test_underpriced_quote_rejected() { + use evmlib::{EncodedPeerId, RewardsAddress}; + + let verifier = create_test_verifier(); + verifier.set_records_stored_for_tests(6000); + let quote = make_fake_quote_at_records( + [0xE1u8; 32], SystemTime::now(), RewardsAddress::new([1u8; 20]), 100, @@ -1800,8 +1877,8 @@ mod tests { let err = verifier .validate_quote_freshness(&payment) - .expect_err("delta beyond tolerance should fail"); - assert!(format!("{err}").contains("stale by 7 records")); + .expect_err("a quote underpricing by >25% should fail"); + assert!(format!("{err}").contains("stale")); } /// Helper: wrap quotes into a tagged serialized `PaymentProof`. @@ -2294,7 +2371,7 @@ mod tests { #[tokio::test] async fn closeness_rejects_pool_with_duplicate_candidate_pub_keys() { // An attacker who submits 16 copies of the same real peer's pub_key - // would otherwise satisfy the 13/16 closeness threshold trivially: + // would otherwise satisfy the closeness threshold trivially: // that one peer's membership in the DHT-returned set would count // 16 times. The dedupe check in verify_merkle_candidate_closeness_inner // must reject the pool BEFORE the network lookup runs (so this test @@ -2866,15 +2943,15 @@ mod tests { } #[test] - fn closeness_required_threshold_unchanged_at_13() { - // Sanity-check that widening the lookup did not also lower the - // matching threshold. The 13/16 floor is the security knob; the - // window widening is purely a false-positive fix for honest pools. + fn closeness_required_threshold_is_majority() { + // Pin the threshold so a future change can't silently move it. This + // is the security knob: a 9/16 majority tolerates closest-set + // divergence between two nodes' views while still requiring most + // candidates to be real peers the live DHT lists as closest. assert_eq!( PaymentVerifier::CANDIDATE_CLOSENESS_REQUIRED, - 13, - "Widening the lookup window must not lower the matching \ - threshold — that would weaken the pay-yourself defence" + 9, + "closeness threshold is a 9/16 majority" ); } @@ -2920,23 +2997,19 @@ mod tests { ); // ========================================================================= - // Regression tests for the original STG-01 failure modes + // Closeness-match logic tests // // These tests use the extracted `check_closeness_match` helper to // exercise the matching logic directly with synthetic peer-ID sets, - // without standing up a real DHT. They prove the two failure modes - // observed on STG-01 on 2026-05-01 are fixed by the K=16 → K=32 - // change: + // without standing up a real DHT. They cover: // - // - "K=16 storer rejects honest pool whose candidates legitimately - // include peers from positions 17–32" (~73% of mismatches) + // - the 9/16 majority threshold (accept at exactly 9, reject below); + // - that a candidate counts only via exact membership in the storer's + // returned closest peers, so off-network fabrications are rejected; + // - the sparse-network short-circuit. // - // and that the security floor (`CANDIDATE_CLOSENESS_REQUIRED = 13/16`) - // still rejects forged pools at the wider window. - // - // Pool address used as the XOR midpoint: `[0u8; 32]`. - // Synthetic PeerIds use distinct constant byte patterns so each test - // can reason about which IDs are "in the network's top-K" vs not. + // Synthetic PeerIds put the tag in `bytes[0]`, so a candidate is in or + // out of the network's returned set purely by tag value. // ========================================================================= /// Build a deterministic `PeerId` from a single byte tag. @@ -2964,78 +3037,50 @@ mod tests { #[test] fn closeness_match_passes_when_candidates_span_positions_1_to_15_and_17() { - // STG-01 regression test: the client's pool contains 16 candidates, - // 15 of which are at network-true positions 1..=15, and ONE of - // which is at position 17 (because the network-true position-16 - // peer was unresponsive when the client over-queried 32). - // - // Pre-fix (K=16 storer): network_peer_ids = 16 entries (positions - // 1..=16); position 17 is NOT in the network set, so matched = - // 15 < 13 — wait, 15 ≥ 13, that path actually passes too. The - // failure mode was a *worse* skew where 4+ of the storer's top-16 - // were unresponsive at the client side. Let me model that case - // precisely below. + // The client's pool contains 16 candidates, 15 at network-true + // positions 1..=15 plus one at position 17 (the position-16 peer was + // unresponsive when the client over-queried). Under K=32 all 16 are + // exact matches, comfortably ≥ the 9/16 majority. let candidates = synthetic_peer_ids(15) .into_iter() .chain(std::iter::once(synthetic_peer_id(17))) .collect::>(); - // Post-fix lookup window = 32, includes position 17. + // Lookup window = 32, includes position 17. let network: Vec = (1..=32).map(synthetic_peer_id).collect(); let pool_address = [0u8; 32]; let result = PaymentVerifier::check_closeness_match(&candidates, &network, &pool_address); assert!( result.is_ok(), - "pool with one candidate at position 17 must pass under K=32: {result:?}" + "pool with one candidate at position 17 must pass: {result:?}" ); } #[test] - fn closeness_match_fails_at_k_16_passes_at_k_32_for_honest_skew() { - // The actual STG-01 failure mode: the client's 16 candidates - // legitimately span network-true positions {1..=12, 17, 19, 21, - // 23} — i.e. 12 positions in the storer's top-16 plus 4 in the - // 17–32 window (because positions 13–16 were unresponsive when - // the client over-queried). + fn closeness_match_accepts_honest_skew_via_exact_matches() { + // Honest skew: the client's 16 candidates span network-true positions + // {1..=12, 17, 19, 21, 23}. The lookup window of 32 covers all of + // them, so all 16 are exact matches — trivially ≥ the 9/16 majority. let candidates: Vec = (1..=12u8) .chain([17u8, 19, 21, 23]) .map(synthetic_peer_id) .collect(); let pool_address = [0u8; 32]; + let network: Vec = (1..=32).map(synthetic_peer_id).collect(); - // Pre-fix (K=16): network = positions 1..=16. Only 12 of the 16 - // candidates appear — below the 13/16 threshold. This is the - // exact false-positive rejection STG-01 was hitting. - let network_pre_fix: Vec = (1..=16).map(synthetic_peer_id).collect(); - let result_pre_fix = - PaymentVerifier::check_closeness_match(&candidates, &network_pre_fix, &pool_address); - assert!( - result_pre_fix.is_err(), - "PRE-FIX: K=16 storer should reject the honest pool (this is \ - the bug we observed; if this assertion stops failing the \ - refactor lost the rejection logic): {result_pre_fix:?}" - ); - - // Post-fix (K=32): network = positions 1..=32. All 16 candidates - // appear (12 at 1..=12, 4 at 17/19/21/23). matched = 16 ≥ 13: - // pool accepted. This is the fix. - let network_post_fix: Vec = (1..=32).map(synthetic_peer_id).collect(); - let result_post_fix = - PaymentVerifier::check_closeness_match(&candidates, &network_post_fix, &pool_address); + let result = PaymentVerifier::check_closeness_match(&candidates, &network, &pool_address); assert!( - result_post_fix.is_ok(), - "POST-FIX: K=32 storer must accept the same honest pool: {result_post_fix:?}" + result.is_ok(), + "honest pool fully inside the lookup window must pass: {result:?}" ); } #[test] - fn closeness_match_rejects_forged_pool_at_k_32() { - // Security floor regression: a fully-forged pool whose candidate - // PeerIds are network-disjoint must still be rejected at the - // wider window K=32. The 13/16 threshold is the security knob; - // widening the lookup window must not soften it. - // - // Tag bytes 100..=115 are deliberately disjoint from the network - // set (1..=32). + fn closeness_match_rejects_forged_pool() { + // Security floor: a fully-forged pool whose candidate PeerIds are + // disjoint from the network's returned closest peers must be + // rejected. The lowered majority threshold must NOT let off-network + // fabrications pass — every counted candidate has to be a peer the + // live DHT actually returned. let forged_candidates: Vec = (100..=115).map(synthetic_peer_id).collect(); let network: Vec = (1..=32).map(synthetic_peer_id).collect(); let pool_address = [0u8; 32]; @@ -3049,42 +3094,43 @@ mod tests { "expected forged-pool rejection message, got: {msg}" ); } - other => panic!( - "forged pool with all candidates outside network's top-32 \ - must be rejected at K=32 (security floor): {other:?}" - ), + other => { + panic!("forged pool disjoint from the network set must be rejected: {other:?}") + } } } #[test] - fn closeness_match_rejects_pool_at_exactly_12_of_16_match() { - // Threshold sanity: a pool with exactly 12 of 16 candidates in - // the network set must still be rejected (12 < 13). - let mut candidates = synthetic_peer_ids(12); - candidates.extend((100..=103).map(synthetic_peer_id)); // 4 disjoint + fn closeness_match_rejects_pool_below_majority() { + // Threshold sanity: 8 candidates are exact matches (tags 1..=8) and + // the other 8 are off-network fabrications (tags 100..=107). 8 < 9 + // → reject. + let mut candidates = synthetic_peer_ids(8); + candidates.extend((100..=107).map(synthetic_peer_id)); // 8 fabrications let network: Vec = (1..=32).map(synthetic_peer_id).collect(); let pool_address = [0u8; 32]; let result = PaymentVerifier::check_closeness_match(&candidates, &network, &pool_address); assert!( result.is_err(), - "12/16 < threshold of 13/16 must reject regardless of K: {result:?}" + "8 matches < majority of 9/16 must reject: {result:?}" ); } #[test] - fn closeness_match_accepts_pool_at_exactly_13_of_16_match() { - // Threshold sanity: a pool with exactly 13 of 16 candidates in - // the network set must pass (13 ≥ 13). - let mut candidates = synthetic_peer_ids(13); - candidates.extend((100..=102).map(synthetic_peer_id)); // 3 disjoint + fn closeness_match_accepts_at_exactly_majority() { + // Threshold sanity: exactly 9 candidates are exact matches (tags + // 1..=9), the other 7 are off-network fabrications (tags 100..=106). + // 9 ≥ 9 → accept. + let mut candidates = synthetic_peer_ids(9); + candidates.extend((100..=106).map(synthetic_peer_id)); // 7 fabrications let network: Vec = (1..=32).map(synthetic_peer_id).collect(); let pool_address = [0u8; 32]; let result = PaymentVerifier::check_closeness_match(&candidates, &network, &pool_address); assert!( result.is_ok(), - "13/16 ≥ threshold of 13/16 must accept: {result:?}" + "9/16 ≥ majority threshold must accept: {result:?}" ); } @@ -3095,14 +3141,14 @@ mod tests { // candidate set can't pass because the storer doesn't have an // authoritative view to compare against. let candidates = synthetic_peer_ids(16); - let network = synthetic_peer_ids(12); // < CANDIDATE_CLOSENESS_REQUIRED + let network = synthetic_peer_ids(8); // < CANDIDATE_CLOSENESS_REQUIRED (9) let pool_address = [0u8; 32]; let result = PaymentVerifier::check_closeness_match(&candidates, &network, &pool_address); match result { Err(Error::Payment(msg)) => { assert!( - msg.contains("authoritative DHT lookup returned only 12"), + msg.contains("authoritative DHT lookup returned only 8"), "expected sparse-DHT error message, got: {msg}" ); } diff --git a/src/storage/handler.rs b/src/storage/handler.rs index d269aea..7b7cc8b 100644 --- a/src/storage/handler.rs +++ b/src/storage/handler.rs @@ -74,11 +74,16 @@ impl AntProtocol { payment_verifier: Arc, quote_generator: Arc, ) -> Self { - // Keep the PaymentVerifier's freshness gate wired to the same - // authoritative store used by this protocol handler. Attaching here + // Keep the PaymentVerifier's freshness gate AND the QuoteGenerator's + // pricing wired to the same authoritative store used by this protocol + // handler. Pricing and the freshness gate MUST read the same record + // count: the generator prices a quote from current_chunks() and the + // verifier later checks the quote against current_chunks(), so the only + // difference they see is genuine in-flight growth. Attaching both here // makes the invariant automatic for every AntProtocol construction // path, including tests and future startup variants. payment_verifier.attach_storage(Arc::clone(&storage)); + quote_generator.attach_storage(Arc::clone(&storage)); Self { storage, @@ -263,10 +268,11 @@ impl AntProtocol { Ok(_) => { let content_len = request.content.len(); info!("Stored chunk {addr_hex} ({content_len} bytes)"); - // Increment the close-records counter consumed by calculate_price. - // The PaymentVerifier reads its current record count directly - // from LmdbStorage::current_chunks(), so we no longer need to - // push the value through a side counter here. + // Bump the in-memory fallback counter. Both pricing and the + // freshness gate now read LmdbStorage::current_chunks() directly, + // so this counter only matters when no storage is attached + // (unit tests / mis-configured startup). Kept warm so that + // fallback path stays roughly accurate. self.quote_generator.record_store(); // 6. Notify replication engine for fresh fan-out.