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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions src/payment/pricing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<u64>()
}

/// Calculate storage price in wei from the number of close records stored.
///
/// Formula: `price_wei = BASELINE + n² × K / D²`
Expand Down Expand Up @@ -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
);
}
}
173 changes: 116 additions & 57 deletions src/payment/verifier.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand All @@ -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.
///
Expand All @@ -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.
///
Expand Down Expand Up @@ -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<Option<Arc<P2PNode>>>,
/// 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,
}
Expand Down Expand Up @@ -255,6 +259,7 @@ impl PaymentVerifier {
closeness_pass_cache,
inflight_closeness,
p2p_node: RwLock::new(None),
records_stored: AtomicU64::new(0),
config,
}
}
Expand All @@ -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}");
}
Comment thread
dirvine marked this conversation as resolved.

/// Check if payment is required for the given `XorName`.
///
/// This is the main entry point for payment verification:
Expand Down Expand Up @@ -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)?;

Expand Down Expand Up @@ -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(())
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<u8> {
use crate::payment::proof::{serialize_single_node_proof, PaymentProof};
Expand All @@ -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;

Expand All @@ -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;

Expand All @@ -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}"
);
}

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

Expand All @@ -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}"
);
}

Expand Down
2 changes: 2 additions & 0 deletions src/storage/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading