From 2e9599e5fd048fa9a880c608f6d084d90a7abcb8 Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Fri, 10 Jul 2026 00:27:48 +0200 Subject: [PATCH 1/2] fix: allow single-node payments with one quote --- ant-core/src/data/client/batch.rs | 171 +++++++++++++++++++-- ant-core/src/data/client/file.rs | 2 +- ant-core/src/data/client/payment.rs | 15 +- ant-core/src/data/client/quote.rs | 222 +++++++++++++++++++--------- ant-core/src/data/mod.rs | 4 +- ant-core/tests/e2e_payment.rs | 6 +- ant-core/tests/e2e_security.rs | 31 ++-- ant-core/tests/support/mod.rs | 3 - 8 files changed, 341 insertions(+), 113 deletions(-) diff --git a/ant-core/src/data/client/batch.rs b/ant-core/src/data/client/batch.rs index a30f67b3..c08755df 100644 --- a/ant-core/src/data/client/batch.rs +++ b/ant-core/src/data/client/batch.rs @@ -12,10 +12,10 @@ use crate::data::client::Client; use crate::data::error::{Error, PartialUploadSpend, Result}; use ant_protocol::evm::{ Amount, EncodedPeerId, PayForQuotesError, PaymentQuote, ProofOfPayment, QuoteHash, - RewardsAddress, TxHash, + RewardsAddress, TxHash, Wallet, }; use ant_protocol::payment::{ - deserialize_proof, serialize_single_node_proof, PaymentProof, SingleNodePayment, + deserialize_proof, serialize_single_node_proof, PaymentProof, QuotePaymentInfo, }; use ant_protocol::transport::{MultiAddr, PeerId}; use ant_protocol::{compute_address, XorName, DATA_TYPE_CHUNK}; @@ -29,6 +29,9 @@ use tracing::{debug, info, warn}; /// Number of chunks per payment wave. const PAYMENT_WAVE_SIZE: usize = 64; +/// Single-node payment pays the selected median quote at 3x its quoted price. +const SINGLE_NODE_PAYMENT_MULTIPLIER: u64 = 3; + /// Soft ceiling on the combined body size of chunks stored concurrently in a /// single wave. Caps store concurrency for large chunks so the send path's /// per-peer body buffers can't pin multiple GB at once (see V2-461). At ~4 MB @@ -36,6 +39,108 @@ const PAYMENT_WAVE_SIZE: usize = 64; /// / adaptive limits instead and are unaffected. const STORE_INFLIGHT_BYTE_BUDGET: usize = 64 * 1024 * 1024; +/// Variable-size single-node payment plan for a chunk. +/// +/// The shared `ant-protocol::payment::SingleNodePayment` helper still models +/// the legacy fixed `CLOSE_GROUP_SIZE` quote set. Node-side verification now +/// accepts any non-empty quote bundle up to `CLOSE_GROUP_SIZE`, so the client +/// keeps the same 3x-median payment rule while allowing the single-node path to +/// proceed with as few as one valid quote. +#[derive(Debug, Clone)] +pub struct SingleNodeQuotePayment { + /// Quotes sorted by price; the median-priced quote receives 3x payment and + /// the rest receive zero. + pub quotes: Vec, +} + +impl SingleNodeQuotePayment { + /// Build a single-node payment from one or more quotes. + /// + /// The quotes are sorted by price, the median quote receives 3x its quoted + /// price, and every other quote is included with a zero amount so proof and + /// payment intent construction stay aligned. + pub fn from_quotes(mut quotes_with_prices: Vec<(PaymentQuote, Amount)>) -> Result { + let quote_count = quotes_with_prices.len(); + if quote_count == 0 { + return Err(Error::Payment( + "Single-node payment requires at least 1 quote, got 0".to_string(), + )); + } + + quotes_with_prices.sort_by_key(|(_, price)| *price); + let median_index = quote_count / 2; + let median_price = quotes_with_prices + .get(median_index) + .ok_or_else(|| { + Error::Payment(format!( + "Missing median quote at index {median_index}: expected {quote_count} quotes but get() failed" + )) + })? + .1; + let enhanced_price = median_price + .checked_mul(Amount::from(SINGLE_NODE_PAYMENT_MULTIPLIER)) + .ok_or_else(|| { + Error::Payment("Price overflow when calculating 3x median".to_string()) + })?; + + let quotes = quotes_with_prices + .into_iter() + .enumerate() + .map(|(idx, (quote, price))| QuotePaymentInfo { + quote_hash: quote.hash(), + rewards_address: quote.rewards_address, + amount: if idx == median_index { + enhanced_price + } else { + Amount::ZERO + }, + price, + }) + .collect(); + + Ok(Self { quotes }) + } + + /// Total on-chain amount paid by this single-node payment. + #[must_use] + pub fn total_amount(&self) -> Amount { + self.quotes.iter().map(|q| q.amount).sum() + } + + /// Pay the non-zero median quote on-chain, returning tx hashes for the + /// non-zero entries that must appear in the payment proof. + pub async fn pay(&self, wallet: &Wallet) -> Result> { + let quote_payments: Vec<_> = self + .quotes + .iter() + .map(|q| (q.quote_hash, q.rewards_address, q.amount)) + .collect(); + + let (tx_hashes, _gas_info) = + wallet + .pay_for_quotes(quote_payments) + .await + .map_err(|PayForQuotesError(err, _)| { + Error::Payment(format!("Failed to pay for quotes: {err}")) + })?; + + let mut result_hashes = Vec::new(); + for quote_info in &self.quotes { + if !quote_info.amount.is_zero() { + let tx_hash = tx_hashes.get("e_info.quote_hash).ok_or_else(|| { + Error::Payment(format!( + "Missing transaction hash for non-zero quote {}", + quote_info.quote_hash + )) + })?; + result_hashes.push(*tx_hash); + } + } + + Ok(result_hashes) + } +} + /// Chunk quoted but not yet paid. Produced by [`Client::prepare_chunk_payment`]. #[derive(Debug)] pub struct PreparedChunk { @@ -50,7 +155,7 @@ pub struct PreparedChunk { /// group. pub quoted_peers: Vec<(PeerId, Vec)>, /// Payment structure (quotes sorted, median selected, not yet paid on-chain). - pub payment: SingleNodePayment, + pub payment: SingleNodeQuotePayment, /// Peer quotes for building `ProofOfPayment`. pub peer_quotes: Vec<(EncodedPeerId, PaymentQuote)>, /// ADR-0004: the signed commitments the bound quotes shipped, forwarded as @@ -275,7 +380,7 @@ impl Client { // wider than the peers that supplied the paid quotes. let quoted_peers = quote_plan.put_peers; - // Build peer_quotes for ProofOfPayment + quotes for SingleNodePayment. + // Build peer_quotes for ProofOfPayment + quotes for single-node payment. // Use node-reported prices directly — no contract price fetch needed. let mut peer_quotes = Vec::with_capacity(quotes_with_peers.len()); let mut quotes_for_payment = Vec::with_capacity(quotes_with_peers.len()); @@ -292,7 +397,7 @@ impl Client { } } - let payment = SingleNodePayment::from_quotes(quotes_for_payment) + let payment = SingleNodeQuotePayment::from_quotes(quotes_for_payment) .map_err(|e| Error::Payment(format!("Failed to create payment: {e}")))?; Ok(Some(PreparedChunk { @@ -1083,7 +1188,6 @@ mod send_assertions { #[allow(clippy::unwrap_used)] mod tests { use super::*; - use ant_protocol::payment::QuotePaymentInfo; use ant_protocol::CLOSE_GROUP_SIZE; /// Median index in the quotes array. @@ -1093,26 +1197,61 @@ mod tests { /// quote index and zero for all other quotes. Adapts automatically to /// `CLOSE_GROUP_SIZE` changes. fn make_prepared_chunk(median_amount: u64) -> PreparedChunk { - let quotes: [QuotePaymentInfo; CLOSE_GROUP_SIZE] = std::array::from_fn(|i| { - let amount = if i == MEDIAN_INDEX { median_amount } else { 0 }; - QuotePaymentInfo { - quote_hash: QuoteHash::from([i as u8 + 1; 32]), - rewards_address: RewardsAddress::new([i as u8 + 10; 20]), - amount: Amount::from(amount), - price: Amount::from(amount), - } - }); + let quotes: Vec = (0..CLOSE_GROUP_SIZE) + .map(|i| { + let amount = if i == MEDIAN_INDEX { median_amount } else { 0 }; + QuotePaymentInfo { + quote_hash: QuoteHash::from([i as u8 + 1; 32]), + rewards_address: RewardsAddress::new([i as u8 + 10; 20]), + amount: Amount::from(amount), + price: Amount::from(amount), + } + }) + .collect(); PreparedChunk { content: Bytes::from(vec![0xAA; 32]), address: [0u8; 32], quoted_peers: Vec::new(), - payment: SingleNodePayment { quotes }, + payment: SingleNodeQuotePayment { quotes }, peer_quotes: Vec::new(), commitment_sidecars: Vec::new(), } } + #[test] + fn single_node_quote_payment_accepts_one_quote() { + let quote = PaymentQuote { + content: xor_name::XorName([0u8; 32]), + timestamp: std::time::SystemTime::UNIX_EPOCH, + price: Amount::from(11u64), + rewards_address: RewardsAddress::new([7u8; 20]), + pub_key: Vec::new(), + signature: Vec::new(), + committed_key_count: 0, + commitment_pin: None, + }; + + let payment = SingleNodeQuotePayment::from_quotes(vec![(quote.clone(), quote.price)]) + .expect("one quote should be enough for SNP payment"); + + assert_eq!(payment.quotes.len(), 1); + assert_eq!(payment.quotes[0].quote_hash, quote.hash()); + assert_eq!(payment.quotes[0].rewards_address, quote.rewards_address); + assert_eq!(payment.quotes[0].amount, Amount::from(33u64)); + assert_eq!(payment.total_amount(), Amount::from(33u64)); + } + + #[test] + fn single_node_quote_payment_rejects_zero_quotes() { + let err = SingleNodeQuotePayment::from_quotes(Vec::new()) + .expect_err("empty SNP quote sets must remain invalid"); + assert!( + err.to_string().contains("at least 1 quote"), + "unexpected error: {err}" + ); + } + #[test] fn payment_intent_from_single_chunk() { let chunk = make_prepared_chunk(300); diff --git a/ant-core/src/data/client/file.rs b/ant-core/src/data/client/file.rs index 960399fd..8716d11d 100644 --- a/ant-core/src/data/client/file.rs +++ b/ant-core/src/data/client/file.rs @@ -1342,7 +1342,7 @@ impl Client { } }; - // Use the median price × 3 (matches SingleNodePayment::from_quotes + // Use the median price × 3 (matches the single-node payment builder // which pays 3x the median to incentivize reliable storage). let mut prices: Vec = quotes.iter().map(|(_, _, _, price, _)| *price).collect(); prices.sort(); diff --git a/ant-core/src/data/client/payment.rs b/ant-core/src/data/client/payment.rs index 859ded4a..c3960585 100644 --- a/ant-core/src/data/client/payment.rs +++ b/ant-core/src/data/client/payment.rs @@ -3,11 +3,12 @@ //! Connects quote collection, on-chain EVM payment, and proof serialization. //! Every PUT to the network requires a valid payment proof. +use crate::data::client::batch::SingleNodeQuotePayment; use crate::data::client::quote::median_paid_quote_issuer; use crate::data::client::Client; use crate::data::error::{Error, Result}; use ant_protocol::evm::{EncodedPeerId, ProofOfPayment, Wallet}; -use ant_protocol::payment::{serialize_single_node_proof, PaymentProof, SingleNodePayment}; +use ant_protocol::payment::{serialize_single_node_proof, PaymentProof}; use ant_protocol::transport::{MultiAddr, PeerId}; use std::sync::Arc; use tracing::{debug, info}; @@ -23,8 +24,8 @@ impl Client { /// Pay for storage and return the serialized payment proof bytes. /// /// This orchestrates the full payment flow: - /// 1. Collect `CLOSE_GROUP_SIZE` quotes plus ordered PUT targets - /// 2. Build `SingleNodePayment` using node-reported prices (median 3x, others 0) + /// 1. Collect at least one witnessed quote plus ordered PUT targets + /// 2. Build single-node payment using node-reported prices (median 3x, others 0) /// 3. Pay on-chain via the wallet /// 4. Serialize `PaymentProof` with transaction hashes /// @@ -48,7 +49,7 @@ impl Client { debug!("Collecting quotes for address {}", hex::encode(address)); - // 1. Collect quotes from network + // 1. Collect at least one witnessed quote from the network let quote_plan = self .get_store_quote_plan(address, data_size, data_type) .await?; @@ -64,7 +65,7 @@ impl Client { // This can be wider than the peers that supplied the paid quotes. let quoted_peers = quote_plan.put_peers; - // 2. Build peer_quotes for ProofOfPayment + quotes for SingleNodePayment. + // 2. Build peer_quotes for ProofOfPayment + quotes for single-node payment. // Use node-reported prices directly — no contract price fetch needed. let mut peer_quotes = Vec::with_capacity(quotes_with_peers.len()); let mut quotes_for_payment = Vec::with_capacity(quotes_with_peers.len()); @@ -84,8 +85,8 @@ impl Client { } } - // 3. Create SingleNodePayment (sorts by price, selects median) - let payment = SingleNodePayment::from_quotes(quotes_for_payment) + // 3. Create single-node payment (sorts by price, selects median) + let payment = SingleNodeQuotePayment::from_quotes(quotes_for_payment) .map_err(|e| Error::Payment(format!("Failed to create payment: {e}")))?; info!( diff --git a/ant-core/src/data/client/quote.rs b/ant-core/src/data/client/quote.rs index 3143d71d..e6854e85 100644 --- a/ant-core/src/data/client/quote.rs +++ b/ant-core/src/data/client/quote.rs @@ -42,8 +42,8 @@ const WITNESSED_QUORUM_DENOMINATOR: usize = 3; /// Number of closest nodes each initial witnessed responder contributes. const SINGLE_NODE_WITNESSED_VIEW_COUNT: usize = 20; -/// Index of the paid median quote after sorting by quoted price. -const MEDIAN_QUOTE_INDEX: usize = CLOSE_GROUP_SIZE / 2; +/// Minimum quote count accepted by the single-node payment path. +const SINGLE_NODE_MIN_QUOTE_COUNT: usize = 1; /// Overall timeout for collecting quote responses. Must accommodate /// connect_with_fallback cascade (direct 5s + hole-punch 15s×3 + relay 30s ≈ @@ -430,6 +430,10 @@ fn single_node_quote_query_count() -> usize { CLOSE_GROUP_SIZE } +fn single_node_min_quote_count() -> usize { + SINGLE_NODE_MIN_QUOTE_COUNT +} + fn fault_tolerant_quote_query_count() -> usize { CLOSE_GROUP_SIZE * FAULT_TOLERANT_QUOTE_QUERY_MULTIPLIER } @@ -738,10 +742,12 @@ fn witnessed_quote_selection_or_error( } pub(crate) fn median_paid_quote_issuer(quotes: &[StoreQuote]) -> Option<(PeerId, Amount)> { - if quotes.len() <= MEDIAN_QUOTE_INDEX { + if quotes.is_empty() { return None; } + let median_quote_index = quotes.len() / 2; + let mut by_price: Vec<(usize, PeerId, Amount)> = quotes .iter() .enumerate() @@ -749,7 +755,7 @@ pub(crate) fn median_paid_quote_issuer(quotes: &[StoreQuote]) -> Option<(PeerId, .collect(); by_price.sort_by_key(|(index, _, price)| (*price, *index)); by_price - .get(MEDIAN_QUOTE_INDEX) + .get(median_quote_index) .map(|(_, peer_id, price)| (*peer_id, *price)) } @@ -765,10 +771,12 @@ fn median_paid_quote_issuer_for_indices( quotes: &[StoreQuote], indices: &[usize], ) -> Option<(PeerId, Amount)> { - if indices.len() <= MEDIAN_QUOTE_INDEX { + if indices.is_empty() { return None; } + let median_quote_index = indices.len() / 2; + let mut by_price: Vec<(usize, PeerId, Amount)> = indices .iter() .enumerate() @@ -779,7 +787,7 @@ fn median_paid_quote_issuer_for_indices( .collect(); by_price.sort_by_key(|(selected_index, _, price)| (*price, *selected_index)); by_price - .get(MEDIAN_QUOTE_INDEX) + .get(median_quote_index) .map(|(_, peer_id, price)| (*peer_id, *price)) } @@ -827,43 +835,56 @@ fn select_witnessed_median_voter_quotes( address: &[u8; 32], voters_by_peer: &VotersByPeer, required_support: usize, + min_quote_count: usize, + target_quote_count: usize, ) -> Option> { - if quotes.len() < CLOSE_GROUP_SIZE { + if min_quote_count == 0 + || target_quote_count < min_quote_count + || quotes.len() < min_quote_count + { return None; } sort_quotes_by_distance(&mut quotes, address); - let mut best_indices: Option<(usize, Vec)> = None; - let mut current_indices = Vec::with_capacity(CLOSE_GROUP_SIZE); - visit_quote_subsets( - quotes.len(), - CLOSE_GROUP_SIZE, - 0, - &mut current_indices, - &mut |indices| { - let Some((_, support)) = median_issuer_voter_support("es, indices, voters_by_peer) - else { - return; - }; - if support < required_support { - return; - } - match &best_indices { - Some((best_support, best)) if *best_support > support => {} - Some((best_support, best)) - if *best_support == support && best.as_slice() <= indices => {} - _ => best_indices = Some((support, indices.to_vec())), - } - }, - ); + let max_quote_count = target_quote_count.min(quotes.len()); + for quote_count in (min_quote_count..=max_quote_count).rev() { + let mut best_indices: Option<(usize, Vec)> = None; + let mut current_indices = Vec::with_capacity(quote_count); + visit_quote_subsets( + quotes.len(), + quote_count, + 0, + &mut current_indices, + &mut |indices| { + let Some((_, support)) = + median_issuer_voter_support("es, indices, voters_by_peer) + else { + return; + }; + if support < required_support { + return; + } + match &best_indices { + Some((best_support, best)) if *best_support > support => {} + Some((best_support, best)) + if *best_support == support && best.as_slice() <= indices => {} + _ => best_indices = Some((support, indices.to_vec())), + } + }, + ); - best_indices.map(|(_, indices)| { - indices - .into_iter() - .map(|index| quotes[index].clone()) - .collect() - }) + if let Some((_, indices)) = best_indices { + return Some( + indices + .into_iter() + .map(|index| quotes[index].clone()) + .collect(), + ); + } + } + + None } fn put_peers_with_median_voters_first( @@ -897,12 +918,11 @@ fn put_peers_with_median_voters_first( impl Client { /// Get storage quotes from the closest peers for a given address. /// - /// Builds a quorum-witnessed candidate set with at least - /// `CLOSE_GROUP_SIZE` peers, requests quotes from all of them concurrently, - /// and returns the closest supported `CLOSE_GROUP_SIZE` successful - /// responders. When multiple sets are possible, the client prefers the - /// one with the strongest paid-median voter support, then the closest - /// peers by XOR distance. + /// Builds a quorum-witnessed candidate set, still attempts to collect the + /// close-group quote count, and returns the largest supported successful + /// quote set. The single-node path now only requires one valid quote to + /// proceed, but still pays the median quote from the selected set when more + /// quotes were successfully fetched. /// /// Returns `Error::AlreadyStored` early if `CLOSE_GROUP_MAJORITY` peers /// report the chunk is already stored. @@ -1010,8 +1030,10 @@ impl Client { &self, address: &[u8; 32], ) -> Result { - // The quote/quorum/consensus scope is the closest CLOSE_GROUP_SIZE. - let required = single_node_quote_query_count(); + // Query the close-group width, but single-node payment now only needs + // one valid witnessed quote to proceed. + let close_group_query_count = single_node_quote_query_count(); + let required_quotes = single_node_min_quote_count(); // Contact the closest PUT_TARGET_WIDTH peers directly so the whole // PUT-target set's addresses arrive in this single query. A network // with fewer than that near the target can't satisfy the wide lookup, @@ -1031,12 +1053,12 @@ impl Client { debug!( target = %hex::encode(address), "Wide witnessed lookup ({PUT_TARGET_WIDTH}) failed ({wide_err}); \ - retrying at close-group width ({required})" + retrying at close-group width ({close_group_query_count})" ); self.network() .find_witnessed_close_group_with_view_count( address, - required, + close_group_query_count, SINGLE_NODE_WITNESSED_VIEW_COUNT, ) .await @@ -1079,7 +1101,7 @@ impl Client { ); let mut selection = - witnessed_quote_selection_or_error(address, &witnessed_quote, required, quorum)?; + witnessed_quote_selection_or_error(address, &witnessed_quote, required_quotes, quorum)?; // Widen the PUT-target set to the closest PUT_TARGET_WIDTH // directly-contacted peers; the quote set above stays the closest // CLOSE_GROUP_SIZE. The same proof is reused on all of them. @@ -1110,13 +1132,23 @@ impl Client { hex::encode(address) ); - if remote_peers.len() < CLOSE_GROUP_SIZE { + let min_quote_count = match "e_selection_policy { + QuoteSelectionPolicy::ClosestByDistance => CLOSE_GROUP_SIZE, + QuoteSelectionPolicy::WitnessedMedianVoters { .. } => single_node_min_quote_count(), + }; + let target_quote_count = match "e_selection_policy { + QuoteSelectionPolicy::ClosestByDistance => CLOSE_GROUP_SIZE, + QuoteSelectionPolicy::WitnessedMedianVoters { .. } => single_node_quote_query_count(), + } + .min(peer_query_count); + + if remote_peers.len() < min_quote_count { return Err(Error::InsufficientPeers(format!( - "Found {} peers, need {CLOSE_GROUP_SIZE}", - remote_peers.len() + "Found {} peers, need {min_quote_count}", + remote_peers.len(), ))); } - debug_assert!(peer_query_count >= CLOSE_GROUP_SIZE); + debug_assert!(peer_query_count >= min_quote_count); let per_peer_timeout = Duration::from_secs(self.config().quote_timeout_secs); let overall_timeout = Duration::from_secs(QUOTE_COLLECTION_TIMEOUT_SECS); @@ -1165,7 +1197,7 @@ impl Client { )); } - if quotes.len() >= CLOSE_GROUP_SIZE || quote_futures.is_empty() { + if quotes.len() >= target_quote_count || quote_futures.is_empty() { break; } @@ -1297,25 +1329,32 @@ impl Client { let quote_count = quotes.len(); let total_responses = quote_count + failure_count + already_stored_count; - if quotes.len() >= CLOSE_GROUP_SIZE { + if quotes.len() >= min_quote_count { let selected_quotes = match quote_selection_policy { QuoteSelectionPolicy::ClosestByDistance => select_closest_quotes(quotes, address), QuoteSelectionPolicy::WitnessedMedianVoters { voters_by_peer, quorum, - } => select_witnessed_median_voter_quotes(quotes, address, &voters_by_peer, quorum) - .ok_or_else(|| { - Error::InsufficientPeers(format!( - "Got {quote_count} quotes, need {CLOSE_GROUP_SIZE} whose paid \ + } => select_witnessed_median_voter_quotes( + quotes, + address, + &voters_by_peer, + quorum, + min_quote_count, + target_quote_count, + ) + .ok_or_else(|| { + Error::InsufficientPeers(format!( + "Got {quote_count} quotes, need at least {min_quote_count} whose paid \ median issuer is recognised by at least {} \ selected witness peers ({total_responses} responses: \ {already_stored_count} already_stored, {failure_count} failed \ including {bad_quote_count} with mismatched peer bindings). \ Failures: [{}]", - quorum, - failures.join("; ") - )) - })?, + quorum, + failures.join("; ") + )) + })?, }; info!( @@ -1329,7 +1368,7 @@ impl Client { } Err(Error::InsufficientPeers(format!( - "Got {quote_count} quotes, need {CLOSE_GROUP_SIZE} ({total_responses} responses: \ + "Got {quote_count} quotes, need {min_quote_count} ({total_responses} responses: \ {already_stored_count} already_stored, {failure_count} failed including \ {bad_quote_count} with mismatched peer bindings). Failures: [{}]", failures.join("; ") @@ -1607,6 +1646,7 @@ mod tests { #[test] fn quote_query_counts_keep_single_node_close_group_only() { assert_eq!(single_node_quote_query_count(), CLOSE_GROUP_SIZE); + assert_eq!(single_node_min_quote_count(), 1); assert_eq!(SINGLE_NODE_WITNESSED_VIEW_COUNT, 20); assert!(SINGLE_NODE_WITNESSED_VIEW_COUNT > single_node_quote_query_count()); assert_eq!(witnessed_close_group_quorum(), 5); @@ -1936,9 +1976,15 @@ mod tests { ); let quorum = witnessed_close_group_quorum(); - let selected = - select_witnessed_median_voter_quotes(quotes, &address, &voters_by_peer, quorum) - .expect("a supported close-group quote set should be selected"); + let selected = select_witnessed_median_voter_quotes( + quotes, + &address, + &voters_by_peer, + quorum, + single_node_min_quote_count(), + CLOSE_GROUP_SIZE, + ) + .expect("a supported close-group quote set should be selected"); assert_eq!(quote_peer_seeds(&selected), vec![1, 2, 3, 6, 7, 8, 9]); let (median_peer_id, _) = @@ -1968,9 +2014,15 @@ mod tests { ); let quorum = witnessed_close_group_quorum(); - let selected = - select_witnessed_median_voter_quotes(quotes, &address, &voters_by_peer, quorum) - .expect("direct witness recognition should support the paid median issuer"); + let selected = select_witnessed_median_voter_quotes( + quotes, + &address, + &voters_by_peer, + quorum, + single_node_min_quote_count(), + CLOSE_GROUP_SIZE, + ) + .expect("direct witness recognition should support the paid median issuer"); let (median_peer_id, _) = median_paid_quote_issuer(&selected).expect("selected quotes have a median"); @@ -1989,6 +2041,38 @@ mod tests { assert_eq!(voters_by_peer[&median_peer_id].len(), quorum); } + #[test] + fn witnessed_quote_selection_allows_single_required_quote() { + const QUOTE_ISSUER_SEED: u8 = 7; + + let address = [0u8; 32]; + let quotes = vec![ + synthetic_quote(QUOTE_ISSUER_SEED, 10), + synthetic_quote(1, 20), + synthetic_quote(2, 30), + ]; + let mut voters_by_peer = HashMap::new(); + voters_by_peer.insert( + synthetic_peer(QUOTE_ISSUER_SEED), + synthetic_voters(&[1, 2, 3, 4, 5]), + ); + + let selected = select_witnessed_median_voter_quotes( + quotes, + &address, + &voters_by_peer, + witnessed_close_group_quorum(), + single_node_min_quote_count(), + CLOSE_GROUP_SIZE, + ) + .expect("one quorum-supported quote is enough for SNP payment"); + + assert_eq!(quote_peer_seeds(&selected), vec![QUOTE_ISSUER_SEED]); + let (median_peer_id, _) = + median_paid_quote_issuer(&selected).expect("single quote is its own median"); + assert_eq!(median_peer_id, synthetic_peer(QUOTE_ISSUER_SEED)); + } + #[test] fn witnessed_quote_selection_rejects_median_without_witness_quorum() { const MEDIAN_ISSUER_SEED: u8 = 7; @@ -2015,6 +2099,8 @@ mod tests { &address, &voters_by_peer, witnessed_close_group_quorum(), + single_node_min_quote_count(), + CLOSE_GROUP_SIZE, ); assert!( diff --git a/ant-core/src/data/mod.rs b/ant-core/src/data/mod.rs index 5a5afaee..07dbccde 100644 --- a/ant-core/src/data/mod.rs +++ b/ant-core/src/data/mod.rs @@ -22,7 +22,9 @@ pub use crate::node::devnet::LocalDevnet; pub use ant_protocol::{compute_address, DataChunk, XorName}; // Re-export client data types -pub use client::batch::{finalize_batch_payment, PaidChunk, PaymentIntent, PreparedChunk}; +pub use client::batch::{ + finalize_batch_payment, PaidChunk, PaymentIntent, PreparedChunk, SingleNodeQuotePayment, +}; pub use client::data::DataUploadResult; pub use client::file::{ CostEstimateConfidence, DownloadEvent, ExternalPaymentInfo, FileChunkPeerReport, diff --git a/ant-core/tests/e2e_payment.rs b/ant-core/tests/e2e_payment.rs index 311b608a..6d69d90e 100644 --- a/ant-core/tests/e2e_payment.rs +++ b/ant-core/tests/e2e_payment.rs @@ -254,10 +254,10 @@ async fn test_quote_collection() { .await .expect("get_store_quotes should succeed"); - // At least 5 quotes required + // Single-node payment now needs only one valid witnessed quote. assert!( - quotes.len() >= 5, - "Should receive at least 5 quotes, got {}", + !quotes.is_empty(), + "Should receive at least one quote, got {}", quotes.len() ); diff --git a/ant-core/tests/e2e_security.rs b/ant-core/tests/e2e_security.rs index 5b91ed4f..756f2fe9 100644 --- a/ant-core/tests/e2e_security.rs +++ b/ant-core/tests/e2e_security.rs @@ -7,14 +7,14 @@ mod support; -use ant_core::data::{compute_address, Client}; +use ant_core::data::{compute_address, Client, SingleNodeQuotePayment}; use ant_protocol::evm::{Amount, EncodedPeerId, ProofOfPayment, RewardsAddress, TxHash}; -use ant_protocol::payment::{serialize_single_node_proof, PaymentProof, SingleNodePayment}; +use ant_protocol::payment::{serialize_single_node_proof, PaymentProof}; use ant_protocol::transport::PeerId; use bytes::Bytes; use serial_test::serial; use std::sync::Arc; -use support::{test_client_config, MiniTestnet, DEFAULT_NODE_COUNT, MEDIAN_QUOTE_INDEX}; +use support::{test_client_config, MiniTestnet, DEFAULT_NODE_COUNT}; async fn setup() -> (Client, MiniTestnet) { let testnet = MiniTestnet::start(DEFAULT_NODE_COUNT).await; @@ -53,7 +53,8 @@ async fn collect_and_pay(client: &Client, content: &Bytes) -> (PaymentProof, Vec } // Pay on-chain - let payment = SingleNodePayment::from_quotes(quotes_for_payment).expect("payment creation"); + let payment = + SingleNodeQuotePayment::from_quotes(quotes_for_payment).expect("payment creation"); let wallet = client.wallet().expect("wallet should be set"); let tx_hashes = payment.pay(wallet).await.expect("on-chain payment"); @@ -370,7 +371,7 @@ async fn test_attack_client_without_wallet() { // ─── Test 9: Underpayment — Single Node ───────────────────────────────────── // -// Collects real quotes, builds a valid SingleNodePayment, then tampers with +// Collects real quotes, builds a valid single-node payment, then tampers with // the median quote's amount (reducing it to 1 atto). Pays on-chain with the // reduced amount. The node's on-chain verifyPayment check should detect that // the paid amount is far below the expected 3× median price and reject the PUT. @@ -397,7 +398,7 @@ async fn test_attack_underpayment_single_node() { .map(|(pid, _, q, _, _)| (*pid, q.rewards_address)) .collect(); - // 2. Build SingleNodePayment normally (sorts by price, median gets 3×) + // 2. Build single-node payment normally (sorts by price, median gets 3×) let mut peer_quotes = Vec::with_capacity(quotes.len()); let mut quotes_for_payment = Vec::with_capacity(quotes.len()); let mut commitment_sidecars = Vec::new(); @@ -410,13 +411,14 @@ async fn test_attack_underpayment_single_node() { } } - let mut payment = SingleNodePayment::from_quotes(quotes_for_payment) + let mut payment = SingleNodeQuotePayment::from_quotes(quotes_for_payment) .expect("payment creation should succeed"); // Target the median peer specifically — it's the only one that receives // payment, so only it will detect the amount mismatch in verifyPayment(). - let original_amount = payment.quotes[MEDIAN_QUOTE_INDEX].amount; - let median_rewards = payment.quotes[MEDIAN_QUOTE_INDEX].rewards_address; + let median_quote_index = payment.quotes.len() / 2; + let original_amount = payment.quotes[median_quote_index].amount; + let median_rewards = payment.quotes[median_quote_index].rewards_address; let target_peer = peer_by_rewards .iter() .find(|(_, addr)| *addr == median_rewards) @@ -428,7 +430,7 @@ async fn test_attack_underpayment_single_node() { ); // 3. Tamper: reduce median payment to 1 atto (should be 3× median price) - payment.quotes[MEDIAN_QUOTE_INDEX].amount = Amount::from(1u64); + payment.quotes[median_quote_index].amount = Amount::from(1u64); // 4. Pay on-chain with the reduced amount — the contract records whatever // amount is sent, it only validates amounts in verifyPayment() @@ -502,13 +504,14 @@ async fn test_attack_underpayment_half_price() { } } - let mut payment = SingleNodePayment::from_quotes(quotes_for_payment) + let mut payment = SingleNodeQuotePayment::from_quotes(quotes_for_payment) .expect("payment creation should succeed"); // Halve the median payment (3× → ~1.5×). // Target the median peer — only it verifies the amount it received. - let original_amount = payment.quotes[MEDIAN_QUOTE_INDEX].amount; - let median_rewards = payment.quotes[MEDIAN_QUOTE_INDEX].rewards_address; + let median_quote_index = payment.quotes.len() / 2; + let original_amount = payment.quotes[median_quote_index].amount; + let median_rewards = payment.quotes[median_quote_index].rewards_address; let target_peer = peer_by_rewards .iter() .find(|(_, addr)| *addr == median_rewards) @@ -519,7 +522,7 @@ async fn test_attack_underpayment_half_price() { !half_amount.is_zero(), "Half of original amount should still be non-zero" ); - payment.quotes[MEDIAN_QUOTE_INDEX].amount = half_amount; + payment.quotes[median_quote_index].amount = half_amount; let wallet = client.wallet().expect("wallet should be set"); let tx_hashes = payment diff --git a/ant-core/tests/support/mod.rs b/ant-core/tests/support/mod.rs index 9486fecc..ef0b8d6c 100644 --- a/ant-core/tests/support/mod.rs +++ b/ant-core/tests/support/mod.rs @@ -64,9 +64,6 @@ const STABILIZATION_TIMEOUT_SECS: u64 = 180; /// spawn delay) compared to a flaky suite. pub const DEFAULT_NODE_COUNT: usize = CLOSE_GROUP_SIZE * 2; -/// Index of the median quote in a `SingleNodePayment` quotes array. -pub const MEDIAN_QUOTE_INDEX: usize = CLOSE_GROUP_SIZE / 2; - /// Test rewards address (20 bytes, all 0x01). const TEST_REWARDS_ADDRESS: [u8; 20] = [0x01; 20]; /// Max records for quoting metrics. From b4f0ad80203b92535f137e74c00df05f172541f3 Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:25:16 +0200 Subject: [PATCH 2/2] refactor: simplify quote handling and adjust single-node payment thresholds - Remove unused `price` from loops processing quotes. - Replace inline constants with `SINGLE_NODE_PAYMENT_MULTIPLIER` and `SINGLE_NODE_MIN_QUOTE_COUNT`. - Enforce quote count constraints and sort by price in single-node payment calculations. - Add tests for quote processing, extended range validation, and payment behavior. --- ant-core/src/data/client/batch.rs | 156 +++++++++++++++++++--------- ant-core/src/data/client/file.rs | 3 +- ant-core/src/data/client/payment.rs | 7 +- ant-core/src/data/client/quote.rs | 130 +++++++++++------------ ant-core/tests/e2e_payment.rs | 7 +- ant-core/tests/e2e_security.rs | 12 +-- 6 files changed, 190 insertions(+), 125 deletions(-) diff --git a/ant-core/src/data/client/batch.rs b/ant-core/src/data/client/batch.rs index c08755df..9a62b4c4 100644 --- a/ant-core/src/data/client/batch.rs +++ b/ant-core/src/data/client/batch.rs @@ -7,7 +7,7 @@ use crate::data::client::adaptive::observe_op; use crate::data::client::classify_error; use crate::data::client::file::UploadEvent; -use crate::data::client::payment::peer_id_to_encoded; +use crate::data::client::payment::{peer_id_to_encoded, SINGLE_NODE_PAYMENT_MULTIPLIER}; use crate::data::client::Client; use crate::data::error::{Error, PartialUploadSpend, Result}; use ant_protocol::evm::{ @@ -18,7 +18,7 @@ use ant_protocol::payment::{ deserialize_proof, serialize_single_node_proof, PaymentProof, QuotePaymentInfo, }; use ant_protocol::transport::{MultiAddr, PeerId}; -use ant_protocol::{compute_address, XorName, DATA_TYPE_CHUNK}; +use ant_protocol::{compute_address, XorName, CLOSE_GROUP_SIZE, DATA_TYPE_CHUNK}; use bytes::Bytes; use futures::stream::{self, FuturesUnordered, StreamExt}; use std::collections::{HashMap, HashSet}; @@ -29,9 +29,6 @@ use tracing::{debug, info, warn}; /// Number of chunks per payment wave. const PAYMENT_WAVE_SIZE: usize = 64; -/// Single-node payment pays the selected median quote at 3x its quoted price. -const SINGLE_NODE_PAYMENT_MULTIPLIER: u64 = 3; - /// Soft ceiling on the combined body size of chunks stored concurrently in a /// single wave. Caps store concurrency for large chunks so the send path's /// per-peer body buffers can't pin multiple GB at once (see V2-461). At ~4 MB @@ -59,42 +56,38 @@ impl SingleNodeQuotePayment { /// The quotes are sorted by price, the median quote receives 3x its quoted /// price, and every other quote is included with a zero amount so proof and /// payment intent construction stay aligned. - pub fn from_quotes(mut quotes_with_prices: Vec<(PaymentQuote, Amount)>) -> Result { - let quote_count = quotes_with_prices.len(); - if quote_count == 0 { - return Err(Error::Payment( - "Single-node payment requires at least 1 quote, got 0".to_string(), - )); + pub fn from_quotes(mut quotes: Vec) -> Result { + let quote_count = quotes.len(); + if !(1..=CLOSE_GROUP_SIZE).contains("e_count) { + return Err(Error::Payment(format!( + "Single-node payment requires 1..={CLOSE_GROUP_SIZE} quotes, got {quote_count}" + ))); } - quotes_with_prices.sort_by_key(|(_, price)| *price); + quotes.sort_by_key(|quote| quote.price); let median_index = quote_count / 2; - let median_price = quotes_with_prices - .get(median_index) - .ok_or_else(|| { - Error::Payment(format!( - "Missing median quote at index {median_index}: expected {quote_count} quotes but get() failed" - )) - })? - .1; + let median_price = quotes[median_index].price; let enhanced_price = median_price .checked_mul(Amount::from(SINGLE_NODE_PAYMENT_MULTIPLIER)) .ok_or_else(|| { Error::Payment("Price overflow when calculating 3x median".to_string()) })?; - let quotes = quotes_with_prices + let quotes = quotes .into_iter() .enumerate() - .map(|(idx, (quote, price))| QuotePaymentInfo { - quote_hash: quote.hash(), - rewards_address: quote.rewards_address, - amount: if idx == median_index { - enhanced_price - } else { - Amount::ZERO - }, - price, + .map(|(idx, quote)| { + let quote_hash = quote.hash(); + QuotePaymentInfo { + quote_hash, + rewards_address: quote.rewards_address, + amount: if idx == median_index { + enhanced_price + } else { + Amount::ZERO + }, + price: quote.price, + } }) .collect(); @@ -388,10 +381,10 @@ impl Client { // quotes ship none); `get_store_quotes` already verified the binding. let mut commitment_sidecars = Vec::new(); - for (peer_id, _addrs, quote, price, commitment) in quotes_with_peers { + for (peer_id, _addrs, quote, _price, commitment) in quotes_with_peers { let encoded = peer_id_to_encoded(&peer_id)?; peer_quotes.push((encoded, quote.clone())); - quotes_for_payment.push((quote, price)); + quotes_for_payment.push(quote); if let Some(sidecar) = commitment { commitment_sidecars.push(sidecar); } @@ -1188,7 +1181,7 @@ mod send_assertions { #[allow(clippy::unwrap_used)] mod tests { use super::*; - use ant_protocol::CLOSE_GROUP_SIZE; + use ant_protocol::payment::SingleNodePayment; /// Median index in the quotes array. const MEDIAN_INDEX: usize = CLOSE_GROUP_SIZE / 2; @@ -1219,27 +1212,79 @@ mod tests { } } - #[test] - fn single_node_quote_payment_accepts_one_quote() { - let quote = PaymentQuote { - content: xor_name::XorName([0u8; 32]), + fn payment_quote(seed: u8, price: u64) -> PaymentQuote { + PaymentQuote { + content: xor_name::XorName([seed; 32]), timestamp: std::time::SystemTime::UNIX_EPOCH, - price: Amount::from(11u64), - rewards_address: RewardsAddress::new([7u8; 20]), + price: Amount::from(price), + rewards_address: RewardsAddress::new([seed; 20]), pub_key: Vec::new(), signature: Vec::new(), committed_key_count: 0, commitment_pin: None, - }; + } + } - let payment = SingleNodeQuotePayment::from_quotes(vec![(quote.clone(), quote.price)]) - .expect("one quote should be enough for SNP payment"); + #[test] + fn single_node_quote_payment_accepts_every_supported_quote_count() { + for quote_count in 1..=CLOSE_GROUP_SIZE { + let quotes = (0..quote_count) + .rev() + .map(|i| payment_quote(i as u8, i as u64 + 1)) + .collect(); + + let payment = SingleNodeQuotePayment::from_quotes(quotes) + .expect("every supported quote count should produce an SNP payment"); + let median_index = quote_count / 2; + let enhanced_price = + payment.quotes[median_index].price * Amount::from(SINGLE_NODE_PAYMENT_MULTIPLIER); + + assert_eq!(payment.quotes.len(), quote_count); + assert!( + payment + .quotes + .windows(2) + .all(|pair| pair[0].price <= pair[1].price), + "quotes should be sorted by price" + ); + for (index, quote) in payment.quotes.iter().enumerate() { + let expected = if index == median_index { + enhanced_price + } else { + Amount::ZERO + }; + assert_eq!(quote.amount, expected); + } + assert_eq!(payment.total_amount(), enhanced_price); + } + } - assert_eq!(payment.quotes.len(), 1); - assert_eq!(payment.quotes[0].quote_hash, quote.hash()); - assert_eq!(payment.quotes[0].rewards_address, quote.rewards_address); - assert_eq!(payment.quotes[0].amount, Amount::from(33u64)); - assert_eq!(payment.total_amount(), Amount::from(33u64)); + #[test] + fn full_quote_payment_matches_protocol_implementation() { + let quotes = (0..CLOSE_GROUP_SIZE) + .rev() + .map(|i| payment_quote(i as u8, i as u64 + 1)) + .collect::>(); + + let local = SingleNodeQuotePayment::from_quotes(quotes.clone()).unwrap(); + let protocol = SingleNodePayment::from_quotes( + quotes + .into_iter() + .map(|quote| { + let price = quote.price; + (quote, price) + }) + .collect(), + ) + .unwrap(); + + assert_eq!(local.total_amount(), protocol.total_amount()); + for (local, protocol) in local.quotes.iter().zip(&protocol.quotes) { + assert_eq!(local.quote_hash, protocol.quote_hash); + assert_eq!(local.rewards_address, protocol.rewards_address); + assert_eq!(local.amount, protocol.amount); + assert_eq!(local.price, protocol.price); + } } #[test] @@ -1247,7 +1292,22 @@ mod tests { let err = SingleNodeQuotePayment::from_quotes(Vec::new()) .expect_err("empty SNP quote sets must remain invalid"); assert!( - err.to_string().contains("at least 1 quote"), + err.to_string().contains("requires 1..="), + "unexpected error: {err}" + ); + } + + #[test] + fn single_node_quote_payment_rejects_too_many_quotes() { + let quotes = (0..=CLOSE_GROUP_SIZE) + .map(|i| payment_quote(i as u8, i as u64 + 1)) + .collect(); + + let err = SingleNodeQuotePayment::from_quotes(quotes) + .expect_err("quote sets larger than the close group must remain invalid"); + assert!( + err.to_string() + .contains(&format!("requires 1..={CLOSE_GROUP_SIZE}")), "unexpected error: {err}" ); } diff --git a/ant-core/src/data/client/file.rs b/ant-core/src/data/client/file.rs index 8716d11d..f2fe43cd 100644 --- a/ant-core/src/data/client/file.rs +++ b/ant-core/src/data/client/file.rs @@ -21,6 +21,7 @@ use crate::data::client::merkle::{ merkle_store_with_retry, should_use_merkle, MerkleBatchPaymentResult, PaymentMode, PreparedMerkleBatch, DEFERRED_ROUND_DELAYS_SECS, }; +use crate::data::client::payment::SINGLE_NODE_PAYMENT_MULTIPLIER; use crate::data::client::Client; use crate::data::error::{Error, PartialUploadSpend, Result}; use ant_protocol::evm::{Amount, PaymentQuote, QuoteHash, TxHash, MAX_LEAVES}; @@ -1350,7 +1351,7 @@ impl Client { .get(prices.len() / 2) .copied() .unwrap_or(Amount::ZERO); - let per_chunk_cost = median_price * Amount::from(3u64); + let per_chunk_cost = median_price * Amount::from(SINGLE_NODE_PAYMENT_MULTIPLIER); let chunk_count_u64 = u64::try_from(chunk_count).unwrap_or(u64::MAX); let total_storage = per_chunk_cost * Amount::from(chunk_count_u64); diff --git a/ant-core/src/data/client/payment.rs b/ant-core/src/data/client/payment.rs index c3960585..582f46fd 100644 --- a/ant-core/src/data/client/payment.rs +++ b/ant-core/src/data/client/payment.rs @@ -13,6 +13,9 @@ use ant_protocol::transport::{MultiAddr, PeerId}; use std::sync::Arc; use tracing::{debug, info}; +/// Single-node payment pays the selected median quote at 3x its quoted price. +pub(crate) const SINGLE_NODE_PAYMENT_MULTIPLIER: u64 = 3; + impl Client { /// Get the wallet, returning an error if not configured. pub(crate) fn require_wallet(&self) -> Result<&Arc> { @@ -76,10 +79,10 @@ impl Client { // quote's forced-price binding, so anything here is payable. let mut commitment_sidecars = Vec::new(); - for (peer_id, _addrs, quote, price, commitment) in quotes_with_peers { + for (peer_id, _addrs, quote, _price, commitment) in quotes_with_peers { let encoded = peer_id_to_encoded(&peer_id)?; peer_quotes.push((encoded, quote.clone())); - quotes_for_payment.push((quote, price)); + quotes_for_payment.push(quote); if let Some(sidecar) = commitment { commitment_sidecars.push(sidecar); } diff --git a/ant-core/src/data/client/quote.rs b/ant-core/src/data/client/quote.rs index e6854e85..87e19b3c 100644 --- a/ant-core/src/data/client/quote.rs +++ b/ant-core/src/data/client/quote.rs @@ -430,10 +430,6 @@ fn single_node_quote_query_count() -> usize { CLOSE_GROUP_SIZE } -fn single_node_min_quote_count() -> usize { - SINGLE_NODE_MIN_QUOTE_COUNT -} - fn fault_tolerant_quote_query_count() -> usize { CLOSE_GROUP_SIZE * FAULT_TOLERANT_QUOTE_QUERY_MULTIPLIER } @@ -835,20 +831,15 @@ fn select_witnessed_median_voter_quotes( address: &[u8; 32], voters_by_peer: &VotersByPeer, required_support: usize, - min_quote_count: usize, - target_quote_count: usize, ) -> Option> { - if min_quote_count == 0 - || target_quote_count < min_quote_count - || quotes.len() < min_quote_count - { + if quotes.is_empty() { return None; } sort_quotes_by_distance(&mut quotes, address); - let max_quote_count = target_quote_count.min(quotes.len()); - for quote_count in (min_quote_count..=max_quote_count).rev() { + let max_quote_count = single_node_quote_query_count().min(quotes.len()); + for quote_count in (SINGLE_NODE_MIN_QUOTE_COUNT..=max_quote_count).rev() { let mut best_indices: Option<(usize, Vec)> = None; let mut current_indices = Vec::with_capacity(quote_count); visit_quote_subsets( @@ -1033,7 +1024,7 @@ impl Client { // Query the close-group width, but single-node payment now only needs // one valid witnessed quote to proceed. let close_group_query_count = single_node_quote_query_count(); - let required_quotes = single_node_min_quote_count(); + let required_quotes = SINGLE_NODE_MIN_QUOTE_COUNT; // Contact the closest PUT_TARGET_WIDTH peers directly so the whole // PUT-target set's addresses arrive in this single query. A network // with fewer than that near the target can't satisfy the wide lookup, @@ -1132,15 +1123,18 @@ impl Client { hex::encode(address) ); - let min_quote_count = match "e_selection_policy { - QuoteSelectionPolicy::ClosestByDistance => CLOSE_GROUP_SIZE, - QuoteSelectionPolicy::WitnessedMedianVoters { .. } => single_node_min_quote_count(), - }; - let target_quote_count = match "e_selection_policy { - QuoteSelectionPolicy::ClosestByDistance => CLOSE_GROUP_SIZE, - QuoteSelectionPolicy::WitnessedMedianVoters { .. } => single_node_quote_query_count(), - } - .min(peer_query_count); + let (min_quote_count, target_quote_count, staged_witnessed_collection) = + match "e_selection_policy { + QuoteSelectionPolicy::ClosestByDistance => { + (CLOSE_GROUP_SIZE, CLOSE_GROUP_SIZE, false) + } + QuoteSelectionPolicy::WitnessedMedianVoters { .. } => ( + SINGLE_NODE_MIN_QUOTE_COUNT, + single_node_quote_query_count(), + true, + ), + }; + let target_quote_count = target_quote_count.min(peer_query_count); if remote_peers.len() < min_quote_count { return Err(Error::InsufficientPeers(format!( @@ -1166,11 +1160,6 @@ impl Client { // network-broken) and the user benefits from seeing them called out. let mut bad_quote_count = 0usize; - let staged_witnessed_collection = matches!( - "e_selection_policy, - QuoteSelectionPolicy::WitnessedMedianVoters { .. } - ); - if staged_witnessed_collection { let mut quote_futures = FuturesUnordered::new(); let mut next_peer_index = 0usize; @@ -1335,26 +1324,19 @@ impl Client { QuoteSelectionPolicy::WitnessedMedianVoters { voters_by_peer, quorum, - } => select_witnessed_median_voter_quotes( - quotes, - address, - &voters_by_peer, - quorum, - min_quote_count, - target_quote_count, - ) - .ok_or_else(|| { - Error::InsufficientPeers(format!( - "Got {quote_count} quotes, need at least {min_quote_count} whose paid \ + } => select_witnessed_median_voter_quotes(quotes, address, &voters_by_peer, quorum) + .ok_or_else(|| { + Error::InsufficientPeers(format!( + "Got {quote_count} quotes, need at least {min_quote_count} whose paid \ median issuer is recognised by at least {} \ selected witness peers ({total_responses} responses: \ {already_stored_count} already_stored, {failure_count} failed \ including {bad_quote_count} with mismatched peer bindings). \ Failures: [{}]", - quorum, - failures.join("; ") - )) - })?, + quorum, + failures.join("; ") + )) + })?, }; info!( @@ -1646,7 +1628,7 @@ mod tests { #[test] fn quote_query_counts_keep_single_node_close_group_only() { assert_eq!(single_node_quote_query_count(), CLOSE_GROUP_SIZE); - assert_eq!(single_node_min_quote_count(), 1); + assert_eq!(SINGLE_NODE_MIN_QUOTE_COUNT, 1); assert_eq!(SINGLE_NODE_WITNESSED_VIEW_COUNT, 20); assert!(SINGLE_NODE_WITNESSED_VIEW_COUNT > single_node_quote_query_count()); assert_eq!(witnessed_close_group_quorum(), 5); @@ -1861,6 +1843,40 @@ mod tests { } } + #[test] + fn witnessed_quote_selection_accepts_one_quorum_recognised_candidate() { + let address = [0u8; 32]; + let witnessed = WitnessedCloseGroup { + target: address, + k: CLOSE_GROUP_SIZE, + initial_closest: witnessed_test_nodes(&[1, 2, 3, 4, 5, 6, 7]), + responder_views: (1..=7) + .map(|responder| witnessed_test_view(responder, &[1])) + .collect(), + }; + + let selection = witnessed_quote_selection_or_error( + &address, + &witnessed, + SINGLE_NODE_MIN_QUOTE_COUNT, + witnessed_close_group_quorum(), + ) + .expect("one quorum-recognised candidate is enough before payment"); + + assert_eq!( + selection + .quote_peers + .iter() + .map(|peer| peer.peer_id.as_bytes()[0]) + .collect::>(), + vec![1] + ); + assert_eq!( + put_peer_seeds(&selection.initial_put_peers), + vec![1, 2, 3, 4, 5, 6, 7] + ); + } + #[test] fn witnessed_quote_peers_include_quorum_fallback_candidates() { const EXTRA_QUORUM_CANDIDATES: usize = 1; @@ -1976,15 +1992,9 @@ mod tests { ); let quorum = witnessed_close_group_quorum(); - let selected = select_witnessed_median_voter_quotes( - quotes, - &address, - &voters_by_peer, - quorum, - single_node_min_quote_count(), - CLOSE_GROUP_SIZE, - ) - .expect("a supported close-group quote set should be selected"); + let selected = + select_witnessed_median_voter_quotes(quotes, &address, &voters_by_peer, quorum) + .expect("a supported close-group quote set should be selected"); assert_eq!(quote_peer_seeds(&selected), vec![1, 2, 3, 6, 7, 8, 9]); let (median_peer_id, _) = @@ -2014,15 +2024,9 @@ mod tests { ); let quorum = witnessed_close_group_quorum(); - let selected = select_witnessed_median_voter_quotes( - quotes, - &address, - &voters_by_peer, - quorum, - single_node_min_quote_count(), - CLOSE_GROUP_SIZE, - ) - .expect("direct witness recognition should support the paid median issuer"); + let selected = + select_witnessed_median_voter_quotes(quotes, &address, &voters_by_peer, quorum) + .expect("direct witness recognition should support the paid median issuer"); let (median_peer_id, _) = median_paid_quote_issuer(&selected).expect("selected quotes have a median"); @@ -2062,8 +2066,6 @@ mod tests { &address, &voters_by_peer, witnessed_close_group_quorum(), - single_node_min_quote_count(), - CLOSE_GROUP_SIZE, ) .expect("one quorum-supported quote is enough for SNP payment"); @@ -2099,8 +2101,6 @@ mod tests { &address, &voters_by_peer, witnessed_close_group_quorum(), - single_node_min_quote_count(), - CLOSE_GROUP_SIZE, ); assert!( diff --git a/ant-core/tests/e2e_payment.rs b/ant-core/tests/e2e_payment.rs index 6d69d90e..9797ed40 100644 --- a/ant-core/tests/e2e_payment.rs +++ b/ant-core/tests/e2e_payment.rs @@ -254,10 +254,11 @@ async fn test_quote_collection() { .await .expect("get_store_quotes should succeed"); - // Single-node payment now needs only one valid witnessed quote. + // A healthy network should still return several quotes even though the + // degraded single-node path can proceed with only one. assert!( - !quotes.is_empty(), - "Should receive at least one quote, got {}", + quotes.len() >= 5, + "Should receive at least 5 quotes, got {}", quotes.len() ); diff --git a/ant-core/tests/e2e_security.rs b/ant-core/tests/e2e_security.rs index 756f2fe9..d57b0837 100644 --- a/ant-core/tests/e2e_security.rs +++ b/ant-core/tests/e2e_security.rs @@ -43,10 +43,10 @@ async fn collect_and_pay(client: &Client, content: &Bytes) -> (PaymentProof, Vec let mut peer_quotes = Vec::with_capacity(quotes.len()); let mut quotes_for_payment = Vec::with_capacity(quotes.len()); let mut commitment_sidecars = Vec::new(); - for (peer_id, _addrs, quote, price, commitment) in quotes { + for (peer_id, _addrs, quote, _price, commitment) in quotes { let encoded = EncodedPeerId::new(*peer_id.as_bytes()); peer_quotes.push((encoded, quote.clone())); - quotes_for_payment.push((quote, price)); + quotes_for_payment.push(quote); if let Some(sidecar) = commitment { commitment_sidecars.push(sidecar); } @@ -402,10 +402,10 @@ async fn test_attack_underpayment_single_node() { let mut peer_quotes = Vec::with_capacity(quotes.len()); let mut quotes_for_payment = Vec::with_capacity(quotes.len()); let mut commitment_sidecars = Vec::new(); - for (peer_id, _addrs, quote, price, commitment) in quotes { + for (peer_id, _addrs, quote, _price, commitment) in quotes { let encoded = EncodedPeerId::new(*peer_id.as_bytes()); peer_quotes.push((encoded, quote.clone())); - quotes_for_payment.push((quote, price)); + quotes_for_payment.push(quote); if let Some(sidecar) = commitment { commitment_sidecars.push(sidecar); } @@ -495,10 +495,10 @@ async fn test_attack_underpayment_half_price() { let mut peer_quotes = Vec::with_capacity(quotes.len()); let mut quotes_for_payment = Vec::with_capacity(quotes.len()); let mut commitment_sidecars = Vec::new(); - for (peer_id, _addrs, quote, price, commitment) in quotes { + for (peer_id, _addrs, quote, _price, commitment) in quotes { let encoded = EncodedPeerId::new(*peer_id.as_bytes()); peer_quotes.push((encoded, quote.clone())); - quotes_for_payment.push((quote, price)); + quotes_for_payment.push(quote); if let Some(sidecar) = commitment { commitment_sidecars.push(sidecar); }