From 4774fc14bf09a7781b6badde601cc2690197e81a Mon Sep 17 00:00:00 2001 From: Warm Beer Date: Tue, 16 Jun 2026 12:24:51 +0200 Subject: [PATCH] fix(payment): accept paid quote issuers within K closest peers Use the Kademlia K width for single-node paid quote issuer locality instead of the storage close-group width, while keeping receiver storage admission unchanged. SemVer: patch --- src/payment/verifier.rs | 137 +++++++++++++++++++++++----------------- src/replication/mod.rs | 2 +- 2 files changed, 80 insertions(+), 59 deletions(-) diff --git a/src/payment/verifier.rs b/src/payment/verifier.rs index e7015456..e7d4e663 100644 --- a/src/payment/verifier.rs +++ b/src/payment/verifier.rs @@ -11,6 +11,7 @@ use crate::payment::pricing::{calculate_price, derive_records_stored_from_price} use crate::payment::proof::{ deserialize_merkle_proof, deserialize_proof, detect_proof_type, ProofType, }; +use crate::replication::config::K_BUCKET_SIZE; use crate::storage::lmdb::LmdbStorage; use ant_protocol::payment::verify::{verify_quote_content, verify_quote_signature}; use evmlib::common::{Amount, QuoteHash}; @@ -48,7 +49,7 @@ pub const MAX_PAYMENT_PROOF_SIZE_BYTES: usize = 262_144; /// verifier's current local price before a client PUT is rejected. /// /// A 20% floor means a paid quote must be at least `0.8 * P_v`, so an -/// attacker who controls a real close-group issuer still pays at least +/// attacker who controls a real K-close issuer still pays at least /// `0.8 * P_v * 3` for an honest verifier. Honest median-paid bundles have /// a structural majority guarantee: the four nodes at or below the median /// accept unless their own price grows more than `1 / 0.8 = 1.25x` between @@ -60,6 +61,11 @@ const PAID_QUOTE_PRICE_FLOOR_TOLERANCE_PCT: u64 = 20; const PERCENT_DENOMINATOR: u64 = 100; const PAID_QUOTE_PAYMENT_MULTIPLIER: u64 = 3; +/// Number of nearest DHT peers accepted for paid-quote issuer locality. +/// +/// This is the Kademlia K width, intentionally wider than `CLOSE_GROUP_SIZE`. +const PAID_QUOTE_ISSUER_CLOSENESS_WIDTH: usize = K_BUCKET_SIZE; + #[derive(Clone, Copy)] struct LegacyMedianCandidate<'a> { encoded_peer_id: &'a evmlib::EncodedPeerId, @@ -105,7 +111,7 @@ pub struct PaymentVerifierConfig { pub evm: EvmVerifierConfig, /// Cache capacity (number of `XorName` values to cache). pub cache_capacity: usize, - /// Close-group width used to check paid-quote issuer locality. + /// Close-group width exposed to storage and replication admission callers. pub close_group_size: usize, /// Local node's rewards address. /// @@ -126,8 +132,8 @@ pub struct PaymentVerifierConfig { /// the verifier: direct client PUTs and fresh chunk replication require local /// close-group responsibility; fresh paid-list replication requires local /// paid-list close-group membership. The verifier itself only checks payment -/// proof validity and that the paid quote's issuer is in the configured close -/// group for the quoted chunk address. +/// proof validity and that the paid quote's issuer is in the K closest peers +/// for the quoted chunk address. /// /// Immediate fresh chunk replication is different: the receiver is about to /// store the newly written chunk as if the client PUT it there directly, so @@ -196,10 +202,10 @@ pub struct PaymentVerifier { /// concurrency. inflight_closeness: Mutex>>, /// P2P node handle, attached post-construction so paid-quote verification - /// can check issuer closeness, and merkle verification can check that - /// candidate `pub_keys` map to peers actually close to the pool midpoint - /// in the live DHT. `None` in unit tests that don't exercise live-DHT - /// checks; production startup MUST call [`attach_p2p_node`]. + /// can check paid-quote issuer K-closeness, and merkle verification can + /// check that candidate `pub_keys` map to peers actually close to the pool + /// midpoint in the live DHT. `None` in unit tests that don't exercise + /// live-DHT checks; production startup MUST call [`attach_p2p_node`]. p2p_node: RwLock>>, /// LMDB storage handle, attached post-construction so the paid-quote /// price-floor check can read the authoritative on-disk record count without @@ -214,11 +220,11 @@ pub struct PaymentVerifier { /// [`Self::set_records_stored_for_tests`] so unit tests that don't wire a /// real `LmdbStorage` can still drive the price-floor logic. test_records_override: RwLock>, - /// Test-only override for the paid-quote issuer close-group check. + /// Test-only override for the paid-quote issuer K-closest check. /// /// Production code derives closest peers from the attached [`P2PNode`]. #[cfg(any(test, feature = "test-utils"))] - test_paid_quote_close_group_override: RwLock>>, + test_paid_quote_k_closest_override: RwLock>>, /// Test-only override for `completedPayments(quote_hash)`. /// /// Production always queries the payment vault; unit tests use this to @@ -344,7 +350,7 @@ impl PaymentVerifier { storage: RwLock::new(None), test_records_override: RwLock::new(None), #[cfg(any(test, feature = "test-utils"))] - test_paid_quote_close_group_override: RwLock::new(None), + test_paid_quote_k_closest_override: RwLock::new(None), #[cfg(any(test, feature = "test-utils"))] test_completed_payments_override: RwLock::new(HashMap::new()), config, @@ -365,7 +371,7 @@ impl PaymentVerifier { debug!("PaymentVerifier: P2PNode attached for payment live-DHT checks"); } - /// Configured close-group width used by payment proof admission callers. + /// Configured close-group width used by storage admission callers. #[must_use] pub fn close_group_size(&self) -> usize { self.config.close_group_size @@ -393,18 +399,25 @@ impl PaymentVerifier { } /// Test-only setter for local closest peers used by the paid-quote - /// issuer close-group check. + /// issuer K-closest check. + #[cfg(any(test, feature = "test-utils"))] + pub fn set_paid_quote_k_closest_for_tests(&self, peer_ids: Vec<[u8; 32]>) { + *self.test_paid_quote_k_closest_override.write() = Some(peer_ids); + } + + /// Compatibility alias for older tests that called this the close group. + /// The check now accepts the K closest peers for the quoted chunk address. #[cfg(any(test, feature = "test-utils"))] pub fn set_paid_quote_close_group_for_tests(&self, peer_ids: Vec<[u8; 32]>) { - *self.test_paid_quote_close_group_override.write() = Some(peer_ids); + self.set_paid_quote_k_closest_for_tests(peer_ids); } /// Compatibility alias for older tests that called this the known-peer - /// set. The check is now specifically the configured close group for the - /// quoted chunk address. + /// set. The check now accepts the K closest peers for the quoted chunk + /// address. #[cfg(any(test, feature = "test-utils"))] pub fn set_paid_quote_known_peers_for_tests(&self, peer_ids: Vec<[u8; 32]>) { - self.set_paid_quote_close_group_for_tests(peer_ids); + self.set_paid_quote_k_closest_for_tests(peer_ids); } /// Test-only setter for an on-chain completed payment amount. @@ -633,7 +646,7 @@ impl PaymentVerifier { /// 2. Median-priced candidate quotes are derived from the supplied bundle /// 3. Each candidate is checked for content binding, peer binding, and a /// valid ML-DSA-65 signature - /// 4. Each candidate must also come from a local close-group peer and + /// 4. Each candidate must also come from a local K-close peer and /// satisfy the paid-quote price floor /// 5. A candidate is accepted only if `completedPayments(quoteHash)` is at /// least 3x the median price @@ -641,9 +654,9 @@ impl PaymentVerifier { /// Non-median quotes are parsed only to locate the median. Their content, /// peer bindings, and signatures are deliberately ignored: the paid /// quote's content hash, quote hash, signature, local floor, issuer - /// close-group - /// check, and on-chain settlement are the authority. A one-quote proof is - /// valid when that single quote passes these checks and was paid 3x. + /// K-closeness check, and on-chain settlement are the authority. A + /// one-quote proof is valid when that single quote passes these checks and + /// was paid 3x. async fn verify_evm_payment( &self, xorname: &XorName, @@ -747,7 +760,7 @@ impl PaymentVerifier { let issuer_peer_id = Self::validate_paid_quote_peer_binding(candidate.encoded_peer_id, candidate.quote)?; - self.validate_paid_quote_issuer_close_group(xorname, &issuer_peer_id) + self.validate_paid_quote_issuer_k_closest(xorname, &issuer_peer_id) .await?; self.validate_paid_quote_price_floor(candidate.quote)?; @@ -869,24 +882,22 @@ impl PaymentVerifier { Ok(()) } - async fn validate_paid_quote_issuer_close_group( + async fn validate_paid_quote_issuer_k_closest( &self, xorname: &XorName, issuer_peer_id: &PeerId, ) -> Result<()> { #[cfg(any(test, feature = "test-utils"))] - if let Some(close_group_peer_ids) = - self.test_paid_quote_close_group_override.read().as_ref() - { - if close_group_peer_ids + if let Some(k_closest_peer_ids) = self.test_paid_quote_k_closest_override.read().as_ref() { + if k_closest_peer_ids .iter() .any(|peer_id| peer_id == issuer_peer_id.as_bytes()) { return Ok(()); } - let close_group_size = self.config.close_group_size; + let issuer_closeness_width = PAID_QUOTE_ISSUER_CLOSENESS_WIDTH; return Err(Error::Payment(format!( - "Paid quote issuer {} is not among this node's local {close_group_size} closest peers for {}", + "Paid quote issuer {} is not among this node's local K={issuer_closeness_width} closest peers for {}", issuer_peer_id.to_hex(), hex::encode(xorname) ))); @@ -898,7 +909,7 @@ impl PaymentVerifier { { crate::logging::warn!( "PaymentVerifier: no P2PNode attached; paid-quote issuer \ - close-group check SKIPPED (test build). Production startup MUST call \ + K-closest check SKIPPED (test build). Production startup MUST call \ PaymentVerifier::attach_p2p_node." ); return Ok(()); @@ -925,18 +936,18 @@ impl PaymentVerifier { // XOR only as a tiebreaker), which demotes an XOR-close relay-only / // NAT'd peer out of the compared window and falsely rejects an honest // payment that legitimately quoted that peer. Use the XOR-only sibling - // so this check matches how the client chose the quoted close group. - let close_group_size = self.config.close_group_size; + // so this check matches how the client chose the quoted K-closest set. + let issuer_closeness_width = PAID_QUOTE_ISSUER_CLOSENESS_WIDTH; let closest = p2p_node .dht_manager() - .find_closest_nodes_local_by_distance_with_self(xorname, close_group_size) + .find_closest_nodes_local_by_distance_with_self(xorname, issuer_closeness_width) .await; if closest.iter().any(|node| node.peer_id == *issuer_peer_id) { return Ok(()); } Err(Error::Payment(format!( - "Paid quote issuer {} is not among this node's local {close_group_size} closest peers for {}", + "Paid quote issuer {} is not among this node's local K={issuer_closeness_width} closest peers for {}", issuer_peer_id.to_hex(), hex::encode(xorname) ))) @@ -1702,6 +1713,16 @@ mod tests { PaymentVerifier::new(config) } + #[test] + fn paid_quote_issuer_closeness_width_uses_k() { + let issuer_closeness_width = PAID_QUOTE_ISSUER_CLOSENESS_WIDTH; + let k_bucket_size = K_BUCKET_SIZE; + let close_group_size = CLOSE_GROUP_SIZE; + + assert_eq!(issuer_closeness_width, k_bucket_size); + assert!(issuer_closeness_width > close_group_size); + } + fn make_signed_quote( xorname: XorName, price: Amount, @@ -1798,15 +1819,15 @@ mod tests { median_price * Amount::from(PAID_QUOTE_PAYMENT_MULTIPLIER) } - fn mark_close_group_paid_candidates( + fn mark_k_closest_paid_candidates( verifier: &PaymentVerifier, peer_quotes: &[(evmlib::EncodedPeerId, PaymentQuote)], ) { - let close_group_peers = median_test_candidates(peer_quotes) + let k_closest_peers = median_test_candidates(peer_quotes) .iter() .map(|(peer_id, _)| *peer_id.as_bytes()) .collect(); - verifier.set_paid_quote_close_group_for_tests(close_group_peers); + verifier.set_paid_quote_k_closest_for_tests(k_closest_peers); } fn mark_candidate_paid(verifier: &PaymentVerifier, quote: &PaymentQuote, amount: Amount) { @@ -2043,7 +2064,7 @@ mod tests { verifier.set_records_stored_for_tests(0); let xorname = [0xA1u8; 32]; let peer_quotes = make_signed_legacy_bundle(xorname, unique_test_prices()); - mark_close_group_paid_candidates(&verifier, &peer_quotes); + mark_k_closest_paid_candidates(&verifier, &peer_quotes); let expected_amount = expected_median_payment(&peer_quotes); let paid_quote = median_test_candidates(&peer_quotes) .first() @@ -2070,7 +2091,7 @@ mod tests { let xorname = [0xB1u8; 32]; let (peer_id, quote) = make_signed_quote(xorname, price_at_records(0), 1); let peer_quotes = vec![(peer_id, quote.clone())]; - mark_close_group_paid_candidates(&verifier, &peer_quotes); + mark_k_closest_paid_candidates(&verifier, &peer_quotes); mark_candidate_paid(&verifier, "e, expected_median_payment(&peer_quotes)); let proof_bytes = serialize_proof(peer_quotes); @@ -2091,7 +2112,7 @@ mod tests { let xorname = [0xB2u8; 32]; let (peer_id, quote) = make_signed_quote(xorname, price_at_records(0), 1); let peer_quotes = vec![(peer_id, quote.clone())]; - mark_close_group_paid_candidates(&verifier, &peer_quotes); + mark_k_closest_paid_candidates(&verifier, &peer_quotes); mark_candidate_paid(&verifier, "e, quote.price); let proof_bytes = serialize_proof(peer_quotes); @@ -2143,7 +2164,7 @@ mod tests { crate::payment::pricing::calculate_price(6000), ], ); - mark_close_group_paid_candidates(&verifier, &peer_quotes); + mark_k_closest_paid_candidates(&verifier, &peer_quotes); let expected_amount = expected_median_payment(&peer_quotes); let paid_quote = median_test_candidates(&peer_quotes) .first() @@ -2180,7 +2201,7 @@ mod tests { crate::payment::pricing::calculate_price(6000), ], ); - mark_close_group_paid_candidates(&verifier, &peer_quotes); + mark_k_closest_paid_candidates(&verifier, &peer_quotes); let expected_amount = expected_median_payment(&peer_quotes); let paid_quote = median_test_candidates(&peer_quotes) .first() @@ -2202,10 +2223,10 @@ mod tests { } #[tokio::test] - async fn test_legacy_paid_median_issuer_close_group_rejection() { + async fn test_legacy_paid_median_issuer_k_closest_rejection() { let verifier = create_test_verifier(); verifier.set_records_stored_for_tests(0); - verifier.set_paid_quote_close_group_for_tests(vec![rand::random()]); + verifier.set_paid_quote_k_closest_for_tests(vec![rand::random()]); let xorname = [0xA4u8; 32]; let peer_quotes = make_signed_legacy_bundle(xorname, unique_test_prices()); let expected_amount = expected_median_payment(&peer_quotes); @@ -2220,11 +2241,11 @@ mod tests { let err = verifier .verify_payment(&xorname, Some(&proof_bytes), VerificationContext::ClientPut) .await - .expect_err("out-of-close-group paid issuer should be rejected"); + .expect_err("out-of-K paid issuer should be rejected"); assert!( format!("{err}").contains("not among this node's local"), - "Error should mention local close-group peers: {err}" + "Error should mention local K-closest peers: {err}" ); } @@ -2245,7 +2266,7 @@ mod tests { crate::payment::pricing::calculate_price(0), ], ); - mark_close_group_paid_candidates(&verifier, &peer_quotes); + mark_k_closest_paid_candidates(&verifier, &peer_quotes); let expected_amount = expected_median_payment(&peer_quotes); let paid_quote = median_test_candidates(&peer_quotes) .first() @@ -2304,7 +2325,7 @@ mod tests { let mut peer_quotes = make_signed_legacy_bundle(xorname, unique_test_prices()); let median_index = median_quote_index(peer_quotes.len()); peer_quotes[median_index].1.content = xor_name::XorName([0xE7u8; 32]); - mark_close_group_paid_candidates(&verifier, &peer_quotes); + mark_k_closest_paid_candidates(&verifier, &peer_quotes); let proof_bytes = serialize_proof(peer_quotes); let err = verifier @@ -2325,7 +2346,7 @@ mod tests { let xorname = [0xA8u8; 32]; let mut peer_quotes = make_signed_legacy_bundle(xorname, unique_test_prices()); peer_quotes[0].1.content = xor_name::XorName([0xE8u8; 32]); - mark_close_group_paid_candidates(&verifier, &peer_quotes); + mark_k_closest_paid_candidates(&verifier, &peer_quotes); let expected_amount = expected_median_payment(&peer_quotes); let paid_quote = median_test_candidates(&peer_quotes) .first() @@ -2353,7 +2374,7 @@ mod tests { let mut peer_quotes = make_signed_legacy_bundle(xorname, unique_test_prices()); let median_index = median_quote_index(peer_quotes.len()); peer_quotes[median_index].1.signature.push(0xFF); - mark_close_group_paid_candidates(&verifier, &peer_quotes); + mark_k_closest_paid_candidates(&verifier, &peer_quotes); let expected_amount = expected_median_payment(&peer_quotes); let paid_quote = median_test_candidates(&peer_quotes) .first() @@ -2381,7 +2402,7 @@ mod tests { let xorname = [0xAAu8; 32]; let mut peer_quotes = make_signed_legacy_bundle(xorname, unique_test_prices()); peer_quotes[0].1.signature.push(0xFF); - mark_close_group_paid_candidates(&verifier, &peer_quotes); + mark_k_closest_paid_candidates(&verifier, &peer_quotes); let expected_amount = expected_median_payment(&peer_quotes); let paid_quote = median_test_candidates(&peer_quotes) .first() @@ -2408,7 +2429,7 @@ mod tests { let xorname = [0xABu8; 32]; let mut peer_quotes = make_signed_legacy_bundle(xorname, unique_test_prices()); peer_quotes[0].0 = evmlib::EncodedPeerId::new(rand::random()); - mark_close_group_paid_candidates(&verifier, &peer_quotes); + mark_k_closest_paid_candidates(&verifier, &peer_quotes); let expected_amount = expected_median_payment(&peer_quotes); let paid_quote = median_test_candidates(&peer_quotes) .first() @@ -2434,7 +2455,7 @@ mod tests { verifier.set_records_stored_for_tests(0); let xorname = [0xACu8; 32]; let peer_quotes = make_signed_legacy_bundle(xorname, tied_median_test_prices()); - mark_close_group_paid_candidates(&verifier, &peer_quotes); + mark_k_closest_paid_candidates(&verifier, &peer_quotes); mark_all_median_candidates_unpaid(&verifier, &peer_quotes); let expected_amount = expected_median_payment(&peer_quotes); let paid_quote = median_test_candidates(&peer_quotes) @@ -2456,10 +2477,10 @@ mod tests { } #[tokio::test] - async fn test_legacy_paid_list_admission_enforces_issuer_close_group() { + async fn test_legacy_paid_list_admission_enforces_issuer_k_closest() { let verifier = create_test_verifier(); verifier.set_records_stored_for_tests(0); - verifier.set_paid_quote_close_group_for_tests(Vec::new()); + verifier.set_paid_quote_k_closest_for_tests(Vec::new()); let xorname = [0xB5u8; 32]; let peer_quotes = make_signed_legacy_bundle(xorname, unique_test_prices()); let expected_amount = expected_median_payment(&peer_quotes); @@ -2478,11 +2499,11 @@ mod tests { VerificationContext::PaidListAdmission, ) .await - .expect_err("paid-list admission must enforce the paid issuer close-group check"); + .expect_err("paid-list admission must enforce the paid issuer K-closest check"); assert!( format!("{err}").contains("not among this node's local"), - "Error should mention local close-group peers: {err}" + "Error should mention local K-closest peers: {err}" ); } @@ -2503,7 +2524,7 @@ mod tests { crate::payment::pricing::calculate_price(0), ], ); - mark_close_group_paid_candidates(&verifier, &peer_quotes); + mark_k_closest_paid_candidates(&verifier, &peer_quotes); let expected_amount = expected_median_payment(&peer_quotes); let paid_quote = median_test_candidates(&peer_quotes) .first() diff --git a/src/replication/mod.rs b/src/replication/mod.rs index b8ef7224..d259fe77 100644 --- a/src/replication/mod.rs +++ b/src/replication/mod.rs @@ -1230,7 +1230,7 @@ async fn handle_fresh_offer( // part of the immediate write fan-out: this receiver is about to store the // record as if the client had PUT it here directly. Storage admission // was checked above before proof work. ClientPut verification applies - // store-strength cache semantics, paid-quote issuer close-group and local + // store-strength cache semantics, paid-quote issuer K-closeness and local // price floor checks for single-node proofs, and merkle candidate // closeness for merkle proofs. match payment_verifier