From f6b8184315e73afd6e7d85ba7ab78e665a42b3d4 Mon Sep 17 00:00:00 2001 From: Chris O'Neil Date: Fri, 29 May 2026 13:57:45 +0100 Subject: [PATCH 1/9] chore(release): cut rc-2026.5.5 --- Cargo.lock | 12 +++++------- Cargo.toml | 6 +++--- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 13b5520..f6b8371 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -809,7 +809,7 @@ dependencies = [ [[package]] name = "ant-node" -version = "0.11.5" +version = "0.11.6-rc.1" dependencies = [ "alloy", "ant-protocol", @@ -861,9 +861,8 @@ dependencies = [ [[package]] name = "ant-protocol" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4d0ba3f671c08a1d52291b601ec35a7353f4f4e10f221b5f0ee372d154636dd" +version = "2.1.2-rc.1" +source = "git+https://github.com/WithAutonomi/ant-protocol?branch=rc-2026.5.5#88ae80b6125e88342092ddec4c1a7f77b011caf4" dependencies = [ "blake3", "bytes", @@ -4866,9 +4865,8 @@ dependencies = [ [[package]] name = "saorsa-core" -version = "0.24.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c1267928da1bcc91748c314f95f7952bc01c0359a4ac70a0b111b0386898934" +version = "0.24.5-rc.1" +source = "git+https://github.com/saorsa-labs/saorsa-core?branch=rc-2026.5.5#7d8816996bfcf04a2a3d9069bb8c6327ac11b2af" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 0ef01ea..c997c5c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ant-node" -version = "0.11.5" +version = "0.11.6-rc.1" 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 = { git = "https://github.com/WithAutonomi/ant-protocol", branch = "rc-2026.5.5" } # Core (provides EVERYTHING: networking, DHT, security, trust, storage) -saorsa-core = "0.24.4" +saorsa-core = { git = "https://github.com/saorsa-labs/saorsa-core", branch = "rc-2026.5.5" } saorsa-pqc = "0.5" # Payment verification - autonomi network lookup + EVM payment From bd86c516394d02d4a1e09386fb8b559938ae4275 Mon Sep 17 00:00:00 2001 From: Chris O'Neil Date: Sat, 30 May 2026 16:52:56 +0100 Subject: [PATCH 2/9] fix(payment): price quotes from current_chunks() to match freshness gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The storage-delta freshness gate added in #120 / refined in 33db86a rejected essentially every payment on an actively-replicating network with: payment error: Quote from peer ... stale by 29 records (quoted 8 vs current 37, tolerance 5) Root cause: pricing and the freshness gate read two different record counts. - Pricing (`QuoteGenerator::create_quote`) used the in-memory `QuotingMetricsTracker`, whose `record_store()` is incremented ONLY on the client-paid PUT path (`storage/handler.rs`). Replication stores (`replication/mod.rs` fresh fan-out + repair fetch) write to LMDB without touching it. - The freshness gate (`validate_quote_freshness`) reads `LmdbStorage::current_chunks()` — the authoritative total, which DOES include replicated and repaired records. So `current` counted all records while `quoted` (price-derived) counted only direct paid PUTs. On any network with replication, current >> quoted, the delta blew past `tolerance = max(5, 5% of quoted)`, and every payment was rejected. 33db86a made the verifier's read authoritative but left pricing on the side counter, which is what introduced the divergence. STG-01 (~907 nodes, 30% NAT, 10 concurrent uploaders) hit ~100% upload failure on the first cycle; smaller runs only passed because per-node record counts stayed under the 5-record floor. Fix: price quotes from the same authoritative source the gate reads. `QuoteGenerator` gains an attached `Arc` (mirroring `PaymentVerifier`) and prices from `current_chunks()`, falling back to the in-memory counter only when no store is attached (unit tests / misconfigured startup). `AntProtocol::new` attaches the store to the generator right beside the verifier, so the invariant holds for every construction path. `calculate_price` and `derive_records_stored_from_price` round-trip exactly, so feeding larger current_chunks() counts through pricing is lossless and the verifier recovers the exact count — leaving the freshness delta at ~genuine in-flight growth. current_chunks() is also a more accurate measure of node fullness for pricing than direct-paid-PUTs-only. Adds a regression test that writes records straight to the store (as replication would, without bumping the side counter) and asserts the quote prices off the store count — the divergence the prior unit tests structurally could not express. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/payment/quote.rs | 140 +++++++++++++++++++++++++++++++++++++++-- src/storage/handler.rs | 18 ++++-- 2 files changed, 148 insertions(+), 10 deletions(-) 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/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. From d1b31f0caa7758df7252c9cf7eabe588e572b12a Mon Sep 17 00:00:00 2001 From: Chris O'Neil Date: Sat, 30 May 2026 17:52:13 +0100 Subject: [PATCH 3/9] chore(release): roll rc-2026.5.5 to 0.11.6-rc.2 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f6b8371..40eb46e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -809,7 +809,7 @@ dependencies = [ [[package]] name = "ant-node" -version = "0.11.6-rc.1" +version = "0.11.6-rc.2" dependencies = [ "alloy", "ant-protocol", diff --git a/Cargo.toml b/Cargo.toml index c997c5c..bf6d12b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ant-node" -version = "0.11.6-rc.1" +version = "0.11.6-rc.2" edition = "2021" authors = ["David Irvine "] description = "Pure quantum-proof network node for the Autonomi decentralized network" From 56dd370c06aa211cd3dae8f0e0c43809b77f7e88 Mon Sep 17 00:00:00 2001 From: Chris O'Neil Date: Sat, 30 May 2026 20:46:51 +0100 Subject: [PATCH 4/9] fix(payment): gate quote freshness on price under-payment, not record delta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #122 made pricing and the freshness gate read the same record source, but uploads on a fresh, rapidly-filling testnet (STG-01, 0.11.6-rc.2) still failed ~100%. The residual cause is the freshness gate itself. The gate compared the price-derived quoted record count against the node's current count with a fixed `max(5, 5%)` tolerance, symmetrically (abs_diff). Two problems on an actively-replicating network: 1. Tolerance too tight for normal in-flight churn. Between a node quoting and verifying a payment (the client collects 7 quotes/chunk, pays on-chain via Arbitrum, then PUTs — several seconds) the node's record count drifts by a handful of records via replication. Sampled STG-01 rejects had a median delta of 8 — just over the floor of 5. At low/moderate fill the pricing curve is nearly flat, so that drift is a negligible price change, yet it was rejected. 2. Symmetric abs_diff rejected over-payments. ~36% of rejects had current < quoted: the node had FEWER records than when it quoted (prune/churn), so the client paid for a fuller, pricier node — an over-payment — and the node rejected it anyway. Rejecting money a node is owed is nonsensical. Because an upload needs every chunk stored, even a ~40% per-chunk reject rate drives upload success to ~0 (0.6^9 for a 9-chunk file; worse for large files). So per-chunk rejection has to approach zero, not merely shrink. Fix: make the gate price-based and one-directional. Compute the price the node would charge now for its current fullness and reject only if the quote paid less than that by more than QUOTE_PRICE_STALENESS_PCT_TOLERANCE (25%). - Over-payment (paid >= current price) is always accepted (fixes #2). - Comparing prices self-scales with the quadratic curve: benign churn on the flat part of the curve is ignored, while genuine staleness at high fill — where the curve is steep — is still caught (fixes #1). Replaces the two record-delta tolerance constants with a single price percentage. Reworks the freshness tests: a 10-record drift now passes (it was "stale by 10" before), over-payment passes (regression test for #2), and a quote underpricing by >25% is still rejected. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/payment/verifier.rs | 149 ++++++++++++++++++++++++++++------------ 1 file changed, 105 insertions(+), 44 deletions(-) diff --git a/src/payment/verifier.rs b/src/payment/verifier.rs index 8d151e7..954854c 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 ))); } } @@ -1759,15 +1786,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 +1810,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_overpayment_accepted() { + use evmlib::{EncodedPeerId, RewardsAddress}; + + let verifier = create_test_verifier(); + // 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( + [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_storage_delta_exceeds_tolerance_rejected() { + fn test_underpriced_quote_rejected() { use evmlib::{EncodedPeerId, RewardsAddress}; let verifier = create_test_verifier(); - verifier.set_records_stored_for_tests(107); - let xorname = [0xE1u8; 32]; + verifier.set_records_stored_for_tests(6000); let quote = make_fake_quote_at_records( - xorname, + [0xE1u8; 32], SystemTime::now(), RewardsAddress::new([1u8; 20]), 100, @@ -1800,8 +1861,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`. From d1e85e18bc476e06abfec2f8c69d0dff2c11afeb Mon Sep 17 00:00:00 2001 From: Chris O'Neil Date: Sat, 30 May 2026 21:39:03 +0100 Subject: [PATCH 5/9] chore(release): roll rc-2026.5.5 to 0.11.6-rc.3 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 40eb46e..2efb5dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -809,7 +809,7 @@ dependencies = [ [[package]] name = "ant-node" -version = "0.11.6-rc.2" +version = "0.11.6-rc.3" dependencies = [ "alloy", "ant-protocol", diff --git a/Cargo.toml b/Cargo.toml index bf6d12b..f0a0b7f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ant-node" -version = "0.11.6-rc.2" +version = "0.11.6-rc.3" edition = "2021" authors = ["David Irvine "] description = "Pure quantum-proof network node for the Autonomi decentralized network" From f643558450b22fd50631506ea768c1366d9988c9 Mon Sep 17 00:00:00 2001 From: grumbach Date: Tue, 2 Jun 2026 16:49:42 +0900 Subject: [PATCH 6/9] =?UTF-8?q?fix(merkle):=20lenient=20closeness=20check?= =?UTF-8?q?=20=E2=80=94=209/16=20majority=20+=20address-space=20band?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exact-match 13/16 closeness gate rejects honest Merkle pools wholesale on a young, high-churn, NAT-heavy network: two nodes' closest-set views to the pool midpoint diverge by far more than the 3 peers 13/16 tolerated. This was the dominant production failure (the "candidate pub_keys do not match" rejection — 1,808 occurrences across 311 chunks in one PROD-UL-01 run), and retrying can't fix a systematic divergence. Two changes to the closeness check: - Lower CANDIDATE_CLOSENESS_REQUIRED from 13/16 to a 9/16 majority. - Count a candidate if it is EITHER an exact match in the storer's returned closest peers OR within the XOR band those peers occupy (distance to the midpoint <= the farthest returned peer). The address-space arm accepts a candidate that is genuinely in the midpoint's close group but simply absent from this storer's particular lookup, while still rejecting fabricated keys that land far from an un-ground midpoint. Lookup width stays at 32 (wider measured too heavy). Security trade-off is deliberate and being A/B-measured against the stricter RC: no theft of funds (payment still binds on-chain to the rewards address), but the pay-yourself grinding gets cheaper; the address-space arm and the planned pool-midpoint consensus-anchor (D1) work are what keep that bounded. Tests cover the address-space arm accepting honest divergence, the security floor rejecting far-from-midpoint fabrications, and the majority boundary. --- src/payment/verifier.rs | 273 +++++++++++++++++++++++++--------------- 1 file changed, 169 insertions(+), 104 deletions(-) diff --git a/src/payment/verifier.rs b/src/payment/verifier.rs index 954854c..4a8a196 100644 --- a/src/payment/verifier.rs +++ b/src/payment/verifier.rs @@ -697,24 +697,34 @@ 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) that must satisfy + /// the closeness check — counting BOTH exact-`PeerId` matches against the + /// DHT's returned closest peers AND candidates that fall within the + /// address-space band those peers occupy (see [`Self::check_closeness_match`]). /// - /// 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. + /// Lowered from 13/16 to a simple majority (9/16) as the lenient arm of + /// an A/B comparison against the stricter RC. Rationale: on a young, + /// high-churn, NAT-heavy network the two nodes' closest-set views diverge + /// by far more than the 3 peers 13/16 tolerated, so honest pools were + /// being rejected wholesale (the STG-01 / PROD-UL-01 "candidate `pub_keys` + /// do not match" rejections — 1,808 across 311 chunks in one prod run). + /// A majority threshold, combined with the address-space proximity arm, + /// accepts pools that are genuinely drawn from the midpoint's close group + /// even when this storer's specific lookup doesn't reproduce the payer's + /// exact 16. /// - /// 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 (deliberate, being measured): this widens the room for + /// the "pay-yourself" attack — an attacker who runs several real + /// neighbourhood peers needs fewer fabricated/self-owned candidates to + /// clear a majority than to clear 13/16. No theft of funds becomes + /// possible (payment still goes on-chain to the bound rewards address); + /// the cost is that grinding storage payments back to your own nodes gets + /// cheaper. The address-space arm narrows this again by requiring even + /// non-matching candidates to actually sit near the midpoint rather than + /// being free off-network fabrications. Pairs with the planned + /// pool-midpoint consensus-anchor (D1) work, which removes the 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. @@ -774,8 +784,11 @@ impl PaymentVerifier { /// 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. + /// running real nodes near the target — same security floor. The window + /// width is held at 32 here (wider was measured as too heavy on the + /// lookup path); the leniency in this arm comes from the lower + /// `CANDIDATE_CLOSENESS_REQUIRED` (9/16) plus the address-space band, not + /// from a wider lookup. const CLOSENESS_LOOKUP_WIDTH: usize = 2 * evmlib::merkle_payments::CANDIDATES_PER_POOL; /// Maximum waiter → leader retries when the leader's future was cancelled @@ -838,8 +851,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 @@ -990,11 +1003,38 @@ impl PaymentVerifier { Ok(candidate_peer_ids) } + /// XOR distance between a 32-byte `PeerId`/address key and the pool + /// midpoint, as a big-endian `[u8; 32]` that orders the same way the + /// Kademlia metric does (lexicographic compare on the XOR == closeness + /// order). Returned by value so callers can `max()`/compare directly. + fn xor_distance(key: &[u8; 32], midpoint: &[u8; 32]) -> [u8; 32] { + let mut out = [0u8; 32]; + for i in 0..32 { + out[i] = key[i] ^ midpoint[i]; + } + out + } + /// 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 /// `CANDIDATE_CLOSENESS_REQUIRED`-of-N threshold. /// + /// A candidate counts toward the threshold if EITHER: + /// + /// 1. **Exact match** — its `PeerId` is one of the peers the storer's own + /// network lookup returned; or + /// 2. **Address-space proximity** — its `PeerId` key sits within the XOR + /// band the returned peers occupy, i.e. its distance to the midpoint is + /// no greater than the farthest peer the lookup returned. Such a + /// candidate is genuinely in the midpoint's close group — it just + /// wasn't in *this* storer's particular lookup result (the two nodes' + /// DHT views diverge on a churny/NAT-heavy network). + /// + /// Arm 2 is the leniency that arm-1-only (exact match) lacked: it accepts + /// honest divergence while still rejecting fabricated keys, which by + /// construction land far from a midpoint the attacker did not grind. + /// /// 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, @@ -1027,19 +1067,35 @@ impl PaymentVerifier { ))); } - // Set-membership check against the returned closest-peers list. - // Candidate `PeerId`s are deduplicated upstream, so each match - // corresponds to a distinct peer. + // Arm 1 set: 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 = network_peer_ids.iter().copied().collect(); + + // Arm 2 band: the XOR distance of the FARTHEST peer the lookup + // returned. Any candidate at least this close to the midpoint is + // within the close group the network itself reported, so it is + // honest even if this storer's lookup didn't surface it. Computed + // over the returned peers' own key bytes (`PeerId::as_bytes()` and + // the midpoint share the 256-bit Kademlia keyspace). + let band_radius = network_peer_ids + .iter() + .map(|pid| Self::xor_distance(pid.as_bytes(), pool_address)) + .max() + .unwrap_or([0xFFu8; 32]); + let matched = candidate_peer_ids .iter() - .filter(|pid| network_set.contains(pid)) + .filter(|pid| { + network_set.contains(pid) + || Self::xor_distance(pid.as_bytes(), pool_address) <= band_radius + }) .count(); if matched < Self::CANDIDATE_CLOSENESS_REQUIRED { debug!( - "Merkle closeness rejected: {matched}/{} candidates match the DHT's closest peers \ + "Merkle closeness rejected: {matched}/{} candidates within the DHT's closest band \ for pool midpoint {} (required: {}, network returned {} peers)", candidate_peer_ids.len(), hex::encode(pool_address), @@ -1055,7 +1111,7 @@ impl PaymentVerifier { } debug!( - "Merkle closeness passed: {matched}/{} candidates matched the DHT's closest peers \ + "Merkle closeness passed: {matched}/{} candidates within the DHT's closest band \ for pool midpoint {}", candidate_peer_ids.len(), hex::encode(pool_address), @@ -2355,7 +2411,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 @@ -2927,15 +2983,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 lenient threshold so a future change can't silently move + // it. This is the security knob deliberately lowered to a majority + // (9/16) for the lenient A/B arm; the address-space band is what + // keeps off-network fabrications from exploiting the lower bar. 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, + "lenient arm pins the closeness threshold at a 9/16 majority" ); } @@ -2985,19 +3041,23 @@ mod 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 the lenient policy: // - // - "K=16 storer rejects honest pool whose candidates legitimately - // include peers from positions 17–32" (~73% of mismatches) + // - threshold lowered to a majority (`CANDIDATE_CLOSENESS_REQUIRED = + // 9/16`), and + // - a candidate counts if it is EITHER an exact match in the network's + // returned set OR within the XOR band the returned peers occupy + // (address-space proximity arm). // - // and that the security floor (`CANDIDATE_CLOSENESS_REQUIRED = 13/16`) - // still rejects forged pools at the wider window. + // They also prove the security floor still holds: a forged pool whose + // candidates are far from the (un-ground) midpoint is rejected even at + // the wider K=32 window and the lower threshold. // // 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 for the `[0u8; 32]` + // midpoint a smaller tag == smaller XOR distance == closer. That lets + // each test reason about both exact membership AND the address-space + // band by tag value alone. // ========================================================================= /// Build a deterministic `PeerId` from a single byte tag. @@ -3025,17 +3085,10 @@ 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))) @@ -3051,52 +3104,62 @@ mod tests { } #[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() { + // STG-01-style honest skew: the client's 16 candidates span + // network-true positions {1..=12, 17, 19, 21, 23}. With K=32 the + // network set is positions 1..=32, 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 K=32 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. + fn closeness_match_accepts_via_address_space_band_without_exact_match() { + // The leniency arm. The storer's lookup returned peers at positions + // {1..=8, 20..=43} (24 peers) — note positions 9..=19 are absent + // from THIS storer's view (churn / NAT). The payer's pool is the + // genuinely-closest 16: positions 1..=16. Positions 9..=16 are NOT + // in the storer's returned set, so exact matches = 8 (positions + // 1..=8) which is below 9/16. // - // Tag bytes 100..=115 are deliberately disjoint from the network - // set (1..=32). + // But the storer's returned set includes peers as far out as + // position 43, so the XOR band radius easily covers positions + // 9..=16 — they are closer to the midpoint than peers the storer + // itself returned. The address-space arm therefore counts all 16, + // and the pool passes. Exact-match-only would have rejected it. + let candidates: Vec = (1..=16u8).map(synthetic_peer_id).collect(); + let network: Vec = (1..=8u8).chain(20..=43u8).map(synthetic_peer_id).collect(); + let pool_address = [0u8; 32]; + + // Sanity: exact matches alone are only 8 (< 9), so a pass here can + // only come from the address-space arm. + let exact: usize = candidates.iter().filter(|c| network.contains(c)).count(); + assert_eq!(exact, 8, "precondition: only 8 exact matches"); + + let result = PaymentVerifier::check_closeness_match(&candidates, &network, &pool_address); + assert!( + result.is_ok(), + "pool within the returned XOR band must pass via the address-space arm: {result:?}" + ); + } + + #[test] + fn closeness_match_rejects_forged_pool_far_from_midpoint() { + // Security floor: a fully-forged pool whose candidate PeerIds are + // both disjoint from the network set AND far outside the XOR band + // (tags 100..=115 vs a network band that ends at tag 32) must be + // rejected — neither arm should count them. The lower threshold and + // the address-space arm must NOT let off-network fabrications pass. 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]; @@ -3111,41 +3174,43 @@ mod tests { ); } other => panic!( - "forged pool with all candidates outside network's top-32 \ - must be rejected at K=32 (security floor): {other:?}" + "forged pool far outside the network's XOR band must be \ + rejected (security floor): {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: only 8 candidates qualify (8 exact in-band + // matches at tags 1..=8) and the other 8 are far-out fabrications + // (tags 100..=107) beyond the band. 8 < 9 → reject. + let mut candidates = synthetic_peer_ids(8); + candidates.extend((100..=107).map(synthetic_peer_id)); // 8 far 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 qualifying < 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 qualify (tags 1..=9, all + // exact in-band matches), the other 7 are far fabrications beyond + // the band (tags 100..=106). 9 ≥ 9 → accept. + let mut candidates = synthetic_peer_ids(9); + candidates.extend((100..=106).map(synthetic_peer_id)); // 7 far 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:?}" ); } @@ -3156,14 +3221,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}" ); } From 807c89eb00e2f199edbb606353e405ae2f1ec50c Mon Sep 17 00:00:00 2001 From: grumbach Date: Tue, 2 Jun 2026 17:57:51 +0900 Subject: [PATCH 7/9] fix(merkle): lower closeness threshold to 9/16 majority MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exact-match 13/16 closeness gate rejects honest Merkle pools wholesale on a young, high-churn, NAT-heavy network: two nodes' views of the closest peers to the pool midpoint diverge by more than a near-unanimous threshold tolerates. This was the dominant production failure (the "candidate pub_keys do not match" rejection), and retrying can't fix a systematic divergence. Lower CANDIDATE_CLOSENESS_REQUIRED from 13/16 to a 9/16 majority. A candidate still counts only if its PeerId is among the peers the storer's own DHT lookup returns as closest, so every counted candidate is a real, reachable, routed peer — a fabricated off-network key cannot satisfy the check. The leniency is purely the lower threshold; it does not relax what makes a candidate valid. Security: a lower threshold lets the pay-yourself attacker clear the bar with fewer real top-K Sybil nodes, but the floor stays "run N real closest nodes AND grind the midpoint" (no fund theft is possible — payment binds on-chain to the rewards address). Pairs with the planned pool-midpoint consensus-anchor work, which removes the midpoint grinding freedom. cargo test payment::verifier::tests: 58 passed. fmt + clippy clean. --- src/payment/verifier.rs | 238 +++++++++++++--------------------------- 1 file changed, 79 insertions(+), 159 deletions(-) diff --git a/src/payment/verifier.rs b/src/payment/verifier.rs index 4a8a196..6774fba 100644 --- a/src/payment/verifier.rs +++ b/src/payment/verifier.rs @@ -697,33 +697,29 @@ impl PaymentVerifier { Ok(()) } - /// Minimum number of candidate `pub_keys` (out of 16) that must satisfy - /// the closeness check — counting BOTH exact-`PeerId` matches against the - /// DHT's returned closest peers AND candidates that fall within the - /// address-space band those peers occupy (see [`Self::check_closeness_match`]). + /// 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. /// - /// Lowered from 13/16 to a simple majority (9/16) as the lenient arm of - /// an A/B comparison against the stricter RC. Rationale: on a young, - /// high-churn, NAT-heavy network the two nodes' closest-set views diverge - /// by far more than the 3 peers 13/16 tolerated, so honest pools were - /// being rejected wholesale (the STG-01 / PROD-UL-01 "candidate `pub_keys` - /// do not match" rejections — 1,808 across 311 chunks in one prod run). - /// A majority threshold, combined with the address-space proximity arm, - /// accepts pools that are genuinely drawn from the midpoint's close group - /// even when this storer's specific lookup doesn't reproduce the payer's - /// exact 16. + /// 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. /// - /// Security cost (deliberate, being measured): this widens the room for - /// the "pay-yourself" attack — an attacker who runs several real - /// neighbourhood peers needs fewer fabricated/self-owned candidates to - /// clear a majority than to clear 13/16. No theft of funds becomes - /// possible (payment still goes on-chain to the bound rewards address); - /// the cost is that grinding storage payments back to your own nodes gets - /// cheaper. The address-space arm narrows this again by requiring even - /// non-matching candidates to actually sit near the midpoint rather than - /// being free off-network fabrications. Pairs with the planned - /// pool-midpoint consensus-anchor (D1) work, which removes the grinding - /// freedom that makes a low threshold dangerous. + /// 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 @@ -781,14 +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. The window - /// width is held at 32 here (wider was measured as too heavy on the - /// lookup path); the leniency in this arm comes from the lower - /// `CANDIDATE_CLOSENESS_REQUIRED` (9/16) plus the address-space band, not - /// from a wider lookup. + /// 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 @@ -1003,37 +997,19 @@ impl PaymentVerifier { Ok(candidate_peer_ids) } - /// XOR distance between a 32-byte `PeerId`/address key and the pool - /// midpoint, as a big-endian `[u8; 32]` that orders the same way the - /// Kademlia metric does (lexicographic compare on the XOR == closeness - /// order). Returned by value so callers can `max()`/compare directly. - fn xor_distance(key: &[u8; 32], midpoint: &[u8; 32]) -> [u8; 32] { - let mut out = [0u8; 32]; - for i in 0..32 { - out[i] = key[i] ^ midpoint[i]; - } - out - } - /// 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 toward the threshold if EITHER: - /// - /// 1. **Exact match** — its `PeerId` is one of the peers the storer's own - /// network lookup returned; or - /// 2. **Address-space proximity** — its `PeerId` key sits within the XOR - /// band the returned peers occupy, i.e. its distance to the midpoint is - /// no greater than the farthest peer the lookup returned. Such a - /// candidate is genuinely in the midpoint's close group — it just - /// wasn't in *this* storer's particular lookup result (the two nodes' - /// DHT views diverge on a churny/NAT-heavy network). - /// - /// Arm 2 is the leniency that arm-1-only (exact match) lacked: it accepts - /// honest divergence while still rejecting fabricated keys, which by - /// construction land far from a midpoint the attacker did not grind. + /// 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. @@ -1067,35 +1043,19 @@ impl PaymentVerifier { ))); } - // Arm 1 set: exact-match membership against the returned closest - // peers. Candidate `PeerId`s are deduplicated upstream, so each - // match corresponds to a distinct peer. + // 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 = network_peer_ids.iter().copied().collect(); - - // Arm 2 band: the XOR distance of the FARTHEST peer the lookup - // returned. Any candidate at least this close to the midpoint is - // within the close group the network itself reported, so it is - // honest even if this storer's lookup didn't surface it. Computed - // over the returned peers' own key bytes (`PeerId::as_bytes()` and - // the midpoint share the 256-bit Kademlia keyspace). - let band_radius = network_peer_ids - .iter() - .map(|pid| Self::xor_distance(pid.as_bytes(), pool_address)) - .max() - .unwrap_or([0xFFu8; 32]); - let matched = candidate_peer_ids .iter() - .filter(|pid| { - network_set.contains(pid) - || Self::xor_distance(pid.as_bytes(), pool_address) <= band_radius - }) + .filter(|pid| network_set.contains(pid)) .count(); if matched < Self::CANDIDATE_CLOSENESS_REQUIRED { debug!( - "Merkle closeness rejected: {matched}/{} candidates within the DHT's closest band \ + "Merkle closeness rejected: {matched}/{} candidates match the DHT's closest peers \ for pool midpoint {} (required: {}, network returned {} peers)", candidate_peer_ids.len(), hex::encode(pool_address), @@ -1111,7 +1071,7 @@ impl PaymentVerifier { } debug!( - "Merkle closeness passed: {matched}/{} candidates within the DHT's closest band \ + "Merkle closeness passed: {matched}/{} candidates matched the DHT's closest peers \ for pool midpoint {}", candidate_peer_ids.len(), hex::encode(pool_address), @@ -2984,14 +2944,14 @@ mod tests { #[test] fn closeness_required_threshold_is_majority() { - // Pin the lenient threshold so a future change can't silently move - // it. This is the security knob deliberately lowered to a majority - // (9/16) for the lenient A/B arm; the address-space band is what - // keeps off-network fabrications from exploiting the lower bar. + // 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, 9, - "lenient arm pins the closeness threshold at a 9/16 majority" + "closeness threshold is a 9/16 majority" ); } @@ -3037,27 +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 cover the lenient policy: - // - // - threshold lowered to a majority (`CANDIDATE_CLOSENESS_REQUIRED = - // 9/16`), and - // - a candidate counts if it is EITHER an exact match in the network's - // returned set OR within the XOR band the returned peers occupy - // (address-space proximity arm). + // without standing up a real DHT. They cover: // - // They also prove the security floor still holds: a forged pool whose - // candidates are far from the (un-ground) midpoint is rejected even at - // the wider K=32 window and the lower threshold. + // - 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. // - // Pool address used as the XOR midpoint: `[0u8; 32]`. - // Synthetic PeerIds put the tag in `bytes[0]`, so for the `[0u8; 32]` - // midpoint a smaller tag == smaller XOR distance == closer. That lets - // each test reason about both exact membership AND the address-space - // band by tag value alone. + // 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. @@ -3093,22 +3045,21 @@ mod tests { .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_accepts_honest_skew_via_exact_matches() { - // STG-01-style honest skew: the client's 16 candidates span - // network-true positions {1..=12, 17, 19, 21, 23}. With K=32 the - // network set is positions 1..=32, so all 16 are exact matches — - // trivially ≥ the 9/16 majority. + // 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) @@ -3119,47 +3070,17 @@ mod tests { let result = PaymentVerifier::check_closeness_match(&candidates, &network, &pool_address); assert!( result.is_ok(), - "honest pool fully inside the K=32 window must pass: {result:?}" + "honest pool fully inside the lookup window must pass: {result:?}" ); } #[test] - fn closeness_match_accepts_via_address_space_band_without_exact_match() { - // The leniency arm. The storer's lookup returned peers at positions - // {1..=8, 20..=43} (24 peers) — note positions 9..=19 are absent - // from THIS storer's view (churn / NAT). The payer's pool is the - // genuinely-closest 16: positions 1..=16. Positions 9..=16 are NOT - // in the storer's returned set, so exact matches = 8 (positions - // 1..=8) which is below 9/16. - // - // But the storer's returned set includes peers as far out as - // position 43, so the XOR band radius easily covers positions - // 9..=16 — they are closer to the midpoint than peers the storer - // itself returned. The address-space arm therefore counts all 16, - // and the pool passes. Exact-match-only would have rejected it. - let candidates: Vec = (1..=16u8).map(synthetic_peer_id).collect(); - let network: Vec = (1..=8u8).chain(20..=43u8).map(synthetic_peer_id).collect(); - let pool_address = [0u8; 32]; - - // Sanity: exact matches alone are only 8 (< 9), so a pass here can - // only come from the address-space arm. - let exact: usize = candidates.iter().filter(|c| network.contains(c)).count(); - assert_eq!(exact, 8, "precondition: only 8 exact matches"); - - let result = PaymentVerifier::check_closeness_match(&candidates, &network, &pool_address); - assert!( - result.is_ok(), - "pool within the returned XOR band must pass via the address-space arm: {result:?}" - ); - } - - #[test] - fn closeness_match_rejects_forged_pool_far_from_midpoint() { + fn closeness_match_rejects_forged_pool() { // Security floor: a fully-forged pool whose candidate PeerIds are - // both disjoint from the network set AND far outside the XOR band - // (tags 100..=115 vs a network band that ends at tag 32) must be - // rejected — neither arm should count them. The lower threshold and - // the address-space arm must NOT let off-network fabrications pass. + // 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]; @@ -3173,37 +3094,36 @@ mod tests { "expected forged-pool rejection message, got: {msg}" ); } - other => panic!( - "forged pool far outside the network's XOR band must be \ - rejected (security floor): {other:?}" - ), + other => { + panic!("forged pool disjoint from the network set must be rejected: {other:?}") + } } } #[test] fn closeness_match_rejects_pool_below_majority() { - // Threshold sanity: only 8 candidates qualify (8 exact in-band - // matches at tags 1..=8) and the other 8 are far-out fabrications - // (tags 100..=107) beyond the band. 8 < 9 → reject. + // 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 far fabrications + 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(), - "8 qualifying < majority of 9/16 must reject: {result:?}" + "8 matches < majority of 9/16 must reject: {result:?}" ); } #[test] fn closeness_match_accepts_at_exactly_majority() { - // Threshold sanity: exactly 9 candidates qualify (tags 1..=9, all - // exact in-band matches), the other 7 are far fabrications beyond - // the band (tags 100..=106). 9 ≥ 9 → accept. + // 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 far fabrications + 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]; From 921665bf10d5a8d73977162e048f1f7ededdecef Mon Sep 17 00:00:00 2001 From: Chris O'Neil Date: Tue, 2 Jun 2026 15:57:08 +0100 Subject: [PATCH 8/9] chore(release): roll rc-2026.5.5 to 0.11.6-rc.4 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2efb5dc..72e5104 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -809,7 +809,7 @@ dependencies = [ [[package]] name = "ant-node" -version = "0.11.6-rc.3" +version = "0.11.6-rc.4" dependencies = [ "alloy", "ant-protocol", diff --git a/Cargo.toml b/Cargo.toml index f0a0b7f..d0b4643 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ant-node" -version = "0.11.6-rc.3" +version = "0.11.6-rc.4" edition = "2021" authors = ["David Irvine "] description = "Pure quantum-proof network node for the Autonomi decentralized network" From cbec58e3b92938579dc3de2430f7930881c81019 Mon Sep 17 00:00:00 2001 From: Chris O'Neil Date: Wed, 3 Jun 2026 18:38:58 +0100 Subject: [PATCH 9/9] chore(release): promote rc-2026.5.5 to 0.11.6 --- Cargo.lock | 102 +++++++++++++++++++++++------------------------------ Cargo.toml | 6 ++-- 2 files changed, 47 insertions(+), 61 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 72e5104..4ca1a6b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -809,7 +809,7 @@ dependencies = [ [[package]] name = "ant-node" -version = "0.11.6-rc.4" +version = "0.11.6" dependencies = [ "alloy", "ant-protocol", @@ -861,8 +861,9 @@ dependencies = [ [[package]] name = "ant-protocol" -version = "2.1.2-rc.1" -source = "git+https://github.com/WithAutonomi/ant-protocol?branch=rc-2026.5.5#88ae80b6125e88342092ddec4c1a7f77b011caf4" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e950d12c9f6d08d0ea560573729d93f15e105d53b669defa682f5e6f92da4b1" dependencies = [ "blake3", "bytes", @@ -1315,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", ] @@ -1489,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", @@ -2960,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", @@ -3011,7 +3012,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2 0.6.4", "tokio", "tower-service", "tracing", @@ -3158,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", @@ -3519,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" @@ -3621,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", @@ -4228,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", @@ -4266,7 +4267,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.3", + "socket2 0.6.4", "tracing", "windows-sys 0.60.2", ] @@ -4279,7 +4280,7 @@ checksum = "76150b617afc75e6e21ac5f39bc196e80b65415ae48d62dbef8e2519d040ce42" dependencies = [ "cfg_aliases", "libc", - "socket2 0.6.3", + "socket2 0.6.4", "tracing", "windows-sys 0.61.2", ] @@ -4721,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", @@ -4865,8 +4866,9 @@ dependencies = [ [[package]] name = "saorsa-core" -version = "0.24.5-rc.1" -source = "git+https://github.com/saorsa-labs/saorsa-core?branch=rc-2026.5.5#7d8816996bfcf04a2a3d9069bb8c6327ac11b2af" +version = "0.24.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c0f8952fc5a4d37eb0bca7de0740830f40347f9da663effde3ddd6b68bcd2fb" dependencies = [ "anyhow", "async-trait", @@ -5037,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" @@ -5085,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" @@ -5323,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", @@ -5410,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" @@ -5489,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", @@ -5818,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", ] @@ -6104,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" @@ -6140,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" @@ -6205,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", @@ -7103,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 d0b4643..e89ab45 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ant-node" -version = "0.11.6-rc.4" +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 = { git = "https://github.com/WithAutonomi/ant-protocol", branch = "rc-2026.5.5" } +ant-protocol = "2.1.2" # Core (provides EVERYTHING: networking, DHT, security, trust, storage) -saorsa-core = { git = "https://github.com/saorsa-labs/saorsa-core", branch = "rc-2026.5.5" } +saorsa-core = "0.24.5" saorsa-pqc = "0.5" # Payment verification - autonomi network lookup + EVM payment