diff --git a/Cargo.toml b/Cargo.toml index 1765bc6..bf22491 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ant-node" -version = "0.6.0" +version = "0.7.0" edition = "2021" authors = ["David Irvine "] description = "Pure quantum-proof network node for the Autonomi decentralized network" diff --git a/src/ant_protocol/mod.rs b/src/ant_protocol/mod.rs index db63072..ca7c9fb 100644 --- a/src/ant_protocol/mod.rs +++ b/src/ant_protocol/mod.rs @@ -45,6 +45,17 @@ pub mod chunk; +/// Number of nodes in a Kademlia close group. +/// +/// Clients fetch quotes from the `CLOSE_GROUP_SIZE` closest nodes to a target +/// address and select the median-priced quote for payment. +pub const CLOSE_GROUP_SIZE: usize = 5; + +/// Minimum number of close group members that must agree for a decision to be valid. +/// +/// This is a simple majority: `(CLOSE_GROUP_SIZE / 2) + 1`. +pub const CLOSE_GROUP_MAJORITY: usize = (CLOSE_GROUP_SIZE / 2) + 1; + // Re-export chunk types for convenience pub use chunk::{ ChunkGetRequest, ChunkGetResponse, ChunkMessage, ChunkMessageBody, ChunkPutRequest, diff --git a/src/lib.rs b/src/lib.rs index f91a15e..e5fade5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -52,7 +52,8 @@ pub mod upgrade; pub use ant_protocol::{ ChunkGetRequest, ChunkGetResponse, ChunkMessage, ChunkMessageBody, ChunkPutRequest, - ChunkPutResponse, ChunkQuoteRequest, ChunkQuoteResponse, CHUNK_PROTOCOL_ID, MAX_CHUNK_SIZE, + ChunkPutResponse, ChunkQuoteRequest, ChunkQuoteResponse, CHUNK_PROTOCOL_ID, + CLOSE_GROUP_MAJORITY, CLOSE_GROUP_SIZE, MAX_CHUNK_SIZE, }; pub use client::{ compute_address, hex_node_id_to_encoded_peer_id, peer_id_to_xor_name, xor_distance, DataChunk, diff --git a/src/payment/single_node.rs b/src/payment/single_node.rs index 02de115..ba9463b 100644 --- a/src/payment/single_node.rs +++ b/src/payment/single_node.rs @@ -10,6 +10,7 @@ //! Total cost is the same as Standard mode (3x), but with one actual payment. //! This saves gas fees while maintaining the same total payment amount. +use crate::ant_protocol::CLOSE_GROUP_SIZE; use crate::error::{Error, Result}; use ant_evm::{Amount, PaymentQuote, QuoteHash, QuotingMetrics, RewardsAddress}; use evmlib::contract::payment_vault; @@ -17,9 +18,6 @@ use evmlib::wallet::Wallet; use evmlib::Network as EvmNetwork; use tracing::info; -/// Required number of quotes for `SingleNode` payment (matches `CLOSE_GROUP_SIZE`) -pub const REQUIRED_QUOTES: usize = 5; - /// Create zero-valued `QuotingMetrics` for payment verification. /// /// The contract doesn't validate metric values, so we use zeroes. @@ -37,20 +35,20 @@ fn zero_quoting_metrics() -> QuotingMetrics { } } -/// Index of the median-priced node after sorting -const MEDIAN_INDEX: usize = 2; +/// Index of the median-priced node after sorting, derived from `CLOSE_GROUP_SIZE`. +const MEDIAN_INDEX: usize = CLOSE_GROUP_SIZE / 2; /// Single node payment structure for a chunk. /// -/// Contains exactly 5 quotes where only the median-priced one receives payment (3x), -/// and the other 4 have `Amount::ZERO`. +/// Contains exactly `CLOSE_GROUP_SIZE` quotes where only the median-priced one +/// receives payment (3x), and the remaining quotes have `Amount::ZERO`. /// -/// The fixed-size array ensures compile-time enforcement of the 5-quote requirement, -/// making the median index (2) always valid. +/// The fixed-size array ensures compile-time enforcement of the quote count, +/// making the median index always valid. #[derive(Debug, Clone)] pub struct SingleNodePayment { - /// All 5 quotes (sorted by price) - fixed size ensures median index is always valid - pub quotes: [QuotePaymentInfo; REQUIRED_QUOTES], + /// All quotes (sorted by price) - fixed size ensures median index is always valid + pub quotes: [QuotePaymentInfo; CLOSE_GROUP_SIZE], } /// Information about a single quote payment @@ -82,9 +80,9 @@ impl SingleNodePayment { /// Returns error if not exactly 5 quotes are provided. pub fn from_quotes(mut quotes_with_prices: Vec<(PaymentQuote, Amount)>) -> Result { let len = quotes_with_prices.len(); - if len != REQUIRED_QUOTES { + if len != CLOSE_GROUP_SIZE { return Err(Error::Payment(format!( - "SingleNode payment requires exactly {REQUIRED_QUOTES} quotes, got {len}" + "SingleNode payment requires exactly {CLOSE_GROUP_SIZE} quotes, got {len}" ))); } @@ -96,7 +94,7 @@ impl SingleNodePayment { .get(MEDIAN_INDEX) .ok_or_else(|| { Error::Payment(format!( - "Missing median quote at index {MEDIAN_INDEX}: expected {REQUIRED_QUOTES} quotes but get() failed" + "Missing median quote at index {MEDIAN_INDEX}: expected {CLOSE_GROUP_SIZE} quotes but get() failed" )) })? .1; @@ -123,8 +121,8 @@ impl SingleNodePayment { }) .collect(); - // Convert Vec to array - we already validated length is REQUIRED_QUOTES - let quotes: [QuotePaymentInfo; REQUIRED_QUOTES] = quotes_vec + // Convert Vec to array - we already validated length is CLOSE_GROUP_SIZE + let quotes: [QuotePaymentInfo; CLOSE_GROUP_SIZE] = quotes_vec .try_into() .map_err(|_| Error::Payment("Failed to convert quotes to fixed array".to_string()))?; @@ -140,7 +138,7 @@ impl SingleNodePayment { /// Get the median quote that receives payment. /// /// Returns `None` only if the internal array is somehow shorter than `MEDIAN_INDEX`, - /// which should never happen since the array is fixed-size `[_; REQUIRED_QUOTES]`. + /// which should never happen since the array is fixed-size `[_; CLOSE_GROUP_SIZE]`. #[must_use] pub fn paid_quote(&self) -> Option<&QuotePaymentInfo> { self.quotes.get(MEDIAN_INDEX) @@ -163,9 +161,9 @@ impl SingleNodePayment { info!( "Paying for {} quotes: 1 real ({} atto) + {} with 0 atto", - REQUIRED_QUOTES, + CLOSE_GROUP_SIZE, self.total_amount(), - REQUIRED_QUOTES - 1 + CLOSE_GROUP_SIZE - 1 ); let (tx_hashes, _gas_info) = wallet.pay_for_quotes(quote_payments).await.map_err( @@ -338,9 +336,9 @@ mod tests { let transaction_config = TransactionConfig::default(); - // Create 5 random quote payments (autonomi pattern) + // Create CLOSE_GROUP_SIZE random quote payments (autonomi pattern) let mut quote_payments = vec![]; - for _ in 0..5 { + for _ in 0..CLOSE_GROUP_SIZE { let quote_hash = dummy_hash(); let reward_address = dummy_address(); let amount = Amount::from(1u64); @@ -412,8 +410,8 @@ mod tests { let mut quote_payments = vec![(real_quote_hash, real_reward_address, real_amount)]; - // Add 4 dummy payments with 0 amount - for _ in 0..4 { + // Add dummy payments with 0 amount for remaining close group members + for _ in 0..CLOSE_GROUP_SIZE - 1 { let dummy_quote_hash = dummy_hash(); let dummy_reward_address = dummy_address(); let dummy_amount = Amount::from(0u64); // 0 amount @@ -559,8 +557,9 @@ mod tests { #[test] #[allow(clippy::unwrap_used)] fn test_paid_quote_returns_median() { - let quotes: Vec<_> = (0..5u8) - .map(|i| (make_test_quote(i + 1), Amount::from(u64::from(i + 1) * 10))) + let quotes: Vec<_> = (1u8..) + .take(CLOSE_GROUP_SIZE) + .map(|i| (make_test_quote(i), Amount::from(u64::from(i) * 10))) .collect(); let payment = SingleNodePayment::from_quotes(quotes).unwrap(); @@ -576,17 +575,18 @@ mod tests { #[test] #[allow(clippy::unwrap_used)] fn test_all_quotes_have_distinct_addresses() { - let quotes: Vec<_> = (0..5u8) - .map(|i| (make_test_quote(i + 1), Amount::from(u64::from(i + 1) * 10))) + let quotes: Vec<_> = (1u8..) + .take(CLOSE_GROUP_SIZE) + .map(|i| (make_test_quote(i), Amount::from(u64::from(i) * 10))) .collect(); let payment = SingleNodePayment::from_quotes(quotes).unwrap(); - // Verify all 5 quotes are present (sorting doesn't lose data) + // Verify all quotes are present (sorting doesn't lose data) let mut addresses: Vec<_> = payment.quotes.iter().map(|q| q.rewards_address).collect(); addresses.sort(); addresses.dedup(); - assert_eq!(addresses.len(), 5); + assert_eq!(addresses.len(), CLOSE_GROUP_SIZE); } #[test] @@ -629,7 +629,7 @@ mod tests { let chunk_size = 1024usize; let mut quotes_with_prices = Vec::new(); - for i in 0..REQUIRED_QUOTES { + for i in 0..CLOSE_GROUP_SIZE { let quoting_metrics = QuotingMetrics { data_size: chunk_size, data_type: 0, @@ -687,7 +687,7 @@ mod tests { .ok_or_else(|| Error::Payment("Failed to calculate median price".to_string()))?; println!("✓ Sorted and selected median price: {median_price} atto"); - assert_eq!(payment.quotes.len(), REQUIRED_QUOTES); + assert_eq!(payment.quotes.len(), CLOSE_GROUP_SIZE); let median_amount = payment .quotes .get(MEDIAN_INDEX) diff --git a/src/payment/verifier.rs b/src/payment/verifier.rs index 3d3d798..c36e5b8 100644 --- a/src/payment/verifier.rs +++ b/src/payment/verifier.rs @@ -3,13 +3,13 @@ //! This is the core payment verification logic for ant-node. //! All new data requires EVM payment on Arbitrum (no free tier). +use crate::ant_protocol::CLOSE_GROUP_SIZE; use crate::error::{Error, Result}; use crate::payment::cache::{CacheStats, VerifiedCache, XorName}; use crate::payment::proof::{ deserialize_merkle_proof, deserialize_proof, detect_proof_type, ProofType, }; use crate::payment::quote::{verify_quote_content, verify_quote_signature}; -use crate::payment::single_node::REQUIRED_QUOTES; use ant_evm::merkle_payments::OnChainPaymentInfo; use ant_evm::{ProofOfPayment, RewardsAddress}; use evmlib::contract::merkle_payment_vault; @@ -410,9 +410,9 @@ impl PaymentVerifier { } let quote_count = payment.peer_quotes.len(); - if quote_count != REQUIRED_QUOTES { + if quote_count != CLOSE_GROUP_SIZE { return Err(Error::Payment(format!( - "Payment must have exactly {REQUIRED_QUOTES} quotes, got {quote_count}" + "Payment must have exactly {CLOSE_GROUP_SIZE} quotes, got {quote_count}" ))); } @@ -1039,9 +1039,9 @@ mod tests { signature: vec![0u8; 64], }; - // Build 5 quotes with distinct peer IDs (required by REQUIRED_QUOTES enforcement) + // Build CLOSE_GROUP_SIZE quotes with distinct peer IDs let mut peer_quotes = Vec::new(); - for _ in 0..5 { + for _ in 0..CLOSE_GROUP_SIZE { let keypair = Keypair::generate_ed25519(); let peer_id = PeerId::from_public_key(&keypair.public()); peer_quotes.push((EncodedPeerId::from(peer_id), quote.clone())); @@ -1121,7 +1121,7 @@ mod tests { let quote = make_fake_quote(xorname, old_timestamp, rewards_addr); let mut peer_quotes = Vec::new(); - for _ in 0..5 { + for _ in 0..CLOSE_GROUP_SIZE { let keypair = libp2p::identity::Keypair::generate_ed25519(); let peer_id = libp2p::PeerId::from_public_key(&keypair.public()); peer_quotes.push((EncodedPeerId::from(peer_id), quote.clone())); @@ -1152,7 +1152,7 @@ mod tests { let quote = make_fake_quote(xorname, future_timestamp, rewards_addr); let mut peer_quotes = Vec::new(); - for _ in 0..5 { + for _ in 0..CLOSE_GROUP_SIZE { let keypair = libp2p::identity::Keypair::generate_ed25519(); let peer_id = libp2p::PeerId::from_public_key(&keypair.public()); peer_quotes.push((EncodedPeerId::from(peer_id), quote.clone())); @@ -1183,7 +1183,7 @@ mod tests { let quote = make_fake_quote(xorname, future_timestamp, rewards_addr); let mut peer_quotes = Vec::new(); - for _ in 0..5 { + for _ in 0..CLOSE_GROUP_SIZE { let keypair = libp2p::identity::Keypair::generate_ed25519(); let peer_id = libp2p::PeerId::from_public_key(&keypair.public()); peer_quotes.push((EncodedPeerId::from(peer_id), quote.clone())); @@ -1214,7 +1214,7 @@ mod tests { let quote = make_fake_quote(xorname, future_timestamp, rewards_addr); let mut peer_quotes = Vec::new(); - for _ in 0..5 { + for _ in 0..CLOSE_GROUP_SIZE { let keypair = libp2p::identity::Keypair::generate_ed25519(); let peer_id = libp2p::PeerId::from_public_key(&keypair.public()); peer_quotes.push((EncodedPeerId::from(peer_id), quote.clone())); @@ -1248,7 +1248,7 @@ mod tests { let quote = make_fake_quote(xorname, old_timestamp, rewards_addr); let mut peer_quotes = Vec::new(); - for _ in 0..5 { + for _ in 0..CLOSE_GROUP_SIZE { let keypair = libp2p::identity::Keypair::generate_ed25519(); let peer_id = libp2p::PeerId::from_public_key(&keypair.public()); peer_quotes.push((EncodedPeerId::from(peer_id), quote.clone())); @@ -1304,7 +1304,7 @@ mod tests { // Use real ML-DSA keys so the pub_key→peer_id binding check passes let ml_dsa = MlDsa65::new(); let mut peer_quotes = Vec::new(); - for _ in 0..5 { + for _ in 0..CLOSE_GROUP_SIZE { let (public_key, _secret_key) = ml_dsa.generate_keypair().expect("keygen"); let pub_key_bytes = public_key.as_bytes().to_vec(); let encoded = encoded_peer_id_for_pub_key(&pub_key_bytes); @@ -1348,7 +1348,7 @@ mod tests { // Use random ed25519 peer IDs — they won't match BLAKE3(pub_key) let mut peer_quotes = Vec::new(); - for _ in 0..5 { + for _ in 0..CLOSE_GROUP_SIZE { let keypair = libp2p::identity::Keypair::generate_ed25519(); let peer_id = libp2p::PeerId::from_public_key(&keypair.public()); peer_quotes.push((EncodedPeerId::from(peer_id), quote.clone())); @@ -1409,7 +1409,7 @@ mod tests { // Build a valid tagged single-node proof let quote = make_fake_quote(xorname, SystemTime::now(), rewards_addr); let mut peer_quotes = Vec::new(); - for _ in 0..5 { + for _ in 0..CLOSE_GROUP_SIZE { let keypair = libp2p::identity::Keypair::generate_ed25519(); let peer_id = libp2p::PeerId::from_public_key(&keypair.public()); peer_quotes.push((EncodedPeerId::from(peer_id), quote.clone()));