diff --git a/ant-core/src/data/client/batch.rs b/ant-core/src/data/client/batch.rs index a30f67b3..9a62b4c4 100644 --- a/ant-core/src/data/client/batch.rs +++ b/ant-core/src/data/client/batch.rs @@ -7,18 +7,18 @@ 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::{ 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}; +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}; @@ -36,6 +36,104 @@ 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: 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.sort_by_key(|quote| quote.price); + let median_index = quote_count / 2; + 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 + .into_iter() + .enumerate() + .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(); + + 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 +148,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 +373,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()); @@ -283,16 +381,16 @@ 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); } } - 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,8 +1181,7 @@ mod send_assertions { #[allow(clippy::unwrap_used)] mod tests { use super::*; - use ant_protocol::payment::QuotePaymentInfo; - 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; @@ -1093,26 +1190,128 @@ 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(), } } + fn payment_quote(seed: u8, price: u64) -> PaymentQuote { + PaymentQuote { + content: xor_name::XorName([seed; 32]), + timestamp: std::time::SystemTime::UNIX_EPOCH, + price: Amount::from(price), + rewards_address: RewardsAddress::new([seed; 20]), + pub_key: Vec::new(), + signature: Vec::new(), + committed_key_count: 0, + commitment_pin: None, + } + } + + #[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); + } + } + + #[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] + 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("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}" + ); + } + #[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..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}; @@ -1342,7 +1343,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(); @@ -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 859ded4a..582f46fd 100644 --- a/ant-core/src/data/client/payment.rs +++ b/ant-core/src/data/client/payment.rs @@ -3,15 +3,19 @@ //! 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}; +/// 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> { @@ -23,8 +27,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 +52,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 +68,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()); @@ -75,17 +79,17 @@ 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); } } - // 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..87e19b3c 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 ≈ @@ -738,10 +738,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 +751,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 +767,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 +783,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)) } @@ -828,42 +832,50 @@ fn select_witnessed_median_voter_quotes( voters_by_peer: &VotersByPeer, required_support: usize, ) -> Option> { - if quotes.len() < CLOSE_GROUP_SIZE { + if quotes.is_empty() { 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 = 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( + 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 +909,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 +1021,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 +1044,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 +1092,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 +1123,26 @@ impl Client { hex::encode(address) ); - if remote_peers.len() < CLOSE_GROUP_SIZE { + 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!( - "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); @@ -1134,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; @@ -1165,7 +1186,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,7 +1318,7 @@ 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 { @@ -1306,7 +1327,7 @@ impl Client { } => 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 \ + "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 \ @@ -1329,7 +1350,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 +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_WITNESSED_VIEW_COUNT, 20); assert!(SINGLE_NODE_WITNESSED_VIEW_COUNT > single_node_quote_query_count()); assert_eq!(witnessed_close_group_quorum(), 5); @@ -1821,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; @@ -1989,6 +2045,36 @@ 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(), + ) + .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; 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..9797ed40 100644 --- a/ant-core/tests/e2e_payment.rs +++ b/ant-core/tests/e2e_payment.rs @@ -254,7 +254,8 @@ async fn test_quote_collection() { .await .expect("get_store_quotes should succeed"); - // At least 5 quotes required + // A healthy network should still return several quotes even though the + // degraded single-node path can proceed with only one. assert!( quotes.len() >= 5, "Should receive at least 5 quotes, got {}", diff --git a/ant-core/tests/e2e_security.rs b/ant-core/tests/e2e_security.rs index 5b91ed4f..d57b0837 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; @@ -43,17 +43,18 @@ 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); } } // 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,26 +398,27 @@ 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(); - 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); } } - 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() @@ -493,22 +495,23 @@ 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); } } - 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.