diff --git a/src/payment/verifier.rs b/src/payment/verifier.rs
index 6774fbac..4eefb989 100644
--- a/src/payment/verifier.rs
+++ b/src/payment/verifier.rs
@@ -162,6 +162,12 @@ pub struct PaymentVerifier {
/// [`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>,
+ /// Test-only override for this node's own peer ID, used by
+ /// `validate_quote_freshness` to pick out the node's own quote from the
+ /// payment bundle. Production code derives it from the attached
+ /// [`P2PNode`]; set via [`Self::set_peer_id_for_tests`] so unit tests can
+ /// drive the freshness logic without wiring a real `P2PNode`.
+ test_peer_id_override: RwLock >,
/// Configuration.
config: PaymentVerifierConfig,
}
@@ -280,6 +286,7 @@ impl PaymentVerifier {
p2p_node: RwLock::new(None),
storage: RwLock::new(None),
test_records_override: RwLock::new(None),
+ test_peer_id_override: RwLock::new(None),
config,
}
}
@@ -318,6 +325,30 @@ impl PaymentVerifier {
*self.test_records_override.write() = Some(count);
}
+ /// Test-only setter for the node's own peer ID used by the quote
+ /// freshness check. Lets unit tests mark which quote in a payment bundle
+ /// is "ours" without wiring a real `P2PNode`. Has no effect in production
+ /// code because production code is expected to call
+ /// [`Self::attach_p2p_node`] instead.
+ #[cfg(any(test, feature = "test-utils"))]
+ pub fn set_peer_id_for_tests(&self, peer_id_bytes: [u8; 32]) {
+ *self.test_peer_id_override.write() = Some(peer_id_bytes);
+ }
+
+ /// Snapshot this node's own peer ID for the quote freshness check.
+ ///
+ /// Prefers the attached [`P2PNode`] (authoritative). 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 the freshness gate rather than rejecting — the
+ /// same fail-open posture as a missing record-count source.
+ fn self_peer_id_bytes(&self) -> Option<[u8; 32]> {
+ if let Some(node) = self.p2p_node.read().as_ref() {
+ return Some(*node.peer_id().as_bytes());
+ }
+ *self.test_peer_id_override.read()
+ }
+
/// Snapshot the current record count for freshness comparisons.
///
/// Prefers the attached `LmdbStorage` (authoritative — covers client PUTs,
@@ -647,6 +678,26 @@ impl PaymentVerifier {
/// 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`].
+ ///
+ /// **Only this node's own quote is gated.** A bundle contains one quote
+ /// per close-group peer, and fullness across a close group is wildly
+ /// heterogeneous on a real network (a freshly joined node holds tens of
+ /// records while an established neighbour holds thousands). Comparing a
+ /// *neighbour's* quote price against *this node's* record count therefore
+ /// rejects honest payments whenever the group spans more than the
+ /// tolerance — on ant-prod-01 a close group spanning 47..=1788 records
+ /// made the three fullest nodes reject every bundle containing the
+ /// emptiest node's (perfectly fresh, 10-second-old) quote, failing the
+ /// PUT after the client had already paid on-chain. The node can only
+ /// re-derive *its own* price from its own record count, so its own quote
+ /// is the only one it can legitimately call stale. Replay of another
+ /// node's old cheap quote is that node's gate to enforce when the PUT
+ /// reaches it; the on-chain median payment binding is unaffected either
+ /// way.
+ ///
+ /// A bundle holds at most one quote per peer — [`Self::validate_quote_structure`]
+ /// rejects duplicate peer IDs and runs before this gate on every path —
+ /// so the loop below matches at most one own quote.
fn validate_quote_freshness(&self, payment: &ProofOfPayment) -> Result<()> {
let Some(current_records) = self.current_records_stored() else {
debug!(
@@ -656,6 +707,14 @@ impl PaymentVerifier {
return Ok(());
};
+ let Some(self_peer_id) = self.self_peer_id_bytes() else {
+ debug!(
+ "PaymentVerifier: no self peer-id source attached; skipping \
+ quote price-staleness check"
+ );
+ return Ok(());
+ };
+
// 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).
@@ -664,18 +723,46 @@ impl PaymentVerifier {
100u64.saturating_sub(QUOTE_PRICE_STALENESS_PCT_TOLERANCE),
)) / Amount::from(100u64);
+ let mut own_quote_seen = false;
for (encoded_peer_id, quote) in &payment.peer_quotes {
+ if encoded_peer_id.as_bytes() != &self_peer_id {
+ // A neighbour's quote prices the *neighbour's* fullness; this
+ // node has no basis to judge it against its own record count.
+ continue;
+ }
+ own_quote_seen = true;
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: paid price encodes \
+ "Own quote {encoded_peer_id:?} stale: quoted price encodes \
{quoted_records} records but node currently holds {current_records} \
- (paid {}, minimum acceptable {min_acceptable_price} at \
+ (quoted {}, minimum acceptable {min_acceptable_price} at \
{QUOTE_PRICE_STALENESS_PCT_TOLERANCE}% under-payment tolerance)",
quote.price
)));
}
}
+
+ // Two self-identity notions coexist in this verifier and are expected
+ // to refer to the same node: `validate_local_recipient` matches "us"
+ // by rewards address, this gate by peer ID. They legitimately diverge
+ // when a PUT reaches a node whose own quote isn't in the bundle but
+ // whose rewards address is shared with a quoted sibling (common in
+ // fleet deployments). The gate fail-opens in that case — leave a
+ // breadcrumb, because a silent no-op is exactly what makes a
+ // production incident hard to reconstruct from node logs.
+ if !own_quote_seen {
+ let our_rewards_address_quoted = payment
+ .peer_quotes
+ .iter()
+ .any(|(_, quote)| quote.rewards_address == self.config.local_rewards_address);
+ if our_rewards_address_quoted {
+ debug!(
+ "PaymentVerifier: bundle contains our rewards address but no quote \
+ under our peer ID; skipping quote price-staleness check"
+ );
+ }
+ }
Ok(())
}
@@ -1814,6 +1901,8 @@ mod tests {
let verifier = create_test_verifier();
// Node gained 10 records since quoting (100 -> 110).
verifier.set_records_stored_for_tests(110);
+ let self_id: [u8; 32] = rand::random();
+ verifier.set_peer_id_for_tests(self_id);
let quote = make_fake_quote_at_records(
[0xE0u8; 32],
SystemTime::now(),
@@ -1821,7 +1910,7 @@ mod tests {
100,
);
let payment = ProofOfPayment {
- peer_quotes: vec![(EncodedPeerId::new(rand::random()), quote)],
+ peer_quotes: vec![(EncodedPeerId::new(self_id), quote)],
};
verifier
@@ -1840,6 +1929,8 @@ mod tests {
let verifier = create_test_verifier();
// Quote priced at 6000 records, but node now holds only 100.
verifier.set_records_stored_for_tests(100);
+ let self_id: [u8; 32] = rand::random();
+ verifier.set_peer_id_for_tests(self_id);
let quote = make_fake_quote_at_records(
[0xE2u8; 32],
SystemTime::now(),
@@ -1847,7 +1938,7 @@ mod tests {
6000,
);
let payment = ProofOfPayment {
- peer_quotes: vec![(EncodedPeerId::new(rand::random()), quote)],
+ peer_quotes: vec![(EncodedPeerId::new(self_id), quote)],
};
verifier
@@ -1865,6 +1956,8 @@ mod tests {
let verifier = create_test_verifier();
verifier.set_records_stored_for_tests(6000);
+ let self_id: [u8; 32] = rand::random();
+ verifier.set_peer_id_for_tests(self_id);
let quote = make_fake_quote_at_records(
[0xE1u8; 32],
SystemTime::now(),
@@ -1872,7 +1965,7 @@ mod tests {
100,
);
let payment = ProofOfPayment {
- peer_quotes: vec![(EncodedPeerId::new(rand::random()), quote)],
+ peer_quotes: vec![(EncodedPeerId::new(self_id), quote)],
};
let err = verifier
@@ -1881,6 +1974,98 @@ mod tests {
assert!(format!("{err}").contains("stale"));
}
+ /// Regression test for the PROD-UL-01 `DataMap` failure (2026-06-04): a
+ /// close group whose fullness spans 47..=1788 records produces a bundle
+ /// where the emptiest node's honest quote prices far below a full node's
+ /// 75% floor. The verifying node must gate only its OWN quote — a
+ /// neighbour's cheap-but-honest quote is not evidence of staleness.
+ #[test]
+ fn test_neighbour_cheap_quote_not_rejected() {
+ use evmlib::{EncodedPeerId, RewardsAddress};
+
+ let verifier = create_test_verifier();
+ // This node holds 1788 records (the fullest rejector in the incident).
+ verifier.set_records_stored_for_tests(1788);
+ let self_id: [u8; 32] = rand::random();
+ verifier.set_peer_id_for_tests(self_id);
+
+ let xorname = [0xE3u8; 32];
+ let rewards = RewardsAddress::new([1u8; 20]);
+ // Own quote is fresh: priced at our own current fullness.
+ let own_quote = make_fake_quote_at_records(xorname, SystemTime::now(), rewards, 1788);
+ // Neighbour quotes from a heterogeneous close group, including a
+ // nearly-empty node at 47 records (price far below our 75% floor).
+ let neighbour_47 = make_fake_quote_at_records(xorname, SystemTime::now(), rewards, 47);
+ let neighbour_978 = make_fake_quote_at_records(xorname, SystemTime::now(), rewards, 978);
+
+ let payment = ProofOfPayment {
+ peer_quotes: vec![
+ (EncodedPeerId::new(rand::random()), neighbour_47),
+ (EncodedPeerId::new(self_id), own_quote),
+ (EncodedPeerId::new(rand::random()), neighbour_978),
+ ],
+ };
+
+ verifier
+ .validate_quote_freshness(&payment)
+ .expect("neighbours' cheaper quotes must not trip this node's own staleness gate");
+ }
+
+ /// The own-quote gate still bites: if THIS node's own quote in the bundle
+ /// underprices its current fullness beyond tolerance, the payment is
+ /// rejected even when every neighbour quote looks expensive.
+ #[test]
+ fn test_own_stale_quote_still_rejected_among_neighbours() {
+ use evmlib::{EncodedPeerId, RewardsAddress};
+
+ let verifier = create_test_verifier();
+ verifier.set_records_stored_for_tests(6000);
+ let self_id: [u8; 32] = rand::random();
+ verifier.set_peer_id_for_tests(self_id);
+
+ let xorname = [0xE4u8; 32];
+ let rewards = RewardsAddress::new([1u8; 20]);
+ let own_stale = make_fake_quote_at_records(xorname, SystemTime::now(), rewards, 100);
+ let neighbour = make_fake_quote_at_records(xorname, SystemTime::now(), rewards, 7000);
+
+ let payment = ProofOfPayment {
+ peer_quotes: vec![
+ (EncodedPeerId::new(rand::random()), neighbour),
+ (EncodedPeerId::new(self_id), own_stale),
+ ],
+ };
+
+ let err = verifier
+ .validate_quote_freshness(&payment)
+ .expect_err("own underpriced quote must still be rejected");
+ assert!(format!("{err}").contains("stale"));
+ }
+
+ /// Without a self peer-id source (no `P2PNode` attached, no test override)
+ /// the gate skips rather than rejecting — mirroring the missing
+ /// record-count-source behaviour.
+ #[test]
+ fn test_freshness_skipped_without_self_peer_id() {
+ use evmlib::{EncodedPeerId, RewardsAddress};
+
+ let verifier = create_test_verifier();
+ verifier.set_records_stored_for_tests(6000);
+ // NOTE: no set_peer_id_for_tests call.
+ let quote = make_fake_quote_at_records(
+ [0xE5u8; 32],
+ SystemTime::now(),
+ RewardsAddress::new([1u8; 20]),
+ 100,
+ );
+ let payment = ProofOfPayment {
+ peer_quotes: vec![(EncodedPeerId::new(rand::random()), quote)],
+ };
+
+ verifier
+ .validate_quote_freshness(&payment)
+ .expect("gate must fail open when self identity is unknown");
+ }
+
/// 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};