From 77496925fafb7d49ac14096e0a6e5b1f0f79f5c4 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 28 May 2026 20:02:21 +0100 Subject: [PATCH 1/3] fix(payment): validate quote freshness by storage delta --- src/payment/pricing.rs | 38 +++++++++ src/payment/verifier.rs | 173 +++++++++++++++++++++++++++------------- src/storage/handler.rs | 2 + 3 files changed, 156 insertions(+), 57 deletions(-) diff --git a/src/payment/pricing.rs b/src/payment/pricing.rs index 5e50c98..74ffa11 100644 --- a/src/payment/pricing.rs +++ b/src/payment/pricing.rs @@ -42,6 +42,27 @@ const PRICE_BASELINE_WEI: u128 = 3_906_250_000_000_000; /// `0.03515625 ANT × 10¹⁸ wei/ANT = 35_156_250_000_000_000 wei`. const PRICE_COEFFICIENT_WEI: u128 = 35_156_250_000_000_000; +/// Price increment per squared record after simplifying `PRICE_COEFFICIENT_WEI / DIVISOR_SQUARED`. +const PRICE_PER_RECORD_SQUARED_WEI: u128 = PRICE_COEFFICIENT_WEI / DIVISOR_SQUARED; + +/// Derive the quoted record count from a quote price. +/// +/// This is the inverse of [`calculate_price`] and is used to validate quote +/// freshness without relying on wall-clock timestamps. It intentionally floors +/// to the nearest integer record count, matching the existing storage-delta +/// tolerance behaviour. +#[must_use] +pub fn derive_records_stored_from_price(price: Amount) -> u64 { + let baseline = Amount::from(PRICE_BASELINE_WEI); + if price <= baseline { + return 0; + } + + let excess = price - baseline; + let n_squared = excess / Amount::from(PRICE_PER_RECORD_SQUARED_WEI); + n_squared.root(2).to::() +} + /// Calculate storage price in wei from the number of close records stored. /// /// Formula: `price_wei = BASELINE + n² × K / D²` @@ -186,4 +207,21 @@ mod tests { assert!(price < Amount::from(WEI_PER_TOKEN)); // well below 1 ANT assert!(price > Amount::from(PRICE_BASELINE_WEI)); // strictly above baseline } + + #[test] + fn test_derive_records_stored_from_price_round_trips() { + for records in [0usize, 1, 5, 100, 6_000, 12_000, 60_000] { + let price = calculate_price(records); + assert_eq!(derive_records_stored_from_price(price), records as u64); + } + } + + #[test] + fn test_derive_records_stored_from_baseline_or_lower_is_zero() { + assert_eq!(derive_records_stored_from_price(Amount::ZERO), 0); + assert_eq!( + derive_records_stored_from_price(Amount::from(PRICE_BASELINE_WEI)), + 0 + ); + } } diff --git a/src/payment/verifier.rs b/src/payment/verifier.rs index 56328fa..05e2964 100644 --- a/src/payment/verifier.rs +++ b/src/payment/verifier.rs @@ -7,6 +7,7 @@ use crate::ant_protocol::CLOSE_GROUP_SIZE; use crate::error::{Error, Result}; use crate::logging::{debug, info}; use crate::payment::cache::{CacheStats, VerifiedCache, XorName}; +use crate::payment::pricing::derive_records_stored_from_price; use crate::payment::proof::{ deserialize_merkle_proof, deserialize_proof, detect_proof_type, ProofType, }; @@ -24,8 +25,8 @@ use saorsa_core::identity::node_identity::peer_id_from_public_key_bytes; use saorsa_core::identity::PeerId; use saorsa_core::P2PNode; use std::num::NonZeroUsize; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; -use std::time::{Duration, SystemTime}; /// Minimum allowed size for a payment proof in bytes. /// @@ -41,16 +42,15 @@ 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; -/// Maximum age of a payment quote before it's considered expired (24 hours). -/// Prevents replaying old cheap quotes against nearly-full nodes. Past-side -/// clock skew is absorbed entirely by this window — there is no separate -/// past-skew tolerance. -const QUOTE_MAX_AGE_SECS: u64 = 86_400; +/// 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; -/// Maximum tolerated forward skew when a quote's timestamp is ahead of the -/// verifying node's wall clock (300 seconds). Applies exclusively to the -/// future direction; past-dated quotes are governed by `QUOTE_MAX_AGE_SECS`. -const QUOTE_FUTURE_SKEW_TOLERANCE_SECS: u64 = 300; +/// 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; /// Configuration for EVM payment verification. /// @@ -139,6 +139,10 @@ pub struct PaymentVerifier { /// midpoint in the live DHT. `None` in unit tests that don't exercise /// merkle verification; production startup MUST call [`attach_p2p_node`]. p2p_node: RwLock>>, + /// Current number of records stored by this node. Updated by the node as it + /// stores new data. Used for storage-delta freshness checks on incoming + /// quotes, replacing the wall-clock dependency. + records_stored: AtomicU64, /// Configuration. config: PaymentVerifierConfig, } @@ -255,6 +259,7 @@ impl PaymentVerifier { closeness_pass_cache, inflight_closeness, p2p_node: RwLock::new(None), + records_stored: AtomicU64::new(0), config, } } @@ -272,6 +277,16 @@ impl PaymentVerifier { debug!("PaymentVerifier: P2PNode attached for merkle closeness checks"); } + /// Update the current number of records stored by this node. + /// + /// Called by the node whenever a new record is stored. The value is used + /// for storage-delta freshness checks on incoming quotes, removing the + /// wall-clock dependency for quote validation. + pub fn set_records_stored(&self, count: u64) { + self.records_stored.store(count, Ordering::Relaxed); + debug!("PaymentVerifier: records_stored updated to {count}"); + } + /// Check if payment is required for the given `XorName`. /// /// This is the main entry point for payment verification: @@ -459,7 +474,7 @@ impl PaymentVerifier { Self::validate_quote_structure(payment)?; Self::validate_quote_content(payment, xorname)?; - Self::validate_quote_timestamps(payment)?; + self.validate_quote_freshness(payment)?; Self::validate_peer_bindings(payment)?; self.validate_local_recipient(payment)?; @@ -550,37 +565,29 @@ impl PaymentVerifier { Ok(()) } - /// Verify quote freshness — reject stale quotes and ones too far in the future. + /// Verify quote freshness using storage-delta inferred from price, not wall-clock time. /// - /// A quote whose timestamp is in the past is accepted as long as its age - /// does not exceed `QUOTE_MAX_AGE_SECS`. A quote whose timestamp is in - /// the future relative to this node is accepted only if the forward skew - /// does not exceed `QUOTE_FUTURE_SKEW_TOLERANCE_SECS`. - fn validate_quote_timestamps(payment: &ProofOfPayment) -> Result<()> { - let now = SystemTime::now(); - let max_age = Duration::from_secs(QUOTE_MAX_AGE_SECS); - let max_future_skew = Duration::from_secs(QUOTE_FUTURE_SKEW_TOLERANCE_SECS); + /// 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. + fn validate_quote_freshness(&self, payment: &ProofOfPayment) -> Result<()> { + let current_records = self.records_stored.load(Ordering::Relaxed); for (encoded_peer_id, quote) in &payment.peer_quotes { - match now.duration_since(quote.timestamp) { - Ok(age) => { - if age > max_age { - return Err(Error::Payment(format!( - "Quote from peer {encoded_peer_id:?} expired: age {}s exceeds max {QUOTE_MAX_AGE_SECS}s", - age.as_secs() - ))); - } - } - Err(future) => { - let skew = future.duration(); - if skew > max_future_skew { - return Err(Error::Payment(format!( - "Quote from peer {encoded_peer_id:?} has timestamp {}s in the future \ - (exceeds {QUOTE_FUTURE_SKEW_TOLERANCE_SECS}s tolerance)", - skew.as_secs() - ))); - } - } + 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); + + if delta > tolerance { + return Err(Error::Payment(format!( + "Quote from peer {encoded_peer_id:?} stale by {delta} records \ + (quoted {quoted_records} vs current {current_records}, tolerance {tolerance})" + ))); } } Ok(()) @@ -1310,6 +1317,7 @@ impl PaymentVerifier { mod tests { use super::*; use evmlib::merkle_payments::MerklePaymentCandidatePool; + use std::time::SystemTime; /// Create a verifier for unit tests. EVM is always on, but tests can /// pre-populate the cache to bypass on-chain verification. @@ -1680,6 +1688,63 @@ mod tests { } } + /// Helper: create a fake quote whose price encodes the supplied record count. + fn make_fake_quote_at_records( + xorname: [u8; 32], + timestamp: SystemTime, + rewards_address: RewardsAddress, + records: usize, + ) -> evmlib::PaymentQuote { + let mut quote = make_fake_quote(xorname, timestamp, rewards_address); + quote.price = crate::payment::pricing::calculate_price(records); + quote + } + + #[test] + fn test_storage_delta_within_tolerance_accepted() { + use evmlib::{EncodedPeerId, RewardsAddress}; + + let verifier = create_test_verifier(); + verifier.set_records_stored(105); + let xorname = [0xE0u8; 32]; + let quote = make_fake_quote_at_records( + xorname, + SystemTime::now(), + RewardsAddress::new([1u8; 20]), + 100, + ); + let payment = ProofOfPayment { + peer_quotes: vec![(EncodedPeerId::new(rand::random()), quote)], + }; + + verifier + .validate_quote_freshness(&payment) + .expect("delta within tolerance should pass"); + } + + #[test] + fn test_storage_delta_exceeds_tolerance_rejected() { + use evmlib::{EncodedPeerId, RewardsAddress}; + + let verifier = create_test_verifier(); + verifier.set_records_stored(107); + let xorname = [0xE1u8; 32]; + let quote = make_fake_quote_at_records( + xorname, + SystemTime::now(), + RewardsAddress::new([1u8; 20]), + 100, + ); + let payment = ProofOfPayment { + peer_quotes: vec![(EncodedPeerId::new(rand::random()), quote)], + }; + + let err = verifier + .validate_quote_freshness(&payment) + .expect_err("delta beyond tolerance should fail"); + assert!(format!("{err}").contains("stale by 7 records")); + } + /// Helper: wrap quotes into a tagged serialized `PaymentProof`. fn serialize_proof(peer_quotes: Vec<(evmlib::EncodedPeerId, evmlib::PaymentQuote)>) -> Vec { use crate::payment::proof::{serialize_single_node_proof, PaymentProof}; @@ -1692,7 +1757,7 @@ mod tests { } #[tokio::test] - async fn test_expired_quote_rejected() { + async fn test_old_quote_uses_storage_delta_not_timestamp() { use evmlib::{EncodedPeerId, RewardsAddress}; use std::time::Duration; @@ -1712,16 +1777,15 @@ mod tests { let proof_bytes = serialize_proof(peer_quotes); let result = verifier.verify_payment(&xorname, Some(&proof_bytes)).await; - assert!(result.is_err(), "Should reject expired quote"); - let err_msg = format!("{}", result.expect_err("should fail")); + let err_msg = format!("{}", result.expect_err("should fail at later check")); assert!( - err_msg.contains("expired"), - "Error should mention 'expired': {err_msg}" + !err_msg.contains("expired"), + "Should not reject by timestamp age: {err_msg}" ); } #[tokio::test] - async fn test_future_timestamp_rejected() { + async fn test_future_quote_uses_storage_delta_not_timestamp() { use evmlib::{EncodedPeerId, RewardsAddress}; use std::time::Duration; @@ -1741,11 +1805,10 @@ mod tests { let proof_bytes = serialize_proof(peer_quotes); let result = verifier.verify_payment(&xorname, Some(&proof_bytes)).await; - assert!(result.is_err(), "Should reject future-timestamped quote"); - let err_msg = format!("{}", result.expect_err("should fail")); + let err_msg = format!("{}", result.expect_err("should fail at later check")); assert!( - err_msg.contains("future"), - "Error should mention 'future': {err_msg}" + !err_msg.contains("future"), + "Should not reject by future timestamp: {err_msg}" ); } @@ -1779,7 +1842,7 @@ mod tests { } #[tokio::test] - async fn test_quote_just_beyond_clock_skew_tolerance_rejected() { + async fn test_quote_beyond_clock_skew_still_uses_storage_delta() { use evmlib::{EncodedPeerId, RewardsAddress}; use std::time::Duration; @@ -1799,14 +1862,10 @@ mod tests { let proof_bytes = serialize_proof(peer_quotes); let result = verifier.verify_payment(&xorname, Some(&proof_bytes)).await; + let err_msg = format!("{}", result.expect_err("should fail at later check")); assert!( - result.is_err(), - "Should reject quote beyond clock skew tolerance" - ); - let err_msg = format!("{}", result.expect_err("should fail")); - assert!( - err_msg.contains("future"), - "Error should mention 'future': {err_msg}" + !err_msg.contains("future"), + "Should not reject by future timestamp: {err_msg}" ); } diff --git a/src/storage/handler.rs b/src/storage/handler.rs index 38d17e4..063da97 100644 --- a/src/storage/handler.rs +++ b/src/storage/handler.rs @@ -259,6 +259,8 @@ impl AntProtocol { info!("Stored chunk {addr_hex} ({content_len} bytes)"); // Increment the close-records counter consumed by calculate_price. self.quote_generator.record_store(); + self.payment_verifier + .set_records_stored(self.quote_generator.records_stored() as u64); // 6. Notify replication engine for fresh fan-out. // Only emit when a real proof is present — cached-as-verified From 33db86aa6cda8635f05e83a3e4768679dd764e12 Mon Sep 17 00:00:00 2001 From: grumbach Date: Fri, 29 May 2026 15:46:09 +0900 Subject: [PATCH 2/3] fix(payment): no-panic price inverse + authoritative record count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two blockers raised against the storage-delta freshness gate introduced in #120: 1. Pre-signature panic on oversized prices derive_records_stored_from_price() ended with `n_squared.root(2).to::()`, which panics in ruint on overflow. validate_quote_freshness runs BEFORE validate_peer_bindings() and verify_quote_signature(), so a deserialized proof carrying a huge `quote.price` could crash the verifier without needing any valid signature. Saturate to u64::MAX instead — the delta check rejects the quote as out-of-range without aborting the process. Adds a regression test (Amount::MAX must not panic). 2. records_stored counter wasn't authoritative The previous side counter: - started at 0 on construction - was never seeded from LMDB on startup - was only updated on a single successful client PUT path - was bypassed on replication stores (handle_fresh_offer + execute_single_fetch) and on prune deletes Net: a freshly started or restarted verifier rejected any non-trivial quote, and long-running nodes drifted away from reality. This drops the side counter entirely. PaymentVerifier holds an attached Arc and reads `current_chunks()` directly on each freshness check. That count is authoritative regardless of which path stored or removed the record. `current_chunks()` is an O(1) B-tree page-header read — cheap enough to run per-quote. Test harnesses that don't wire a real storage use `set_records_stored_for_tests(n)` to drive the freshness logic. Production startup (node.rs, devnet.rs) calls `attach_storage` right after the storage and verifier Arcs are created. Also fixes the verifier doc comment that still said "Quote timestamps are fresh" after the wall-clock gate was removed (left untouched here; will follow as a separate doc nit if requested). cargo build, cargo clippy --all-features -- -D warnings, and cargo test --lib all pass (496 passing). --- src/devnet.rs | 11 ++++- src/node.rs | 15 ++++--- src/payment/pricing.rs | 28 ++++++++++++- src/payment/verifier.rs | 93 +++++++++++++++++++++++++++++++++-------- src/storage/handler.rs | 5 ++- 5 files changed, 125 insertions(+), 27 deletions(-) diff --git a/src/devnet.rs b/src/devnet.rs index d58647c..3b2a910 100644 --- a/src/devnet.rs +++ b/src/devnet.rs @@ -563,9 +563,16 @@ impl Devnet { crate::payment::wire_ml_dsa_signer(&mut quote_generator, identity) .map_err(|e| DevnetError::Startup(format!("Failed to wire ML-DSA-65 signer: {e}")))?; + let storage = Arc::new(storage); + let payment_verifier = Arc::new(payment_verifier); + + // Attach storage so storage-delta freshness checks query the + // authoritative on-disk count. + payment_verifier.attach_storage(Arc::clone(&storage)); + Ok(AntProtocol::new( - Arc::new(storage), - Arc::new(payment_verifier), + storage, + payment_verifier, Arc::new(quote_generator), )) } diff --git a/src/node.rs b/src/node.rs index ebee324..2a30bb3 100644 --- a/src/node.rs +++ b/src/node.rs @@ -398,11 +398,16 @@ impl NodeBuilder { // This same signer is used for both regular quotes and merkle candidate quotes. crate::payment::wire_ml_dsa_signer(&mut quote_generator, identity)?; - let protocol = AntProtocol::new( - Arc::new(storage), - Arc::new(payment_verifier), - Arc::new(quote_generator), - ); + let storage = Arc::new(storage); + let payment_verifier = Arc::new(payment_verifier); + + // Give the verifier a handle to the on-disk record store so its + // storage-delta freshness check reads the authoritative count instead + // of relying on a side counter that may drift across replication, + // repair, and prune paths. + payment_verifier.attach_storage(Arc::clone(&storage)); + + let protocol = AntProtocol::new(storage, payment_verifier, Arc::new(quote_generator)); info!( "ANT protocol handler initialized with ML-DSA-65 signing (protocol={CHUNK_PROTOCOL_ID})" diff --git a/src/payment/pricing.rs b/src/payment/pricing.rs index 74ffa11..f8a829e 100644 --- a/src/payment/pricing.rs +++ b/src/payment/pricing.rs @@ -51,6 +51,12 @@ const PRICE_PER_RECORD_SQUARED_WEI: u128 = PRICE_COEFFICIENT_WEI / DIVISOR_SQUAR /// freshness without relying on wall-clock timestamps. It intentionally floors /// to the nearest integer record count, matching the existing storage-delta /// tolerance behaviour. +/// +/// Saturates to `u64::MAX` for any price that would otherwise overflow `u64`. +/// This matters because the verifier calls this on untrusted deserialized +/// `quote.price` values BEFORE signature verification: a panic here is a +/// pre-auth crash vector. Saturating leaves the delta check to reject the +/// quote as out-of-range without aborting the process. #[must_use] pub fn derive_records_stored_from_price(price: Amount) -> u64 { let baseline = Amount::from(PRICE_BASELINE_WEI); @@ -60,7 +66,17 @@ pub fn derive_records_stored_from_price(price: Amount) -> u64 { let excess = price - baseline; let n_squared = excess / Amount::from(PRICE_PER_RECORD_SQUARED_WEI); - n_squared.root(2).to::() + let root = n_squared.root(2); + // ruint's `Uint::to::()` panics on overflow. We MUST NOT panic here: + // freshness runs on untrusted deserialized `quote.price` before signature + // verification, so a hostile oversized price would otherwise be a pre-auth + // crash vector. Saturate to `u64::MAX` instead; the delta check rejects + // out-of-range quotes. + if root > Amount::from(u64::MAX) { + u64::MAX + } else { + root.to::() + } } /// Calculate storage price in wei from the number of close records stored. @@ -224,4 +240,14 @@ mod tests { 0 ); } + + #[test] + fn test_derive_records_stored_from_max_price_saturates_no_panic() { + // Hostile/malformed quotes may carry an oversized U256 price. + // The verifier calls this BEFORE signature verification, so we MUST + // NOT panic on overflow — saturate to u64::MAX and let the delta + // check reject the quote. + let v = derive_records_stored_from_price(Amount::MAX); + assert_eq!(v, u64::MAX); + } } diff --git a/src/payment/verifier.rs b/src/payment/verifier.rs index 05e2964..8d151e7 100644 --- a/src/payment/verifier.rs +++ b/src/payment/verifier.rs @@ -5,13 +5,14 @@ use crate::ant_protocol::CLOSE_GROUP_SIZE; use crate::error::{Error, Result}; -use crate::logging::{debug, info}; +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::proof::{ deserialize_merkle_proof, deserialize_proof, detect_proof_type, ProofType, }; use crate::payment::single_node::SingleNodePayment; +use crate::storage::lmdb::LmdbStorage; use ant_protocol::payment::verify::{verify_quote_content, verify_quote_signature}; use evmlib::common::Amount; use evmlib::contract::payment_vault; @@ -25,7 +26,6 @@ use saorsa_core::identity::node_identity::peer_id_from_public_key_bytes; use saorsa_core::identity::PeerId; use saorsa_core::P2PNode; use std::num::NonZeroUsize; -use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; /// Minimum allowed size for a payment proof in bytes. @@ -139,10 +139,19 @@ pub struct PaymentVerifier { /// midpoint in the live DHT. `None` in unit tests that don't exercise /// merkle verification; production startup MUST call [`attach_p2p_node`]. p2p_node: RwLock>>, - /// Current number of records stored by this node. Updated by the node as it - /// stores new data. Used for storage-delta freshness checks on incoming - /// quotes, replacing the wall-clock dependency. - records_stored: AtomicU64, + /// LMDB storage handle, attached post-construction so the storage-delta + /// freshness check can read the authoritative on-disk record count without + /// depending on a side counter that may drift from replication/repair/prune + /// paths. `None` in unit tests that pre-set [`Self::test_records_override`]; + /// production startup MUST call [`attach_storage`]. + storage: RwLock>>, + /// Test-only override for the storage-delta freshness check. + /// + /// When `Some(n)`, `validate_quote_freshness` uses `n` as the current + /// record count instead of querying `storage.current_chunks()`. Set via + /// [`Self::set_records_stored_for_tests`] so unit tests that don't wire a + /// real `LmdbStorage` can still drive the freshness logic. + test_records_override: RwLock>, /// Configuration. config: PaymentVerifierConfig, } @@ -259,7 +268,8 @@ impl PaymentVerifier { closeness_pass_cache, inflight_closeness, p2p_node: RwLock::new(None), - records_stored: AtomicU64::new(0), + storage: RwLock::new(None), + test_records_override: RwLock::new(None), config, } } @@ -277,14 +287,48 @@ impl PaymentVerifier { debug!("PaymentVerifier: P2PNode attached for merkle closeness checks"); } - /// Update the current number of records stored by this node. + /// Attach the node's [`LmdbStorage`] handle so storage-delta freshness + /// checks can query the authoritative on-disk record count. /// - /// Called by the node whenever a new record is stored. The value is used - /// for storage-delta freshness checks on incoming quotes, removing the - /// wall-clock dependency for quote validation. - pub fn set_records_stored(&self, count: u64) { - self.records_stored.store(count, Ordering::Relaxed); - debug!("PaymentVerifier: records_stored updated to {count}"); + /// Production startup MUST call this once the storage exists; otherwise + /// `validate_quote_freshness` falls back to treating the current count as + /// zero, which will reject all non-trivial quotes. Idempotent: calling + /// twice replaces the handle. + pub fn attach_storage(&self, storage: Arc) { + *self.storage.write() = Some(storage); + debug!("PaymentVerifier: LmdbStorage attached for storage-delta freshness checks"); + } + + /// Test-only setter for the current record count used by storage-delta + /// freshness checks. Lets unit tests drive the freshness logic without + /// wiring a real `LmdbStorage`. Has no effect in production code because + /// production code is expected to call [`Self::attach_storage`] instead. + #[cfg(any(test, feature = "test-utils"))] + pub fn set_records_stored_for_tests(&self, count: u64) { + *self.test_records_override.write() = Some(count); + } + + /// Snapshot the current record count for freshness comparisons. + /// + /// Prefers the attached `LmdbStorage` (authoritative — covers client PUTs, + /// replication stores, repair fetches, and prune deletes by definition). + /// Falls back to a test override if one was set. Returns `None` only when + /// no source is available (mis-configured production startup); the caller + /// treats that as "unknown" and skips storage-delta gating rather than + /// rejecting all quotes outright. + fn current_records_stored(&self) -> Option { + if let Some(storage) = self.storage.read().as_ref() { + match storage.current_chunks() { + Ok(n) => return Some(n), + Err(e) => { + warn!( + "PaymentVerifier: failed to read current_chunks() for freshness check: {e}" + ); + return None; + } + } + } + *self.test_records_override.read() } /// Check if payment is required for the given `XorName`. @@ -571,8 +615,23 @@ impl PaymentVerifier { /// 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. + /// + /// 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 + /// rejecting every quote — see [`Self::current_records_stored`]. fn validate_quote_freshness(&self, payment: &ProofOfPayment) -> Result<()> { - let current_records = self.records_stored.load(Ordering::Relaxed); + let Some(current_records) = self.current_records_stored() else { + debug!( + "PaymentVerifier: no record-count source attached; skipping \ + storage-delta freshness check" + ); + return Ok(()); + }; for (encoded_peer_id, quote) in &payment.peer_quotes { let quoted_records = derive_records_stored_from_price(quote.price); @@ -1705,7 +1764,7 @@ mod tests { use evmlib::{EncodedPeerId, RewardsAddress}; let verifier = create_test_verifier(); - verifier.set_records_stored(105); + verifier.set_records_stored_for_tests(105); let xorname = [0xE0u8; 32]; let quote = make_fake_quote_at_records( xorname, @@ -1727,7 +1786,7 @@ mod tests { use evmlib::{EncodedPeerId, RewardsAddress}; let verifier = create_test_verifier(); - verifier.set_records_stored(107); + verifier.set_records_stored_for_tests(107); let xorname = [0xE1u8; 32]; let quote = make_fake_quote_at_records( xorname, diff --git a/src/storage/handler.rs b/src/storage/handler.rs index 063da97..345a174 100644 --- a/src/storage/handler.rs +++ b/src/storage/handler.rs @@ -258,9 +258,10 @@ impl AntProtocol { 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. self.quote_generator.record_store(); - self.payment_verifier - .set_records_stored(self.quote_generator.records_stored() as u64); // 6. Notify replication engine for fresh fan-out. // Only emit when a real proof is present — cached-as-verified From 7faac2353de28c600eebdc4bfae6a1a0816412e6 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 29 May 2026 10:57:26 +0100 Subject: [PATCH 3/3] fix(payment): attach verifier storage in protocol constructor --- src/devnet.rs | 4 ---- src/node.rs | 6 ------ src/storage/handler.rs | 6 ++++++ 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/src/devnet.rs b/src/devnet.rs index 3b2a910..3dede92 100644 --- a/src/devnet.rs +++ b/src/devnet.rs @@ -566,10 +566,6 @@ impl Devnet { let storage = Arc::new(storage); let payment_verifier = Arc::new(payment_verifier); - // Attach storage so storage-delta freshness checks query the - // authoritative on-disk count. - payment_verifier.attach_storage(Arc::clone(&storage)); - Ok(AntProtocol::new( storage, payment_verifier, diff --git a/src/node.rs b/src/node.rs index 2a30bb3..e63ec27 100644 --- a/src/node.rs +++ b/src/node.rs @@ -401,12 +401,6 @@ impl NodeBuilder { let storage = Arc::new(storage); let payment_verifier = Arc::new(payment_verifier); - // Give the verifier a handle to the on-disk record store so its - // storage-delta freshness check reads the authoritative count instead - // of relying on a side counter that may drift across replication, - // repair, and prune paths. - payment_verifier.attach_storage(Arc::clone(&storage)); - let protocol = AntProtocol::new(storage, payment_verifier, Arc::new(quote_generator)); info!( diff --git a/src/storage/handler.rs b/src/storage/handler.rs index 345a174..d269aea 100644 --- a/src/storage/handler.rs +++ b/src/storage/handler.rs @@ -74,6 +74,12 @@ 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 + // makes the invariant automatic for every AntProtocol construction + // path, including tests and future startup variants. + payment_verifier.attach_storage(Arc::clone(&storage)); + Self { storage, payment_verifier,