From 5e020ef215bc49f490c085327be89ac5f8e1b209 Mon Sep 17 00:00:00 2001 From: grumbach Date: Wed, 17 Jun 2026 17:59:44 +0900 Subject: [PATCH 01/11] feat(client): resolve-before-pay for commitment-bound quotes (ADR-0003) Implement the client half of ADR-0003: the client pays nothing it cannot resolve. Before paying, it runs the full binding check on every quote and candidate, so a node that overstates its storage to inflate its price is dropped before any on-chain payment instead of after. For each single-node quote and each merkle candidate, the client now: - verifies the quote's own ML-DSA-65 signature and that it is for the requested content; - checks the (committed_key_count, commitment_pin) shape, the count cap, and that price == calculate_price(count) by exact recomputation; and - for a bound quote, fully resolves the shipped commitment: parse it, check it is bound to the quoting peer, verify its signature, that it hashes to the quote's pin, and that its key_count matches the claimed count. Anything unresolvable, withheld, off-curve, or contradictory is dropped before payment, exactly as the storer would reject it. The verified commitments are then forwarded as sidecars in the PUT bundle (single-node and merkle) so storers cross-check synchronously. Sidecar blobs are size-capped before parsing. Pricing and the commitment verification come from ant-protocol, so client and node never disagree. Adds an e2e test (real QUIC nodes + Anvil) proving bound quotes are shipped, priced, fully resolve, and round-trip; and the off-curve / forged-commitment rejection is covered in unit tests. Depends on the evmlib, ant-protocol, and ant-node ADR-0003 releases; will not build standalone until those publish. --- ant-core/examples/bench-quoting.rs | 2 +- ant-core/src/data/client/batch.rs | 19 +- ant-core/src/data/client/file.rs | 13 +- ant-core/src/data/client/merkle.rs | 208 ++++++++++-- ant-core/src/data/client/mod.rs | 2 + ant-core/src/data/client/payment.rs | 14 +- ant-core/src/data/client/quote.rs | 508 +++++++++++++++++++++++++--- ant-core/src/data/error.rs | 13 + ant-core/tests/e2e_adr0003.rs | 186 ++++++++++ ant-core/tests/e2e_payment.rs | 5 +- ant-core/tests/e2e_security.rs | 33 +- ant-core/tests/support/mod.rs | 88 ++++- 12 files changed, 994 insertions(+), 97 deletions(-) create mode 100644 ant-core/tests/e2e_adr0003.rs diff --git a/ant-core/examples/bench-quoting.rs b/ant-core/examples/bench-quoting.rs index 54bf27b3..abf14865 100644 --- a/ant-core/examples/bench-quoting.rs +++ b/ant-core/examples/bench-quoting.rs @@ -531,7 +531,7 @@ async fn bench_merkle_once(client: &Client, rep: usize, concurrency: usize) -> R &addrs_clone, |body| match body { ChunkMessageBody::MerkleCandidateQuoteResponse( - MerkleCandidateQuoteResponse::Success { candidate_node }, + MerkleCandidateQuoteResponse::Success { candidate_node, .. }, ) => match rmp_serde::from_slice::( &candidate_node, ) { diff --git a/ant-core/src/data/client/batch.rs b/ant-core/src/data/client/batch.rs index d277f939..f68e1808 100644 --- a/ant-core/src/data/client/batch.rs +++ b/ant-core/src/data/client/batch.rs @@ -53,6 +53,10 @@ pub struct PreparedChunk { pub payment: SingleNodePayment, /// Peer quotes for building `ProofOfPayment`. pub peer_quotes: Vec<(EncodedPeerId, PaymentQuote)>, + /// ADR-0003: the signed commitments the bound quotes shipped, forwarded as + /// sidecars in the PUT bundle so storers cross-check synchronously. Empty + /// when every quote was baseline (no commitment to pin). + pub commitment_sidecars: Vec>, } /// Chunk paid but not yet stored. Produced by [`Client::batch_pay`]. @@ -210,6 +214,9 @@ fn build_paid_chunks( peer_quotes: chunk.peer_quotes, }, tx_hashes, + // ADR-0003: forward the bound quotes' commitments so storers + // cross-check synchronously; stripped before persistence node-side. + commitment_sidecars: chunk.commitment_sidecars, }; let proof_bytes = serialize_single_node_proof(&proof) @@ -272,11 +279,17 @@ impl Client { // 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()); + // ADR-0003: forward each bound quote's commitment sidecar (baseline + // quotes ship none); `get_store_quotes` already verified the binding. + let mut commitment_sidecars = Vec::new(); - for (peer_id, _addrs, quote, price) 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)); + if let Some(sidecar) = commitment { + commitment_sidecars.push(sidecar); + } } let payment = SingleNodePayment::from_quotes(quotes_for_payment) @@ -288,6 +301,7 @@ impl Client { quoted_peers, payment, peer_quotes, + commitment_sidecars, })) } @@ -1095,6 +1109,7 @@ mod tests { quoted_peers: Vec::new(), payment: SingleNodePayment { quotes }, peer_quotes: Vec::new(), + commitment_sidecars: Vec::new(), } } @@ -1206,6 +1221,8 @@ mod tests { rewards_address: RewardsAddress::new([1u8; 20]), pub_key: vec![], signature: vec![], + committed_key_count: 0, + commitment_pin: None, }; (EncodedPeerId::from([i as u8; 32]), quote) }) diff --git a/ant-core/src/data/client/file.rs b/ant-core/src/data/client/file.rs index c5c28ccf..84737ee1 100644 --- a/ant-core/src/data/client/file.rs +++ b/ant-core/src/data/client/file.rs @@ -139,8 +139,15 @@ pub enum FileChunkPeerStatus { /// One entry in the per-chunk quote list returned by /// [`Client::get_store_quotes`]: the responding peer, its addresses, the -/// signed quote it returned, and the payment amount it is demanding. -type QuoteEntry = (PeerId, Vec, PaymentQuote, Amount); +/// signed quote it returned, the payment amount it is demanding, and (ADR-0003) +/// the opaque signed-commitment blob the node shipped with the quote. +type QuoteEntry = ( + PeerId, + Vec, + PaymentQuote, + Amount, + Option>, +); type DownloadBatchEntry = (usize, std::result::Result); @@ -1330,7 +1337,7 @@ impl Client { // Use the median price × 3 (matches SingleNodePayment::from_quotes // which pays 3x the median to incentivize reliable storage). - let mut prices: Vec = quotes.iter().map(|(_, _, _, price)| *price).collect(); + let mut prices: Vec = quotes.iter().map(|(_, _, _, price, _)| *price).collect(); prices.sort(); let median_price = prices .get(prices.len() / 2) diff --git a/ant-core/src/data/client/merkle.rs b/ant-core/src/data/client/merkle.rs index d807128b..08700950 100644 --- a/ant-core/src/data/client/merkle.rs +++ b/ant-core/src/data/client/merkle.rs @@ -13,7 +13,13 @@ use ant_protocol::evm::{ Amount, MerklePaymentCandidateNode, MerklePaymentCandidatePool, MerklePaymentProof, MerkleTree, MidpointProof, PoolCommitment, CANDIDATES_PER_POOL, MAX_LEAVES, }; -use ant_protocol::payment::{serialize_merkle_proof, verify_merkle_candidate_signature}; +use ant_protocol::payment::commitment::{ + commitment_hash, verify_commitment_signature, StorageCommitment, MAX_COMMITMENT_KEY_COUNT, + MAX_COMMITMENT_SIDECAR_BYTES, +}; +use ant_protocol::payment::{ + calculate_price, serialize_merkle_proof, verify_merkle_candidate_signature, +}; use ant_protocol::transport::PeerId; use ant_protocol::{ compute_address, send_and_await_chunk_response, ChunkMessage, ChunkMessageBody, @@ -31,6 +37,80 @@ use xor_name::XorName; /// Default threshold: use merkle payments when chunk count >= this value. pub const DEFAULT_MERKLE_THRESHOLD: usize = 64; +/// ADR-0003 resolve-before-pay gate for a merkle candidate — the merkle-path +/// equivalent of the single-node `quote_commitment_binding_is_valid`. Runs the +/// FULL binding check (shape, cap, exact price, and for bound candidates the +/// commitment parse, peer-binding, signature, `hash == pin`, and +/// `count == key_count`) before the candidate is allowed into a pool the client +/// may pay. `peer_id` is derived from the candidate's `pub_key` +/// (`BLAKE3(pub_key)`), matching the storer. +/// +/// Returns `Ok(())` if the binding fully resolves, else `Err(detail)`. +fn merkle_candidate_binding_is_valid( + peer_id: &PeerId, + candidate: &MerklePaymentCandidateNode, + commitment: &Option>, +) -> std::result::Result<(), String> { + let count = candidate.committed_key_count; + let pin = candidate.commitment_pin; + match (count, pin.is_some()) { + (0, false) | (1.., true) => {} + (1.., false) => { + return Err(format!( + "committed_key_count={count} > 0 but commitment_pin is None (unauditable count)" + )); + } + (0, true) => { + return Err("committed_key_count=0 with a commitment_pin (incoherent baseline)".into()); + } + } + if count > MAX_COMMITMENT_KEY_COUNT { + return Err(format!( + "committed_key_count={count} exceeds MAX_COMMITMENT_KEY_COUNT={MAX_COMMITMENT_KEY_COUNT}" + )); + } + let expected = calculate_price(count as usize); + if candidate.price != expected { + return Err(format!( + "price {} does not equal calculate_price(committed_key_count={count}) = {expected}", + candidate.price + )); + } + + let Some(pin) = pin else { + return Ok(()); // baseline candidate pins nothing + }; + let Some(blob) = commitment else { + return Err("bound candidate did not ship its commitment; pin is unresolvable".into()); + }; + if blob.len() > MAX_COMMITMENT_SIDECAR_BYTES { + return Err(format!( + "shipped commitment is {} bytes, exceeds MAX_COMMITMENT_SIDECAR_BYTES={MAX_COMMITMENT_SIDECAR_BYTES}", + blob.len() + )); + } + let commitment: StorageCommitment = rmp_serde::from_slice(blob) + .map_err(|e| format!("shipped commitment did not deserialize: {e}"))?; + if compute_address(&commitment.sender_public_key) != *peer_id.as_bytes() + || commitment.sender_peer_id != *peer_id.as_bytes() + { + return Err("shipped commitment is not bound to the candidate peer".into()); + } + if !verify_commitment_signature(&commitment) { + return Err("shipped commitment has an invalid signature".into()); + } + if commitment_hash(&commitment) != Some(pin) { + return Err("shipped commitment does not hash to the candidate's pin".into()); + } + if commitment.key_count != count { + return Err(format!( + "shipped commitment attests key_count={} but the candidate claims {count}", + commitment.key_count + )); + } + Ok(()) +} + /// Payment mode for uploads. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "snake_case")] @@ -83,6 +163,10 @@ pub struct PreparedMerkleBatch { tree: MerkleTree, /// Internal: chunk addresses in order. addresses: Vec<[u8; 32]>, + /// ADR-0003: validated commitment sidecars keyed by `(peer, pin)`, collected + /// during candidate validation. At proof time the winner pool's candidates + /// forward theirs in `MerklePaymentProof.commitment_sidecars`. + commitment_sidecars: HashMap<(PeerId, [u8; 32]), Vec>, } /// Result of checking a merkle upload batch before payment. @@ -443,8 +527,10 @@ impl Client { midpoint_proofs.len() ); - // 3. Collect candidate pools from the network (all pools in parallel) - let candidate_pools = self + // 3. Collect candidate pools from the network (all pools in parallel). + // Each candidate's ADR-0003 binding is verified during collection and + // its commitment sidecar captured (keyed by peer, pin). + let (candidate_pools, commitment_sidecars) = self .build_candidate_pools( &midpoint_proofs, data_type, @@ -466,6 +552,7 @@ impl Client { candidate_pools, tree, addresses: addresses.to_vec(), + commitment_sidecars, }) } @@ -579,14 +666,17 @@ impl Client { data_type: u32, data_size: u64, merkle_payment_timestamp: u64, - ) -> Result> { + ) -> Result<( + Vec, + HashMap<(PeerId, [u8; 32]), Vec>, + )> { let mut pool_futures = FuturesUnordered::new(); for midpoint_proof in midpoint_proofs { let pool_address = midpoint_proof.address(); let mp = midpoint_proof.clone(); pool_futures.push(async move { - let candidate_nodes = self + let (candidate_nodes, sidecars) = self .get_merkle_candidate_pool( &pool_address.0, data_type, @@ -594,19 +684,28 @@ impl Client { merkle_payment_timestamp, ) .await?; - Ok::<_, Error>(MerklePaymentCandidatePool { - midpoint_proof: mp, - candidate_nodes, - }) + Ok::<_, Error>(( + MerklePaymentCandidatePool { + midpoint_proof: mp, + candidate_nodes, + }, + sidecars, + )) }); } let mut pools = Vec::with_capacity(midpoint_proofs.len()); + // ADR-0003: merged map of every validated candidate's commitment sidecar, + // keyed by (peer, pin). At proof time the winner pool's candidates look + // up their sidecars here to forward in the merkle PUT bundle. + let mut sidecars: HashMap<(PeerId, [u8; 32]), Vec> = HashMap::new(); while let Some(result) = pool_futures.next().await { - pools.push(result?); + let (pool, pool_sidecars) = result?; + pools.push(pool); + sidecars.extend(pool_sidecars); } - Ok(pools) + Ok((pools, sidecars)) } /// Collect `CANDIDATES_PER_POOL` (16) merkle candidate quotes from the network. @@ -617,7 +716,10 @@ impl Client { data_type: u32, data_size: u64, merkle_payment_timestamp: u64, - ) -> Result<[MerklePaymentCandidateNode; CANDIDATES_PER_POOL]> { + ) -> Result<( + [MerklePaymentCandidateNode; CANDIDATES_PER_POOL], + HashMap<(PeerId, [u8; 32]), Vec>, + )> { let node = self.network().node(); let timeout = Duration::from_secs(self.config().quote_timeout_secs); @@ -685,12 +787,15 @@ impl Client { &addrs_clone, |body| match body { ChunkMessageBody::MerkleCandidateQuoteResponse( - MerkleCandidateQuoteResponse::Success { candidate_node }, + MerkleCandidateQuoteResponse::Success { + candidate_node, + commitment, + }, ) => { match rmp_serde::from_slice::( &candidate_node, ) { - Ok(node) => Some(Ok(node)), + Ok(node) => Some(Ok((node, commitment))), Err(e) => Some(Err(Error::Serialization(format!( "Failed to deserialize candidate node from {peer_id_clone}: {e}" )))), @@ -742,19 +847,23 @@ impl Client { impl std::future::Future< Output = ( PeerId, - std::result::Result, + std::result::Result<(MerklePaymentCandidateNode, Option>), Error>, ), >, >, target_address: &[u8; 32], merkle_payment_timestamp: u64, - ) -> Result<[MerklePaymentCandidateNode; CANDIDATES_PER_POOL]> { + ) -> Result<( + [MerklePaymentCandidateNode; CANDIDATES_PER_POOL], + HashMap<(PeerId, [u8; 32]), Vec>, + )> { let mut valid: Vec<(PeerId, MerklePaymentCandidateNode)> = Vec::new(); let mut failures: Vec = Vec::new(); + let mut sidecars: HashMap<(PeerId, [u8; 32]), Vec> = HashMap::new(); while let Some((peer_id, result)) = futures.next().await { match result { - Ok(candidate) => { + Ok((candidate, commitment)) => { if !verify_merkle_candidate_signature(&candidate) { warn!("Invalid ML-DSA-65 signature from merkle candidate {peer_id}"); failures.push(format!("{peer_id}: invalid signature")); @@ -765,7 +874,38 @@ impl Client { failures.push(format!("{peer_id}: timestamp mismatch")); continue; } - valid.push((peer_id, candidate)); + // The candidate's identity is `BLAKE3(candidate.pub_key)` — + // this is what the storer derives (verifier.rs) and what proof + // finalization keys the sidecar by. Require it to equal the + // network responder so a two-identity operator cannot answer + // as B while shipping A's commitment. + let candidate_peer = PeerId::from_bytes(compute_address(&candidate.pub_key)); + if candidate_peer != peer_id { + warn!( + "Dropping merkle candidate {peer_id} — pub_key derives {candidate_peer}, \ + not the responding peer" + ); + failures.push(format!("{peer_id}: candidate pub_key/peer mismatch")); + continue; + } + // ADR-0003: the FULL resolve-before-pay binding check, same as + // the single-node path — a candidate priced off its committed + // count, or shipping an unresolvable/forged commitment, is + // dropped before it can enter a pool the client pays. Checked + // against the CANDIDATE peer (the one the storer audits). + if let Err(detail) = + merkle_candidate_binding_is_valid(&candidate_peer, &candidate, &commitment) + { + warn!("Dropping merkle candidate {peer_id} — ADR-0003 binding invalid: {detail}"); + failures.push(format!("{peer_id}: bad commitment binding ({detail})")); + continue; + } + // Key the sidecar by the CANDIDATE peer (== BLAKE3(pub_key)) so + // proof finalization, which derives the same key, finds it. + if let (Some(pin), Some(blob)) = (candidate.commitment_pin, commitment) { + sidecars.insert((candidate_peer, pin), blob); + } + valid.push((candidate_peer, candidate)); } Err(e) => { debug!("Failed to get merkle candidate from {peer_id}: {e}"); @@ -791,9 +931,11 @@ impl Client { .map(|(_, candidate)| candidate) .collect(); - candidates - .try_into() - .map_err(|_| Error::Payment("Failed to convert candidates to fixed array".to_string())) + let array: [MerklePaymentCandidateNode; CANDIDATES_PER_POOL] = + candidates.try_into().map_err(|_| { + Error::Payment("Failed to convert candidates to fixed array".to_string()) + })?; + Ok((array, sidecars)) } /// Upload chunks using pre-computed merkle proofs from a batch payment. @@ -1304,6 +1446,19 @@ pub fn finalize_merkle_batch( )) })?; + // ADR-0003: collect the winner pool's candidate commitment sidecars (those + // that were bound + validated during collection), to forward in each proof + // so the storer can cross-check synchronously. Built once for the pool. + let winner_sidecars: Vec> = winner_pool + .candidate_nodes + .iter() + .filter_map(|c| { + let pin = c.commitment_pin?; + let peer = PeerId::from_bytes(compute_address(&c.pub_key)); + prepared.commitment_sidecars.get(&(peer, pin)).cloned() + }) + .collect(); + // Generate proofs for each chunk info!("Generating merkle proofs for {chunk_count} chunks"); let mut proofs = HashMap::with_capacity(chunk_count); @@ -1318,7 +1473,9 @@ pub fn finalize_merkle_batch( )) })?; - let merkle_proof = MerklePaymentProof::new(*xorname, address_proof, winner_pool.clone()); + let mut merkle_proof = + MerklePaymentProof::new(*xorname, address_proof, winner_pool.clone()); + merkle_proof.commitment_sidecars = winner_sidecars.clone(); let tagged_bytes = serialize_merkle_proof(&merkle_proof) .map_err(|e| Error::Serialization(format!("Failed to serialize merkle proof: {e}")))?; @@ -1611,6 +1768,8 @@ mod tests { reward_address: RewardsAddress::new([i as u8; 20]), merkle_payment_timestamp: timestamp, signature: vec![i as u8; 64], + committed_key_count: 0, + commitment_pin: None, }); let pool = MerklePaymentCandidatePool { @@ -1649,6 +1808,8 @@ mod tests { reward_address: ant_protocol::evm::RewardsAddress::new([0u8; 20]), merkle_payment_timestamp: 1000, signature: vec![0u8; 64], + committed_key_count: 0, + commitment_pin: None, }; // Timestamp check: 1000 != 2000 @@ -1668,6 +1829,8 @@ mod tests { reward_address: RewardsAddress::new([i as u8; 20]), merkle_payment_timestamp: timestamp, signature: vec![i as u8; 64], + committed_key_count: 0, + commitment_pin: None, }) } @@ -1703,6 +1866,7 @@ mod tests { candidate_pools, tree, addresses: addrs, + commitment_sidecars: HashMap::new(), } } diff --git a/ant-core/src/data/client/mod.rs b/ant-core/src/data/client/mod.rs index ddd085ad..5214df9b 100644 --- a/ant-core/src/data/client/mod.rs +++ b/ant-core/src/data/client/mod.rs @@ -89,6 +89,7 @@ pub(crate) fn classify_error(err: &Error) -> Outcome { | Error::CostEstimationInconclusive(_) | Error::Cancelled(_) | Error::BadQuoteBinding { .. } + | Error::BadQuoteCommitment { .. } // A remote node responded with a structured rejection — the // transport round-trip succeeded, so the node declined at the // application layer (payment/disk/quote/pool). Not a local @@ -770,6 +771,7 @@ mod tests { | Error::Cancelled(_) | Error::PartialUpload { .. } | Error::BadQuoteBinding { .. } + | Error::BadQuoteCommitment { .. } | Error::RemotePut { .. } | Error::CloseGroupShortfall(_) => (), }; diff --git a/ant-core/src/data/client/payment.rs b/ant-core/src/data/client/payment.rs index 8bcad918..3d2fb3e7 100644 --- a/ant-core/src/data/client/payment.rs +++ b/ant-core/src/data/client/payment.rs @@ -68,11 +68,20 @@ impl Client { // 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()); - - for (peer_id, _addrs, quote, price) in quotes_with_peers { + // ADR-0003: forward the signed commitment each bound quote shipped, so + // the storers can cross-check the quote's count against the original + // commitment synchronously ("the commitment arrived with the quote"). + // A baseline quote ships none. `get_store_quotes` already verified each + // 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 { let encoded = peer_id_to_encoded(&peer_id)?; peer_quotes.push((encoded, quote.clone())); quotes_for_payment.push((quote, price)); + if let Some(sidecar) = commitment { + commitment_sidecars.push(sidecar); + } } // 3. Create SingleNodePayment (sorts by price, selects median) @@ -102,6 +111,7 @@ impl Client { let proof = PaymentProof { proof_of_payment: ProofOfPayment { peer_quotes }, tx_hashes, + commitment_sidecars, }; let proof_bytes = serialize_single_node_proof(&proof) diff --git a/ant-core/src/data/client/quote.rs b/ant-core/src/data/client/quote.rs index 68ff69b7..2737f191 100644 --- a/ant-core/src/data/client/quote.rs +++ b/ant-core/src/data/client/quote.rs @@ -8,6 +8,12 @@ use crate::data::client::Client; use crate::data::client::PUT_TARGET_WIDTH; use crate::data::error::{Error, Result}; use ant_protocol::evm::{Amount, PaymentQuote}; +use ant_protocol::payment::calculate_price; +use ant_protocol::payment::commitment::{ + commitment_hash, verify_commitment_signature, StorageCommitment, MAX_COMMITMENT_KEY_COUNT, + MAX_COMMITMENT_SIDECAR_BYTES, +}; +use ant_protocol::payment::{verify_quote_content, verify_quote_signature}; use ant_protocol::transport::{ DHTNode, MultiAddr, P2PNode, PeerId, ResponderView, WitnessedCloseGroup, }; @@ -54,6 +60,21 @@ const QUOTE_COLLECTION_TIMEOUT_SECS: u64 = 120; /// `ant-protocol` re-exports it (`pqc::ops::ML_DSA_65_PUBLIC_KEY_SIZE`). const ML_DSA_PUB_KEY_LEN: usize = 1952; +/// One collected quote: the responding peer, its addresses, the signed quote, +/// the price it demands, and (ADR-0003) the opaque signed-commitment blob the +/// node shipped alongside the quote (`None` for a baseline quote), to be +/// forwarded as a sidecar in the PUT bundle. +type QuotedPeer = ( + PeerId, + Vec, + PaymentQuote, + Amount, + Option>, +); + +/// Per-peer classification outcome: `(quote, price, commitment)` on success. +type QuoteResult = std::result::Result<(PaymentQuote, Amount, Option>), Error>; + /// Check that a quote's `pub_key` is well-formed and BLAKE3-hashes to the /// claimed `peer_id`. /// @@ -64,11 +85,10 @@ const ML_DSA_PUB_KEY_LEN: usize = 1952; /// failing either check causes the storer to reject the entire close-group /// proof and burn the chunk's payment. /// -/// We mirror the cheap structural check here. The storer also runs -/// `verify_quote_content` and `verify_quote_signature`; those are ML-DSA -/// verifications (~1 ms per requested quote) and are deliberately NOT mirrored -/// on the client to keep upload latency unchanged. They are tracked as a -/// follow-up if a real attack surfaces them. +/// This is the cheap structural pre-check. ADR-0003 additionally has the client +/// run `verify_quote_content` + `verify_quote_signature` (the full ML-DSA check) +/// in [`classify_quote_response`] before paying, so a quote the storer would +/// reject never gets paid. fn quote_binding_is_valid(peer_id: &PeerId, quote: &PaymentQuote) -> bool { if quote.pub_key.len() != ML_DSA_PUB_KEY_LEN { return false; @@ -76,19 +96,122 @@ fn quote_binding_is_valid(peer_id: &PeerId, quote: &PaymentQuote) -> bool { compute_address("e.pub_key) == *peer_id.as_bytes() } +/// ADR-0003 client-side resolve-before-pay gate — "the client pays nothing it +/// cannot resolve", the ceiling's load-bearing wall (ADR §"The client pays +/// nothing it cannot resolve"). +/// +/// Runs the **full** binding check before paying, identical to the storer's, +/// using the shared `ant-protocol` commitment type + verifier so client and +/// node can never disagree: +/// 1. **Shape.** `(0, None)` baseline or `(n>0, Some(pin))` bound; the mixed +/// shapes `(n>0, None)` (unauditable count) and `(0, Some)` (incoherent +/// baseline) are rejected. +/// 2. **Cap.** `committed_key_count <= MAX_COMMITMENT_KEY_COUNT` — a count a +/// commitment could never legitimately attest is rejected before pricing. +/// 3. **Forced price.** `price == calculate_price(committed_key_count)`, by +/// exact recomputation with the shared `calculate_price` — never inverted. +/// 4. **Resolution (bound quotes).** The shipped commitment must: parse as a +/// `StorageCommitment`, be bound to the quoting peer +/// (`BLAKE3(sender_public_key) == sender_peer_id`), have a valid ML-DSA-65 +/// signature, hash to the quote's `commitment_pin` +/// (`commitment_hash == pin`), and attest exactly the claimed count +/// (`key_count == committed_key_count`). A withheld, unparseable, wrong-pin, +/// mis-bound, or count-mismatched commitment is unresolvable → the quote is +/// dropped before payment. +/// +/// Returns `Ok(())` if the binding fully resolves, or `Err(detail)` naming the +/// rule that failed. +fn quote_commitment_binding_is_valid( + peer_id: &PeerId, + quote: &PaymentQuote, + commitment: &Option>, +) -> std::result::Result<(), String> { + let count = quote.committed_key_count; + let pin = quote.commitment_pin; + match (count, pin.is_some()) { + (0, false) | (1.., true) => {} + (1.., false) => { + return Err(format!( + "committed_key_count={count} > 0 but commitment_pin is None (unauditable count)" + )); + } + (0, true) => { + return Err("committed_key_count=0 with a commitment_pin (incoherent baseline)".into()); + } + } + if count > MAX_COMMITMENT_KEY_COUNT { + return Err(format!( + "committed_key_count={count} exceeds MAX_COMMITMENT_KEY_COUNT={MAX_COMMITMENT_KEY_COUNT}" + )); + } + // Forced price: exact recomputation, never inversion. + let expected = calculate_price(count as usize); + if quote.price != expected { + return Err(format!( + "price {} does not equal calculate_price(committed_key_count={count}) = {expected}", + quote.price + )); + } + + // Baseline `(0, None)` pins nothing — fully resolved by the checks above. + let Some(pin) = pin else { + return Ok(()); + }; + + // Bound quote: the commitment MUST have arrived and MUST resolve the pin. + let Some(blob) = commitment else { + return Err( + "bound quote did not ship its commitment; the pin is unresolvable so the quote \ + is dropped before payment" + .into(), + ); + }; + // Cap before parsing: bound the deserialize work a malicious responder can + // force, and never forward an oversized blob in the PUT bundle. + if blob.len() > MAX_COMMITMENT_SIDECAR_BYTES { + return Err(format!( + "shipped commitment is {} bytes, exceeds MAX_COMMITMENT_SIDECAR_BYTES={MAX_COMMITMENT_SIDECAR_BYTES}", + blob.len() + )); + } + let commitment: StorageCommitment = rmp_serde::from_slice(blob).map_err(|e| { + format!("shipped commitment did not deserialize as a StorageCommitment: {e}") + })?; + + // Peer binding: the commitment must belong to the quoting peer, exactly as + // the storer derives a candidate's peer id (`BLAKE3(pub_key)`). + if compute_address(&commitment.sender_public_key) != *peer_id.as_bytes() + || commitment.sender_peer_id != *peer_id.as_bytes() + { + return Err("shipped commitment is not bound to the quoting peer".into()); + } + if !verify_commitment_signature(&commitment) { + return Err("shipped commitment has an invalid signature".into()); + } + if commitment_hash(&commitment) != Some(pin) { + return Err("shipped commitment does not hash to the quote's pin".into()); + } + if commitment.key_count != count { + return Err(format!( + "shipped commitment attests key_count={} but the quote claims {count}", + commitment.key_count + )); + } + Ok(()) +} + /// Classification of a `ChunkQuoteResponse::Success` body for a single peer. /// /// Mirrors the storer-side `validate_peer_bindings` check from /// `ant-node/src/payment/verifier.rs` — the cheap BLAKE3 binding — /// so we drop misbehaving peers' quotes before payment. /// -/// We deliberately do NOT mirror the storer's `verify_quote_signature` -/// (ML-DSA-65 verify, ~1 ms × CLOSE_GROUP_SIZE × every chunk) or -/// `verify_quote_content`. Those are useful defense-in-depth for an -/// attacker who self-consistently crafts a signed-but-stolen or wrong- -/// content quote, but they are NOT cheap and are out of scope for this -/// fix. Adding them changes upload latency materially. Track them as a -/// follow-up if a real attack surfaces them. +/// ADR-0003: the client now ALSO runs the storer's `verify_quote_content` and +/// `verify_quote_signature` (ML-DSA-65) before paying, so "the client pays +/// nothing it cannot resolve" covers the quote's own validity too, not just the +/// commitment binding. This matches what the merkle path already does +/// client-side and costs ~1 ms × CLOSE_GROUP_SIZE per chunk — accepted, since +/// paying a quote the storer then rejects burns the on-chain payment. /// /// Pulling the logic out of the async closure lets us unit-test the /// primary defense (not just the post-collect defensive filter). @@ -101,12 +224,21 @@ fn quote_binding_is_valid(peer_id: &PeerId, quote: &PaymentQuote) -> bool { /// - `Err(Error::BadQuoteBinding { .. })` — bad binding (mirrors the /// storer-side rejection). Outer collector counts these via the typed /// variant (no string matching). +/// - `Err(Error::BadQuoteCommitment { .. })` — ADR-0003 forced-price binding +/// failed (price off the curve, incoherent shape, or a bound quote that did +/// not ship its commitment); dropped before payment like a bad binding. /// - `Err(Error::Serialization(...))` — the quote bytes did not deserialize. +/// +/// On success the returned commitment is the opaque signed-commitment blob the +/// node shipped with the quote (`None` for a baseline quote), to be forwarded +/// as a sidecar in the PUT bundle. fn classify_quote_response( peer_id: &PeerId, + expected_content: &[u8; 32], quote_bytes: &[u8], already_stored: bool, -) -> std::result::Result<(PaymentQuote, Amount), Error> { + commitment: Option>, +) -> std::result::Result<(PaymentQuote, Amount, Option>), Error> { let payment_quote = rmp_serde::from_slice::(quote_bytes).map_err(|e| { Error::Serialization(format!("Failed to deserialize quote from {peer_id}: {e}")) })?; @@ -132,22 +264,51 @@ fn classify_quote_response( }); } + // ADR-0003 "the client runs the full binding check": verify the quote's OWN + // ML-DSA-65 signature and that it is for THIS content, before paying — + // exactly what the storer checks and what the merkle path already does + // client-side. A quote with a valid pub_key binding but a bad signature or + // wrong content would otherwise be paid and then rejected by the storer. + if !verify_quote_content(&payment_quote, expected_content) { + return Err(Error::BadQuoteBinding { + peer_id: peer_id.to_string(), + detail: "quote content does not match the requested address".to_string(), + }); + } + if !verify_quote_signature(&payment_quote) { + return Err(Error::BadQuoteBinding { + peer_id: peer_id.to_string(), + detail: "quote ML-DSA-65 signature is invalid".to_string(), + }); + } + + // ADR-0003 forced-price gate: drop a quote whose price is not exactly the + // public formula of its committed count, whose (count, pin) shape is + // incoherent, or which is bound but did not ship its commitment. The storer + // re-runs the arithmetic and would reject the bundle; we drop it here so we + // never pay a quote we cannot resolve. + if let Err(detail) = quote_commitment_binding_is_valid(peer_id, &payment_quote, &commitment) { + warn!("Dropping response from {peer_id} — ADR-0003 binding invalid: {detail}"); + return Err(Error::BadQuoteCommitment { + peer_id: peer_id.to_string(), + detail, + }); + } + if already_stored { debug!("Peer {peer_id} already has chunk"); return Err(Error::AlreadyStored); } let price = payment_quote.price; debug!("Received quote from {peer_id}: price = {price}"); - Ok((payment_quote, price)) + Ok((payment_quote, price, commitment)) } /// Drop quotes whose `pub_key` does not BLAKE3-hash to the peer that supplied /// them. Logs each dropped quote at WARN. -fn drop_quotes_with_bad_bindings( - quotes: &mut Vec<(PeerId, Vec, PaymentQuote, Amount)>, -) -> usize { +fn drop_quotes_with_bad_bindings(quotes: &mut Vec) -> usize { let before = quotes.len(); - quotes.retain(|(peer_id, _, quote, _)| { + quotes.retain(|(peer_id, _, quote, _, _)| { if quote_binding_is_valid(peer_id, quote) { true } else { @@ -1093,7 +1254,7 @@ impl Client { // Check already-stored: only count votes from the closest CLOSE_GROUP_SIZE peers. if !already_stored_peers.is_empty() { let mut all_peers_by_distance: Vec<(bool, [u8; 32])> = Vec::new(); - for (peer_id, _, _, _) in "es { + for (peer_id, _, _, _, _) in "es { all_peers_by_distance.push((false, peer_xor_distance(peer_id, address))); } for (_, dist) in &already_stored_peers { @@ -1186,39 +1347,71 @@ mod tests { struct Keypair { peer_id: PeerId, pub_key_bytes: Vec, + secret_key_bytes: Vec, } fn gen_keypair() -> Keypair { let ml_dsa = MlDsa65::new(); - let (pub_key, _sk) = ml_dsa.generate_keypair().expect("ML-DSA-65 keygen"); + let (pub_key, sk) = ml_dsa.generate_keypair().expect("ML-DSA-65 keygen"); let pub_key_bytes = pub_key.as_bytes().to_vec(); let peer_id = PeerId::from_bytes(compute_address(&pub_key_bytes)); Keypair { peer_id, pub_key_bytes, + secret_key_bytes: sk.as_bytes().to_vec(), } } + /// Build a PROPERLY-SIGNED baseline quote for `content`, signed by a real + /// ML-DSA-65 key whose `BLAKE3(pub_key)` is the returned peer id. Passes the + /// client's full classifier gate (binding + content + signature + price). + fn signed_baseline_quote(content: [u8; 32]) -> (PeerId, PaymentQuote) { + use ant_protocol::pqc::ops::MlDsaSecretKey; + let kp = gen_keypair(); + let mut quote = PaymentQuote { + content: XorName(content), + timestamp: SystemTime::UNIX_EPOCH, + price: calculate_price(0), + rewards_address: RewardsAddress::new([0u8; 20]), + pub_key: kp.pub_key_bytes.clone(), + signature: Vec::new(), + committed_key_count: 0, + commitment_pin: None, + }; + let ml_dsa = MlDsa65::new(); + let sk = MlDsaSecretKey::from_bytes(&kp.secret_key_bytes).expect("sk"); + let msg = quote.bytes_for_sig(); + quote.signature = ml_dsa.sign(&sk, &msg).expect("sign").as_bytes().to_vec(); + (kp.peer_id, quote) + } + /// Build a quote tuple whose `pub_key` correctly hashes to its peer_id. /// Signature is left empty: this filter does not verify signatures. - fn good_quote_real() -> (PeerId, Vec, PaymentQuote, Amount) { + /// + /// The quote is a valid ADR-0003 **baseline**: `(0, None)` priced at + /// `calculate_price(0)`, so it passes the forced-price gate in + /// `classify_quote_response`. The 5th tuple element is the (absent) + /// commitment sidecar. + fn good_quote_real() -> QuotedPeer { let kp = gen_keypair(); let quote = PaymentQuote { content: XorName([0u8; 32]), timestamp: SystemTime::UNIX_EPOCH, - price: Amount::ZERO, + price: calculate_price(0), rewards_address: RewardsAddress::new([0u8; 20]), pub_key: kp.pub_key_bytes, signature: Vec::new(), + committed_key_count: 0, + commitment_pin: None, }; - (kp.peer_id, Vec::new(), quote, Amount::ZERO) + (kp.peer_id, Vec::new(), quote, calculate_price(0), None) } /// Build a quote tuple where the quote carries a different keypair's /// `pub_key` than the peer_id derives from. Mirrors the production /// failure shape: peer A advertised on the transport, but the quote /// carries peer B's key. - fn bad_quote_real() -> (PeerId, Vec, PaymentQuote, Amount) { + fn bad_quote_real() -> QuotedPeer { let claimed = gen_keypair(); let signing = gen_keypair(); assert_ne!(claimed.pub_key_bytes, signing.pub_key_bytes); @@ -1226,12 +1419,14 @@ mod tests { let quote = PaymentQuote { content: XorName([0u8; 32]), timestamp: SystemTime::UNIX_EPOCH, - price: Amount::ZERO, + price: calculate_price(0), rewards_address: RewardsAddress::new([0u8; 20]), pub_key: signing.pub_key_bytes, signature: Vec::new(), + committed_key_count: 0, + commitment_pin: None, }; - (claimed.peer_id, Vec::new(), quote, Amount::ZERO) + (claimed.peer_id, Vec::new(), quote, calculate_price(0), None) } fn witnessed_test_node(seed: u8) -> DHTNode { @@ -1319,7 +1514,7 @@ mod tests { #[test] fn binding_accepts_real_self_consistent_keypair() { - let (peer_id, _, quote, _) = good_quote_real(); + let (peer_id, _, quote, _, _) = good_quote_real(); // Property under test: the predicate accepts a quote whose pub_key // genuinely belongs to the claimed peer. assert!(quote_binding_is_valid(&peer_id, "e)); @@ -1329,7 +1524,7 @@ mod tests { #[test] fn binding_rejects_real_crossed_keypair() { - let (peer_id, _, quote, _) = bad_quote_real(); + let (peer_id, _, quote, _, _) = bad_quote_real(); assert!(!quote_binding_is_valid(&peer_id, "e)); assert!(!storer_binding_would_accept(&peer_id, "e)); } @@ -1348,6 +1543,8 @@ mod tests { rewards_address: RewardsAddress::new([0u8; 20]), pub_key: oversized, signature: Vec::new(), + committed_key_count: 0, + commitment_pin: None, }; // BLAKE3(pub_key) DOES equal the peer_id we constructed, so the // bare hash check would pass — but the length guard must reject. @@ -1370,6 +1567,8 @@ mod tests { rewards_address: RewardsAddress::new([0u8; 20]), pub_key: undersized, signature: Vec::new(), + committed_key_count: 0, + commitment_pin: None, }; assert!(!quote_binding_is_valid(&peer_id, "e)); assert!(!storer_binding_would_accept(&peer_id, "e)); @@ -1854,7 +2053,7 @@ mod tests { // the binding, so this asserts the binding-only filter is correct // for binding-only failures (other failure modes are filtered by // the per-peer classifier upstream). - for (peer_id, _, quote, _) in "es { + for (peer_id, _, quote, _, _) in "es { assert!( storer_binding_would_accept(peer_id, quote), "every retained quote must satisfy the full storer-side spec" @@ -1869,7 +2068,7 @@ mod tests { let dropped = drop_quotes_with_bad_bindings(&mut quotes); assert_eq!(dropped, 0); assert_eq!(quotes.len(), before); - for (peer_id, _, quote, _) in "es { + for (peer_id, _, quote, _, _) in "es { assert!(storer_binding_would_accept(peer_id, quote)); } } @@ -1894,11 +2093,17 @@ mod tests { // signature, content, timestamp, price, rewards_address. The patch // is a filter, not a transformation; this test catches any future // regression that mutates a retained quote. - let (peer_id, addrs, original_quote, amount) = good_quote_real(); - let mut quotes = vec![(peer_id, addrs.clone(), original_quote.clone(), amount)]; + let (peer_id, addrs, original_quote, amount, commitment) = good_quote_real(); + let mut quotes = vec![( + peer_id, + addrs.clone(), + original_quote.clone(), + amount, + commitment, + )]; let _ = drop_quotes_with_bad_bindings(&mut quotes); - let (kept_peer, kept_addrs, kept_quote, kept_amount) = + let (kept_peer, kept_addrs, kept_quote, kept_amount, _kept_commitment) = quotes.pop().expect("the good quote must survive filtering"); assert_eq!(kept_peer.as_bytes(), peer_id.as_bytes()); assert_eq!(kept_addrs.len(), addrs.len()); @@ -1955,7 +2160,7 @@ mod tests { // Step 1: prove the storer would reject the pre-filter set. let storer_would_reject_count = quotes .iter() - .filter(|(p, _, q, _)| !storer_binding_would_accept(p, q)) + .filter(|(p, _, q, _, _)| !storer_binding_would_accept(p, q)) .count(); assert_eq!( storer_would_reject_count, 1, @@ -1967,7 +2172,7 @@ mod tests { assert_eq!(dropped, 1, "exactly the crossed-key quote must be filtered"); // Step 3: prove the storer would accept every survivor under the FULL spec. - for (peer_id, _, quote, _) in "es { + for (peer_id, _, quote, _, _) in "es { assert!( storer_binding_would_accept(peer_id, quote), "every post-filter quote must be accepted by the storer spec — \ @@ -2003,7 +2208,7 @@ mod tests { "this is the precondition for InsufficientPeers downstream" ); // Sanity: every survivor is storer-acceptable under the full spec. - for (peer_id, _, quote, _) in "es { + for (peer_id, _, quote, _, _) in "es { assert!(storer_binding_would_accept(peer_id, quote)); } } @@ -2028,23 +2233,54 @@ mod tests { #[test] fn classifier_accepts_real_self_consistent_quote() { - let (peer_id, _, quote, _) = good_quote_real(); + // A properly-signed baseline quote for the requested content passes the + // full client gate (binding + content + signature + price). + let content = [7u8; 32]; + let (peer_id, quote) = signed_baseline_quote(content); let bytes = serialize_quote("e); - let result = classify_quote_response(&peer_id, &bytes, false); + let result = classify_quote_response(&peer_id, &content, &bytes, false, None); match result { - Ok((q, price)) => { + Ok((q, price, commitment)) => { assert_eq!(q.pub_key, quote.pub_key); assert_eq!(price, quote.price); + assert!(commitment.is_none(), "baseline quote ships no commitment"); } Err(e) => panic!("expected Ok, got {e}"), } } + #[test] + fn classifier_rejects_quote_with_invalid_signature() { + // A quote whose pub_key binds correctly but whose signature is bogus is + // dropped BEFORE payment (the storer would reject it and burn the pay). + let content = [7u8; 32]; + let (peer_id, mut quote) = signed_baseline_quote(content); + quote.signature = vec![0u8; quote.signature.len()]; // corrupt the signature + let bytes = serialize_quote("e); + let result = classify_quote_response(&peer_id, &content, &bytes, false, None); + assert!( + matches!(result, Err(Error::BadQuoteBinding { .. })), + "a quote with an invalid signature must be rejected; got {result:?}" + ); + } + + #[test] + fn classifier_rejects_quote_for_wrong_content() { + // A validly-signed quote for a DIFFERENT address is dropped before pay. + let (peer_id, quote) = signed_baseline_quote([7u8; 32]); + let bytes = serialize_quote("e); + let result = classify_quote_response(&peer_id, &[9u8; 32], &bytes, false, None); + assert!( + matches!(result, Err(Error::BadQuoteBinding { .. })), + "a quote for the wrong content must be rejected; got {result:?}" + ); + } + #[test] fn classifier_rejects_crossed_keypair_with_typed_error() { - let (peer_id, _, quote, _) = bad_quote_real(); + let (peer_id, _, quote, _, _) = bad_quote_real(); let bytes = serialize_quote("e); - let result = classify_quote_response(&peer_id, &bytes, false); + let result = classify_quote_response(&peer_id, &[0u8; 32], &bytes, false, None); match result { Err(Error::BadQuoteBinding { peer_id: pid, @@ -2072,10 +2308,10 @@ mod tests { /// unfiltered." #[test] fn classifier_rejects_already_stored_vote_from_bad_binding_peer() { - let (peer_id, _, quote, _) = bad_quote_real(); + let (peer_id, _, quote, _, _) = bad_quote_real(); let bytes = serialize_quote("e); // The peer claims already_stored=true, but its quote has a crossed key. - let result = classify_quote_response(&peer_id, &bytes, true); + let result = classify_quote_response(&peer_id, &[0u8; 32], &bytes, true, None); assert!( matches!(result, Err(Error::BadQuoteBinding { .. })), "crossed-key peer must be classified BadQuoteBinding even when \ @@ -2087,9 +2323,10 @@ mod tests { /// passing the bind-check). This is the contrast to the test above. #[test] fn classifier_honours_already_stored_vote_from_good_binding_peer() { - let (peer_id, _, quote, _) = good_quote_real(); + let content = [7u8; 32]; + let (peer_id, quote) = signed_baseline_quote(content); let bytes = serialize_quote("e); - let result = classify_quote_response(&peer_id, &bytes, true); + let result = classify_quote_response(&peer_id, &content, &bytes, true, None); assert!( matches!(result, Err(Error::AlreadyStored)), "honest peer's already_stored vote must be honoured; got {result:?}" @@ -2098,9 +2335,9 @@ mod tests { #[test] fn classifier_returns_serialization_error_on_bad_bytes() { - let (peer_id, _, _, _) = good_quote_real(); + let (peer_id, _, _, _, _) = good_quote_real(); let garbage = b"this is not a valid msgpack PaymentQuote".to_vec(); - let result = classify_quote_response(&peer_id, &garbage, false); + let result = classify_quote_response(&peer_id, &[0u8; 32], &garbage, false, None); assert!( matches!(result, Err(Error::Serialization(_))), "garbage bytes must produce a Serialization error; got {result:?}" @@ -2111,21 +2348,19 @@ mod tests { /// independent storer-spec re-derivation across mixed responders. #[test] fn classifier_verdict_matches_storer_binding_spec_for_mixed_responders() { - let mut responders: Vec<(PeerId, PaymentQuote)> = (0..12) - .map(|_| { - let (p, _, q, _) = good_quote_real(); - (p, q) - }) - .collect(); + let content = [7u8; 32]; + let mut responders: Vec<(PeerId, PaymentQuote)> = + (0..12).map(|_| signed_baseline_quote(content)).collect(); for _ in 0..4 { - let (p, _, q, _) = bad_quote_real(); + let (p, _, q, _, _) = bad_quote_real(); responders.push((p, q)); } for (peer_id, quote) in &responders { let bytes = serialize_quote(quote); let storer_verdict = storer_binding_would_accept(peer_id, quote); - let classifier_verdict = classify_quote_response(peer_id, &bytes, false).is_ok(); + let classifier_verdict = + classify_quote_response(peer_id, &content, &bytes, false, None).is_ok(); assert_eq!( classifier_verdict, storer_verdict, "classifier and storer-binding-spec must agree on every responder \ @@ -2134,4 +2369,167 @@ mod tests { ); } } + + // ============================================================ + // ADR-0003: quote_commitment_binding_is_valid (forced-price gate) + // + // Mirrors the storer-side `binding_violation` in + // `ant-node/src/payment/verifier.rs`. The client runs this before + // paying so it never pays a quote the storer's arithmetic gate would + // reject. The client now runs the FULL check (shape, cap, exact price, + // and for bound quotes: parse + peer-binding + signature + hash==pin + + // count==key_count) using the shared ant-protocol commitment type, so an + // unresolvable/forged commitment is never paid. A live resolve against a + // REAL signed commitment is proven in the e2e suite (e2e_adr0003.rs). + // ============================================================ + + /// A throwaway peer id for tests that fail BEFORE commitment resolution + /// (shape/cap/price checks don't depend on the peer). + fn any_peer() -> PeerId { + PeerId::from_bytes([0u8; 32]) + } + + /// Build a quote carrying a specific `(count, pin, price)` binding. + fn quote_with_binding( + committed_key_count: u32, + commitment_pin: Option<[u8; 32]>, + price: Amount, + ) -> PaymentQuote { + PaymentQuote { + content: XorName([0u8; 32]), + timestamp: SystemTime::UNIX_EPOCH, + price, + rewards_address: RewardsAddress::new([0u8; 20]), + pub_key: Vec::new(), + signature: Vec::new(), + committed_key_count, + commitment_pin, + } + } + + #[test] + fn binding_baseline_ok_only_at_baseline_price() { + // (0, None) priced at calculate_price(0) is the valid baseline. + let q = quote_with_binding(0, None, calculate_price(0)); + assert!(quote_commitment_binding_is_valid(&any_peer(), &q, &None).is_ok()); + + // (0, None) priced above baseline is rejected — the forged-shape + // bypass (strip the pin, charge more than the empty-node price). + let q = quote_with_binding(0, None, calculate_price(500)); + assert!(quote_commitment_binding_is_valid(&any_peer(), &q, &None).is_err()); + } + + #[test] + fn binding_rejects_incoherent_shapes() { + // count > 0 but no pin: unauditable. + let q = quote_with_binding(500, None, calculate_price(500)); + assert!(quote_commitment_binding_is_valid(&any_peer(), &q, &None).is_err()); + // count 0 but a pin: incoherent baseline. + let q = quote_with_binding(0, Some([9u8; 32]), calculate_price(0)); + assert!(quote_commitment_binding_is_valid(&any_peer(), &q, &None).is_err()); + } + + #[test] + fn binding_rejects_count_above_cap() { + let over = MAX_COMMITMENT_KEY_COUNT + 1; + let q = quote_with_binding(over, Some([9u8; 32]), calculate_price(over as usize)); + assert!( + quote_commitment_binding_is_valid(&any_peer(), &q, &Some(vec![1u8; 16])).is_err(), + "a count above MAX_COMMITMENT_KEY_COUNT must be rejected before payment" + ); + } + + #[test] + fn binding_rejects_on_curve_wrong_count() { + // Priced for 499 but claims count 500 — on a real price curve but the + // wrong count. Rejected at the exact-price check, before resolution. + let q = quote_with_binding(500, Some([9u8; 32]), calculate_price(499)); + assert!(quote_commitment_binding_is_valid(&any_peer(), &q, &Some(vec![1u8; 16])).is_err()); + } + + #[test] + fn binding_rejects_bound_quote_without_shipped_commitment() { + // A bound quote whose commitment did not arrive is unresolvable, so it + // is dropped before payment even though its price is on the curve. + let q = quote_with_binding(500, Some([9u8; 32]), calculate_price(500)); + assert!( + quote_commitment_binding_is_valid(&any_peer(), &q, &None).is_err(), + "a bound quote missing its commitment must be rejected" + ); + } + + #[test] + fn binding_rejects_garbage_and_wrong_pin_commitment() { + // A bound quote whose shipped commitment is garbage (doesn't even + // deserialize) is rejected — the client never pays an unresolvable pin. + let q = quote_with_binding(500, Some([9u8; 32]), calculate_price(500)); + assert!( + quote_commitment_binding_is_valid(&any_peer(), &q, &Some(vec![0xFF; 8])).is_err(), + "an unparseable commitment must be rejected before payment" + ); + + // A well-formed-but-wrong commitment (valid StorageCommitment bytes that + // do NOT hash to the quote's pin / aren't bound to the peer) is also + // rejected. We serialize a real StorageCommitment shape with mismatched + // fields; it fails peer-binding first, then would fail hash==pin. + let bogus = StorageCommitment { + root: [1u8; 32], + key_count: 500, + sender_peer_id: [2u8; 32], // not the quoting peer + sender_public_key: vec![3u8; 1952], + signature: vec![4u8; 3293], + }; + let blob = rmp_serde::to_vec(&bogus).expect("serialize bogus commitment"); + assert!( + quote_commitment_binding_is_valid(&any_peer(), &q, &Some(blob)).is_err(), + "a commitment not bound to the quoting peer must be rejected before payment" + ); + } + + #[test] + fn binding_rejects_oversized_commitment_before_parsing() { + // A bound quote shipping a blob larger than the sidecar cap is rejected + // before any deserialize attempt (DoS guard on the hot path). + let q = quote_with_binding(500, Some([9u8; 32]), calculate_price(500)); + let huge = Some(vec![0u8; MAX_COMMITMENT_SIDECAR_BYTES + 1]); + assert!( + quote_commitment_binding_is_valid(&any_peer(), &q, &huge).is_err(), + "an oversized commitment blob must be rejected before payment" + ); + } + + #[test] + fn classifier_drops_off_curve_quote_with_typed_error() { + // End-to-end through the classifier: a VALIDLY-SIGNED, correctly-bound + // quote for the right content, but with an off-curve price, is dropped + // as BadQuoteCommitment (the forced-price extraction guard fires after + // the quote's own signature/content checks pass). + use ant_protocol::pqc::ops::MlDsaSecretKey; + let content = [7u8; 32]; + let kp = gen_keypair(); + let mut quote = PaymentQuote { + content: XorName(content), + timestamp: SystemTime::UNIX_EPOCH, + // claims baseline shape but charges a non-baseline price + price: calculate_price(500), + rewards_address: RewardsAddress::new([0u8; 20]), + pub_key: kp.pub_key_bytes.clone(), + signature: Vec::new(), + committed_key_count: 0, + commitment_pin: None, + }; + let ml_dsa = MlDsa65::new(); + let sk = MlDsaSecretKey::from_bytes(&kp.secret_key_bytes).expect("sk"); + quote.signature = ml_dsa + .sign(&sk, "e.bytes_for_sig()) + .expect("sign") + .as_bytes() + .to_vec(); + let bytes = serialize_quote("e); + let result = classify_quote_response(&kp.peer_id, &content, &bytes, false, None); + assert!( + matches!(result, Err(Error::BadQuoteCommitment { .. })), + "off-curve quote must be dropped as BadQuoteCommitment; got {result:?}" + ); + } } diff --git a/ant-core/src/data/error.rs b/ant-core/src/data/error.rs index f6e94386..8f7ed960 100644 --- a/ant-core/src/data/error.rs +++ b/ant-core/src/data/error.rs @@ -119,6 +119,19 @@ pub enum Error { detail: String, }, + /// ADR-0003: a quote's commitment binding does not hold — its price is not + /// `calculate_price(committed_key_count)`, its `(count, pin)` shape is + /// incoherent, or a shipped commitment does not match the pinned count/hash. + /// The storer's arithmetic gate would reject such a quote, so the client + /// drops it before paying ("the client pays nothing it cannot resolve"). + #[error("bad quote commitment from peer {peer_id}: {detail}")] + BadQuoteCommitment { + /// The peer ID we got the quote from. + peer_id: String, + /// Diagnostic detail (which binding rule failed). + detail: String, + }, + /// Not enough disk space for the operation. #[error("insufficient disk space: {0}")] InsufficientDiskSpace(String), diff --git a/ant-core/tests/e2e_adr0003.rs b/ant-core/tests/e2e_adr0003.rs new file mode 100644 index 00000000..448047c4 --- /dev/null +++ b/ant-core/tests/e2e_adr0003.rs @@ -0,0 +1,186 @@ +//! ADR-0003 end-to-end: commitment-bound quote pricing. +//! +//! Proves, against a real in-process QUIC testnet + Anvil EVM, that: +//! +//! 1. A node carrying a live storage commitment emits a COMMITMENT-BOUND quote: +//! `committed_key_count == N`, `commitment_pin == Some`, the price is exactly +//! `calculate_price(N)`, and the signed commitment is SHIPPED in the quote +//! response (ADR-0003 "the commitment arrived with the quote"). +//! 2. The client's forced-price gate ACCEPTS those bound quotes (they are +//! self-consistent) and a full pay → store → retrieve round-trip succeeds — +//! so the storer accepts the bound quotes and the forwarded sidecars too. +//! 3. A node with NO commitment emits a valid BASELINE quote `(0, None)` priced +//! at `calculate_price(0)`, and the full flow still works. +//! +//! The OFF-CURVE rejection (a node charging above its committed count is dropped +//! before payment) is proven directly on the gate in the `quote.rs` unit tests +//! (`classifier_drops_off_curve_quote_with_typed_error` + the +//! `quote_commitment_binding_is_valid` suite) — honest nodes never emit an +//! off-curve quote, so it cannot be surfaced through a live happy-path network. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +mod support; + +use ant_core::data::{compute_address, Client}; +use ant_protocol::payment::calculate_price; +use ant_protocol::payment::commitment::{ + commitment_hash, verify_commitment_signature, StorageCommitment, +}; +use bytes::Bytes; +use serial_test::serial; +use std::sync::Arc; +use support::{test_client_config, MiniTestnet, DEFAULT_NODE_COUNT}; + +/// The committed key count every node attests in these tests. Picked above the +/// pricing baseline so `calculate_price(N)` is strictly greater than the +/// empty-node baseline — a bound quote is visibly distinct from a baseline one. +const COMMITTED_KEYS: u32 = 9_000; + +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn adr0003_bound_quotes_are_shipped_priced_and_resolve() { + let testnet = MiniTestnet::start_with_commitments(DEFAULT_NODE_COUNT, COMMITTED_KEYS).await; + let node = testnet.node(3).expect("node 3 exists"); + let client = Client::from_node(Arc::clone(&node), test_client_config()) + .with_wallet(testnet.wallet().clone()); + + let content = Bytes::from("adr-0003 bound-quote payload"); + let address = compute_address(&content); + + // Collect quotes directly so we can inspect the ADR-0003 binding the client + // verified before it would pay. + let quotes = client + .get_store_quotes(&address, content.len() as u64, 0) + .await + .expect("quote collection should reach quorum"); + + assert!( + quotes.len() >= ant_protocol::CLOSE_GROUP_SIZE, + "must collect a full close group of bound quotes, got {}", + quotes.len() + ); + + let expected_price = calculate_price(COMMITTED_KEYS as usize); + for (peer_id, _addrs, quote, price, commitment) in "es { + // Bound shape: the count is what the node committed, with a pin. + assert_eq!( + quote.committed_key_count, COMMITTED_KEYS, + "quote from {peer_id} must carry the committed key count" + ); + let pin = quote + .commitment_pin + .expect("a bound quote must carry a commitment pin"); + + // Forced price: exactly calculate_price(N), by recomputation. This is + // the ceiling — a node cannot charge above what it can prove it stores. + assert_eq!( + quote.price, expected_price, + "quote from {peer_id} must be priced at calculate_price(committed_key_count)" + ); + assert_eq!(*price, expected_price); + + // The commitment arrived WITH the quote (no separate fetch needed). + let sidecar = commitment + .as_ref() + .expect("a bound quote response must ship its signed commitment"); + + // FULLY resolve the shipped commitment exactly as the client gate does + // before paying — this is the real anti-cheat invariant, not just + // "non-empty bytes". The commitment must deserialize, carry a valid + // signature, be bound to the quoting peer, hash to the quote's pin, and + // attest exactly the claimed count. + let resolved: StorageCommitment = + rmp_serde::from_slice(sidecar).expect("shipped commitment must deserialize"); + assert!( + verify_commitment_signature(&resolved), + "shipped commitment from {peer_id} must be validly signed" + ); + assert_eq!( + resolved.sender_peer_id, + *peer_id.as_bytes(), + "shipped commitment must be bound to the quoting peer" + ); + assert_eq!( + compute_address(&resolved.sender_public_key), + *peer_id.as_bytes(), + "BLAKE3(sender_public_key) must equal the quoting peer (full peer binding)" + ); + assert_eq!( + commitment_hash(&resolved), + Some(pin), + "shipped commitment must hash to the quote's pin" + ); + assert_eq!( + resolved.key_count, COMMITTED_KEYS, + "shipped commitment must attest exactly the committed key count" + ); + } + + // Full round-trip: the client paid (accepting the bound quotes + forwarding + // the sidecars), the storers accepted the bundle, and the chunk is back. + let stored = client + .chunk_put(content.clone()) + .await + .expect("paid put with commitment-bound quotes must succeed"); + assert_eq!(stored, address); + + let retrieved = client + .chunk_get(&address) + .await + .expect("chunk_get should succeed") + .expect("chunk must be found after storing"); + assert_eq!(retrieved.content.as_ref(), content.as_ref()); + + drop(client); + testnet.teardown().await; +} + +/// The other half: a node with NO commitment emits a valid BASELINE quote +/// `(0, None)` priced at `calculate_price(0)`, and the full flow still works. +/// This guards the baseline branch of the same forced-price gate. +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn adr0003_baseline_quotes_still_work() { + let testnet = MiniTestnet::start(DEFAULT_NODE_COUNT).await; + let node = testnet.node(3).expect("node 3 exists"); + let client = Client::from_node(Arc::clone(&node), test_client_config()) + .with_wallet(testnet.wallet().clone()); + + let content = Bytes::from("adr-0003 baseline payload"); + let address = compute_address(&content); + + let quotes = client + .get_store_quotes(&address, content.len() as u64, 0) + .await + .expect("quote collection should reach quorum"); + + let baseline = calculate_price(0); + for (peer_id, _addrs, quote, _price, commitment) in "es { + assert_eq!( + quote.committed_key_count, 0, + "no-commitment node must quote count 0 ({peer_id})" + ); + assert!( + quote.commitment_pin.is_none(), + "baseline quote pins nothing ({peer_id})" + ); + assert_eq!( + quote.price, baseline, + "baseline quote must price at calculate_price(0) ({peer_id})" + ); + assert!( + commitment.is_none(), + "baseline quote ships no commitment ({peer_id})" + ); + } + + let stored = client + .chunk_put(content.clone()) + .await + .expect("paid put with baseline quotes must succeed"); + assert_eq!(stored, address); + + drop(client); + testnet.teardown().await; +} diff --git a/ant-core/tests/e2e_payment.rs b/ant-core/tests/e2e_payment.rs index 3275c348..311b608a 100644 --- a/ant-core/tests/e2e_payment.rs +++ b/ant-core/tests/e2e_payment.rs @@ -262,7 +262,7 @@ async fn test_quote_collection() { ); // All prices should be > 0 - for (peer_id, _addrs, _quote, price) in "es { + for (peer_id, _addrs, _quote, price, _commitment) in "es { assert!( !price.is_zero(), "Quote price from peer {peer_id} should be > 0" @@ -270,7 +270,8 @@ async fn test_quote_collection() { } // All peer IDs should be unique - let unique_peers: std::collections::HashSet<_> = quotes.iter().map(|(p, _, _, _)| *p).collect(); + let unique_peers: std::collections::HashSet<_> = + quotes.iter().map(|(p, _, _, _, _)| *p).collect(); assert_eq!( unique_peers.len(), quotes.len(), diff --git a/ant-core/tests/e2e_security.rs b/ant-core/tests/e2e_security.rs index 888b418c..5b91ed4f 100644 --- a/ant-core/tests/e2e_security.rs +++ b/ant-core/tests/e2e_security.rs @@ -42,10 +42,14 @@ async fn collect_and_pay(client: &Client, content: &Bytes) -> (PaymentProof, Vec // Build peer_quotes and payment let mut peer_quotes = Vec::with_capacity(quotes.len()); let mut quotes_for_payment = Vec::with_capacity(quotes.len()); - for (peer_id, _addrs, quote, price) in quotes { + let mut commitment_sidecars = Vec::new(); + 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)); + if let Some(sidecar) = commitment { + commitment_sidecars.push(sidecar); + } } // Pay on-chain @@ -57,6 +61,7 @@ async fn collect_and_pay(client: &Client, content: &Bytes) -> (PaymentProof, Vec let proof = PaymentProof { proof_of_payment: ProofOfPayment { peer_quotes }, tx_hashes, + commitment_sidecars, }; let proof_bytes = serialize_single_node_proof(&proof).expect("serialize proof"); @@ -86,6 +91,7 @@ async fn test_attack_forged_signature() { peer_quotes: tampered_quotes, }, tx_hashes: proof.tx_hashes.clone(), + commitment_sidecars: proof.commitment_sidecars.clone(), }; let tampered_bytes = rmp_serde::to_vec(&tampered_proof).expect("serialize tampered proof"); @@ -192,7 +198,7 @@ async fn test_attack_zero_amount_payment() { let target_peer = quotes.first().expect("should have quotes").0; let mut peer_quotes = Vec::with_capacity(quotes.len()); - for (peer_id, _addrs, quote, _price) in quotes { + for (peer_id, _addrs, quote, _price, _commitment) in quotes { let encoded = EncodedPeerId::new(*peer_id.as_bytes()); peer_quotes.push((encoded, quote)); } @@ -201,6 +207,7 @@ async fn test_attack_zero_amount_payment() { let fake_proof = PaymentProof { proof_of_payment: ProofOfPayment { peer_quotes }, tx_hashes: vec![], + commitment_sidecars: vec![], }; let fake_bytes = rmp_serde::to_vec(&fake_proof).expect("serialize fake proof"); @@ -236,7 +243,7 @@ async fn test_attack_fabricated_tx_hash() { let target_peer = quotes.first().expect("should have quotes").0; let mut peer_quotes = Vec::with_capacity(quotes.len()); - for (peer_id, _addrs, quote, _price) in quotes { + for (peer_id, _addrs, quote, _price, _commitment) in quotes { let encoded = EncodedPeerId::new(*peer_id.as_bytes()); peer_quotes.push((encoded, quote)); } @@ -246,6 +253,7 @@ async fn test_attack_fabricated_tx_hash() { let fake_proof = PaymentProof { proof_of_payment: ProofOfPayment { peer_quotes }, tx_hashes: vec![fake_tx_hash], + commitment_sidecars: vec![], }; let fake_bytes = rmp_serde::to_vec(&fake_proof).expect("serialize fake proof"); @@ -317,6 +325,7 @@ async fn test_attack_corrupted_public_key() { peer_quotes: tampered_quotes, }, tx_hashes: proof.tx_hashes.clone(), + commitment_sidecars: proof.commitment_sidecars.clone(), }; let tampered_bytes = rmp_serde::to_vec(&tampered_proof).expect("serialize tampered proof"); @@ -385,16 +394,20 @@ async fn test_attack_underpayment_single_node() { // to target the median peer after from_quotes() sorts internally. let peer_by_rewards: Vec<(PeerId, RewardsAddress)> = quotes .iter() - .map(|(pid, _, q, _)| (*pid, q.rewards_address)) + .map(|(pid, _, q, _, _)| (*pid, q.rewards_address)) .collect(); // 2. Build SingleNodePayment 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()); - for (peer_id, _addrs, quote, price) in quotes { + let mut commitment_sidecars = Vec::new(); + 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)); + if let Some(sidecar) = commitment { + commitment_sidecars.push(sidecar); + } } let mut payment = SingleNodePayment::from_quotes(quotes_for_payment) @@ -434,6 +447,7 @@ async fn test_attack_underpayment_single_node() { let proof = PaymentProof { proof_of_payment: ProofOfPayment { peer_quotes }, tx_hashes, + commitment_sidecars, }; let proof_bytes = serialize_single_node_proof(&proof).expect("serialize proof"); @@ -473,15 +487,19 @@ async fn test_attack_underpayment_half_price() { let peer_by_rewards: Vec<(PeerId, RewardsAddress)> = quotes .iter() - .map(|(pid, _, q, _)| (*pid, q.rewards_address)) + .map(|(pid, _, q, _, _)| (*pid, q.rewards_address)) .collect(); let mut peer_quotes = Vec::with_capacity(quotes.len()); let mut quotes_for_payment = Vec::with_capacity(quotes.len()); - for (peer_id, _addrs, quote, price) in quotes { + let mut commitment_sidecars = Vec::new(); + 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)); + if let Some(sidecar) = commitment { + commitment_sidecars.push(sidecar); + } } let mut payment = SingleNodePayment::from_quotes(quotes_for_payment) @@ -512,6 +530,7 @@ async fn test_attack_underpayment_half_price() { let proof = PaymentProof { proof_of_payment: ProofOfPayment { peer_quotes }, tx_hashes, + commitment_sidecars, }; let proof_bytes = serialize_single_node_proof(&proof).expect("serialize proof"); diff --git a/ant-core/tests/support/mod.rs b/ant-core/tests/support/mod.rs index 4a168c81..69e93a5a 100644 --- a/ant-core/tests/support/mod.rs +++ b/ant-core/tests/support/mod.rs @@ -19,10 +19,12 @@ use ant_core::data::ClientConfig; // Node-internal types (test harness needs to *be* a node) — direct // ant-node import is correct here. ant-node is a dev-dep so this is // only linked into test binaries. +use ant_node::payment::quote::CommitmentSource; use ant_node::payment::{ EvmVerifierConfig, PaymentVerifier, PaymentVerifierConfig, QuoteGenerator, QuotingMetricsTracker, }; +use ant_node::replication::commitment_state::{BuiltCommitment, ResponderCommitmentState}; use ant_node::storage::{AntProtocol, LmdbStorage, LmdbStorageConfig}; // Wire / transport / EVM types: route through ant-protocol so the test // harness exercises the same surface the client does. @@ -111,7 +113,21 @@ impl MiniTestnet { /// Start a testnet with the given number of nodes. /// /// Use `DEFAULT_NODE_COUNT` for standard tests, 35+ for merkle tests (need 16 peers per pool). + /// Nodes emit baseline `(0, None)` quotes (no commitment source wired). pub async fn start(node_count: usize) -> Self { + Self::start_inner(node_count, None).await + } + + /// ADR-0003: start a testnet where every node carries a live storage + /// commitment over `key_count` synthetic keys, so they emit COMMITMENT-BOUND + /// quotes (price = `calculate_price(key_count)`, pinned, commitment shipped + /// in the quote response). Exercises the full node→client→storer binding + /// handshake against real QUIC + Anvil. + pub async fn start_with_commitments(node_count: usize, key_count: u32) -> Self { + Self::start_inner(node_count, Some(key_count)).await + } + + async fn start_inner(node_count: usize, commitment_key_count: Option) -> Self { // Start Anvil EVM testnet FIRST let testnet = Testnet::new().await.expect("start Anvil testnet"); let evm_network = testnet.to_network(); @@ -136,8 +152,15 @@ impl MiniTestnet { let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port); let temp_dir = tempfile::TempDir::new().expect("create temp dir"); - let (node, protocol, handler) = - Self::spawn_node(addr, &bootstrap_addrs, temp_dir.path(), &evm_network, i).await; + let (node, protocol, handler) = Self::spawn_node( + addr, + &bootstrap_addrs, + temp_dir.path(), + &evm_network, + i, + commitment_key_count, + ) + .await; bootstrap_addrs.push(addr); nodes.push(TestNode { @@ -155,8 +178,15 @@ impl MiniTestnet { let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port); let temp_dir = tempfile::TempDir::new().expect("create temp dir"); - let (node, protocol, handler) = - Self::spawn_node(addr, &bootstrap_addrs, temp_dir.path(), &evm_network, i).await; + let (node, protocol, handler) = Self::spawn_node( + addr, + &bootstrap_addrs, + temp_dir.path(), + &evm_network, + i, + commitment_key_count, + ) + .await; nodes.push(TestNode { p2p_node: Some(Arc::clone(&node)), @@ -261,6 +291,7 @@ impl MiniTestnet { data_dir: &std::path::Path, evm_network: &EvmNetwork, node_index: usize, + commitment_key_count: Option, ) -> (Arc, Arc, tokio::task::JoinHandle<()>) { // Generate ML-DSA-65 identity for this node let identity = Arc::new(NodeIdentity::generate().expect("generate node identity")); @@ -342,6 +373,16 @@ impl MiniTestnet { // and payment closeness checks use the node's live DHT view. protocol.attach_p2p_node(Arc::clone(&node)); + // ADR-0003: optionally give this node a live storage commitment so it + // emits COMMITMENT-BOUND quotes (price = calculate_price(key_count), + // pinned, with the signed commitment shipped in the quote response). + // Without this the node has no commitment source and emits baseline + // `(0, None)` quotes — the path the rest of the suite exercises. + if let Some(key_count) = commitment_key_count { + let source = build_commitment_source(key_count, &identity); + protocol.attach_commitment_source(source); + } + // Start message handler loop let handler_node = Arc::clone(&node); let handler_protocol = Arc::clone(&protocol); @@ -441,3 +482,42 @@ impl MiniTestnet { } } } + +/// ADR-0003: build a live `ResponderCommitmentState` holding one current +/// commitment over `key_count` synthetic keys, signed by `identity`'s ML-DSA-65 +/// key and bound to its peer id (`BLAKE3(pub_key)`). Returned as the +/// `CommitmentSource` the quote generator prices against — so the node emits a +/// commitment-bound quote (`calculate_price(key_count)`, pinned) and ships the +/// signed commitment in the quote response. The commitment is real: it passes +/// the storer's signature, peer-binding, and hash==pin checks. +fn build_commitment_source(key_count: u32, identity: &NodeIdentity) -> Arc { + // Use the SAME identity→commitment-key conversion the replication engine + // uses in production (`MlDsaSecretKey::from_bytes(MlDsa65, identity bytes)`), + // so the commitment is signed by the node's real key and binds to its real + // peer id — exactly what the storer's cross-check verifies. + use ant_protocol::pqc::api::{MlDsaSecretKey, MlDsaVariant}; + + let pub_key = identity.public_key().as_bytes().to_vec(); + let peer_id = *blake3::hash(&pub_key).as_bytes(); + let sk_bytes = identity.secret_key_bytes().to_vec(); + let sk = MlDsaSecretKey::from_bytes(MlDsaVariant::MlDsa65, &sk_bytes) + .expect("deserialize ML-DSA-65 secret key"); + + // `key_count` distinct (key, bytes_hash) leaves — the commitment attests + // exactly this many keys, which becomes the quote's committed_key_count. + let entries: Vec<([u8; 32], [u8; 32])> = (0..key_count) + .map(|i| { + let mut k = [0u8; 32]; + k[..4].copy_from_slice(&i.to_le_bytes()); + let mut b = [1u8; 32]; + b[..4].copy_from_slice(&i.to_le_bytes()); + (k, b) + }) + .collect(); + + let built = + BuiltCommitment::build(entries, &peer_id, &sk, &pub_key).expect("build storage commitment"); + let state = ResponderCommitmentState::new(); + state.rotate(built); + Arc::new(state) +} From 78bee3799dc91657e8c625b18e6ba9f9dd5b4b43 Mon Sep 17 00:00:00 2001 From: grumbach Date: Tue, 30 Jun 2026 17:41:31 +0200 Subject: [PATCH 02/11] =?UTF-8?q?chore:=20TEMP=20ADR-0004=20git=20deps=20f?= =?UTF-8?q?or=20CI=20=E2=80=94=20STRIP=20BEFORE=20MERGE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [patch.crates-io] points evmlib/ant-protocol at their PR branches; ant-core points the ant-node dep (runtime + dev) at the matching ant-node ADR-0004 branch (published versions version-collide with the patch). At release: revert ant-node to a published version, drop the patch block, bump ant-protocol pin. --- Cargo.lock | 31 +++++++++---------- Cargo.toml | 18 +++++++++++ ant-core/Cargo.toml | 6 ++-- .../tests/{e2e_adr0003.rs => e2e_adr0004.rs} | 0 4 files changed, 35 insertions(+), 20 deletions(-) rename ant-core/tests/{e2e_adr0003.rs => e2e_adr0004.rs} (100%) diff --git a/Cargo.lock b/Cargo.lock index 31e7aa25..bfa5ef91 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -815,7 +815,7 @@ dependencies = [ [[package]] name = "ant-cli" -version = "0.2.11" +version = "0.2.8" dependencies = [ "ant-core", "anyhow", @@ -835,7 +835,7 @@ dependencies = [ [[package]] name = "ant-core" -version = "0.3.1" +version = "0.2.8" dependencies = [ "alloy", "ant-node", @@ -892,9 +892,8 @@ dependencies = [ [[package]] name = "ant-node" -version = "0.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbbc1f23610ffb700cd85bbf7519ebe29c767ccffc85480c29277da627de41de" +version = "0.14.1" +source = "git+https://github.com/grumbach/ant-node?branch=adr-0003-commitment-bound-pricing#195981f53a92f1a6522f8bf96c903c2259c50a09" dependencies = [ "ant-protocol", "blake3", @@ -942,9 +941,8 @@ dependencies = [ [[package]] name = "ant-protocol" -version = "2.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a35986649c5963ec808efb44621d8b94716cc2ef5a041ea9a1875487578097c" +version = "2.2.1" +source = "git+https://github.com/WithAutonomi/ant-protocol?branch=adr-0003-commitment-bound-pricing#140adea15c4f7e3594d13a017a0770677171322b" dependencies = [ "blake3", "bytes", @@ -2010,9 +2008,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.20" +version = "0.9.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" dependencies = [ "crossbeam-utils", ] @@ -2530,8 +2528,7 @@ dependencies = [ [[package]] name = "evmlib" version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebd96cf24017cd31cd422c80de3078a821b7884a5b1feb00087e98209326c676" +source = "git+https://github.com/WithAutonomi/evmlib?branch=adr-0003-signed-quote-fields#4adfdb5615271f716e4233681fb4cf640a0a34d0" dependencies = [ "alloy", "ant-merkle", @@ -3261,7 +3258,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.57.0", + "windows-core 0.58.0", ] [[package]] @@ -4579,9 +4576,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.15" +version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ "aws-lc-rs", "bytes", @@ -5250,9 +5247,9 @@ dependencies = [ [[package]] name = "saorsa-core" -version = "0.26.2" +version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d9c05644c46b8de670aa5c8eea037b90731f1f204e8aa5d8a2f03496d740401" +checksum = "c3f44ef51271c8bfc5b5305f73831a5090fde070553e2a96636feae109b4fdc4" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index f3978b22..745a1102 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,21 @@ [workspace] members = ["ant-core", "ant-cli"] resolver = "2" + +# ─── TEMPORARY: STRIP BEFORE MERGE — ADR-0004 coordinated cutover ─── +# ant-protocol/evmlib's ADR-0004 commitment-bound-quote types (the signed +# `committed_key_count`/`commitment_pin` fields, the commitment sidecar, and +# `BadQuoteCommitment`) are not yet published to crates.io, so this workspace +# cannot build against the published `ant-protocol`/`evmlib`. These git patches +# point those deps at their PR branches so CI can build the coordinated change. +# +# AT RELEASE (once the ADR-0004 versions are published, in order +# evmlib → ant-protocol): +# 1. delete this entire [patch.crates-io] block, AND +# 2. bump the `ant-protocol` pin in ant-core/Cargo.toml to the published +# version (evmlib is re-exported through ant-protocol, no direct pin): +# ant-protocol = "2.3.0" # <- published ADR-0004 ant-protocol +# # (which in turn depends on the published evmlib = "0.9.0") +[patch.crates-io] +evmlib = { git = "https://github.com/WithAutonomi/evmlib", branch = "adr-0003-signed-quote-fields" } +ant-protocol = { git = "https://github.com/WithAutonomi/ant-protocol", branch = "adr-0003-commitment-bound-pricing" } diff --git a/ant-core/Cargo.toml b/ant-core/Cargo.toml index 2153839d..fd944420 100644 --- a/ant-core/Cargo.toml +++ b/ant-core/Cargo.toml @@ -37,7 +37,7 @@ tower-http = { version = "0.6.8", features = ["cors"] } # under `ant_protocol::{evm, transport, pqc}`. This is the ONE pin for # those three deps — do not add direct evmlib/saorsa-core/saorsa-pqc # deps here or the version can skew between ant-client and ant-node. -ant-protocol = "2.2.2" +ant-protocol = "2.3.0" xor_name = "5" self_encryption = "0.36" futures = "0.3" @@ -65,7 +65,7 @@ sysinfo = { version = "0.32", default-features = false, features = ["system"] } # `ant-protocol` pin above points at a git branch, this ant-node must point at # the matching ant-node branch carrying the same saorsa-core / ant-protocol # lineage rather than a released version. -ant-node = { version = "0.14.2", optional = true } +ant-node = { git = "https://github.com/grumbach/ant-node", branch = "adr-0003-commitment-bound-pricing", optional = true } tracing-subscriber = { version = "0.3", features = ["env-filter"] } [target.'cfg(unix)'.dependencies] @@ -96,7 +96,7 @@ test-utils = [] # always compile even without the `devnet` feature. Pinned to the same # version as the runtime dep so there is a single ant-node / # saorsa-core version across the whole graph. -ant-node = { version = "0.14.2", features = ["test-utils"] } +ant-node = { git = "https://github.com/grumbach/ant-node", branch = "adr-0003-commitment-bound-pricing", features = ["test-utils"] } serial_test = "3" anyhow = "1" alloy = { version = "1.6", features = ["node-bindings"] } diff --git a/ant-core/tests/e2e_adr0003.rs b/ant-core/tests/e2e_adr0004.rs similarity index 100% rename from ant-core/tests/e2e_adr0003.rs rename to ant-core/tests/e2e_adr0004.rs From 08d83b5b0f90f38aefd39ab5308e06d615c1238c Mon Sep 17 00:00:00 2001 From: grumbach Date: Tue, 30 Jun 2026 17:41:31 +0200 Subject: [PATCH 03/11] feat(client): thread ADR-0004 commitment sidecar through witnessed quote flow; renumber ADR-0003 -> ADR-0004 StoreQuote carries the commitment sidecar end-to-end (resolve-before-pay); get_store_quotes family + median helpers + downstream consumers updated. --- ant-core/src/data/client/batch.rs | 6 +-- ant-core/src/data/client/file.rs | 9 +++- ant-core/src/data/client/merkle.rs | 14 +++--- ant-core/src/data/client/payment.rs | 2 +- ant-core/src/data/client/quote.rs | 77 ++++++++++++++++------------- ant-core/src/data/error.rs | 2 +- ant-core/tests/e2e_adr0004.rs | 10 ++-- ant-core/tests/support/mod.rs | 6 +-- 8 files changed, 72 insertions(+), 54 deletions(-) diff --git a/ant-core/src/data/client/batch.rs b/ant-core/src/data/client/batch.rs index f68e1808..a30f67b3 100644 --- a/ant-core/src/data/client/batch.rs +++ b/ant-core/src/data/client/batch.rs @@ -53,7 +53,7 @@ pub struct PreparedChunk { pub payment: SingleNodePayment, /// Peer quotes for building `ProofOfPayment`. pub peer_quotes: Vec<(EncodedPeerId, PaymentQuote)>, - /// ADR-0003: the signed commitments the bound quotes shipped, forwarded as + /// ADR-0004: the signed commitments the bound quotes shipped, forwarded as /// sidecars in the PUT bundle so storers cross-check synchronously. Empty /// when every quote was baseline (no commitment to pin). pub commitment_sidecars: Vec>, @@ -214,7 +214,7 @@ fn build_paid_chunks( peer_quotes: chunk.peer_quotes, }, tx_hashes, - // ADR-0003: forward the bound quotes' commitments so storers + // ADR-0004: forward the bound quotes' commitments so storers // cross-check synchronously; stripped before persistence node-side. commitment_sidecars: chunk.commitment_sidecars, }; @@ -279,7 +279,7 @@ impl Client { // 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()); - // ADR-0003: forward each bound quote's commitment sidecar (baseline + // ADR-0004: forward each bound quote's commitment sidecar (baseline // quotes ship none); `get_store_quotes` already verified the binding. let mut commitment_sidecars = Vec::new(); diff --git a/ant-core/src/data/client/file.rs b/ant-core/src/data/client/file.rs index 84737ee1..960399fd 100644 --- a/ant-core/src/data/client/file.rs +++ b/ant-core/src/data/client/file.rs @@ -139,7 +139,7 @@ pub enum FileChunkPeerStatus { /// One entry in the per-chunk quote list returned by /// [`Client::get_store_quotes`]: the responding peer, its addresses, the -/// signed quote it returned, the payment amount it is demanding, and (ADR-0003) +/// signed quote it returned, the payment amount it is demanding, and (ADR-0004) /// the opaque signed-commitment blob the node shipped with the quote. type QuoteEntry = ( PeerId, @@ -977,6 +977,13 @@ pub struct FileUploadResult { } /// Payment information for external signing — either wave-batch or merkle. +// ADR-0004 added the signed commitment fields (`committed_key_count`, +// `commitment_pin`) to the merkle candidate quotes carried inside +// `PreparedMerkleBatch`, which grew the `Merkle` variant past the +// `large_enum_variant` threshold. This enum is constructed one-off per payment +// (never held in bulk collections), so the size delta is harmless; allow it +// rather than box a field on the security-sensitive merkle-finalize path. +#[allow(clippy::large_enum_variant)] #[derive(Debug)] pub enum ExternalPaymentInfo { /// Wave-batch: individual (quote_hash, rewards_address, amount) tuples. diff --git a/ant-core/src/data/client/merkle.rs b/ant-core/src/data/client/merkle.rs index 08700950..091a3f6d 100644 --- a/ant-core/src/data/client/merkle.rs +++ b/ant-core/src/data/client/merkle.rs @@ -37,7 +37,7 @@ use xor_name::XorName; /// Default threshold: use merkle payments when chunk count >= this value. pub const DEFAULT_MERKLE_THRESHOLD: usize = 64; -/// ADR-0003 resolve-before-pay gate for a merkle candidate — the merkle-path +/// ADR-0004 resolve-before-pay gate for a merkle candidate — the merkle-path /// equivalent of the single-node `quote_commitment_binding_is_valid`. Runs the /// FULL binding check (shape, cap, exact price, and for bound candidates the /// commitment parse, peer-binding, signature, `hash == pin`, and @@ -163,7 +163,7 @@ pub struct PreparedMerkleBatch { tree: MerkleTree, /// Internal: chunk addresses in order. addresses: Vec<[u8; 32]>, - /// ADR-0003: validated commitment sidecars keyed by `(peer, pin)`, collected + /// ADR-0004: validated commitment sidecars keyed by `(peer, pin)`, collected /// during candidate validation. At proof time the winner pool's candidates /// forward theirs in `MerklePaymentProof.commitment_sidecars`. commitment_sidecars: HashMap<(PeerId, [u8; 32]), Vec>, @@ -528,7 +528,7 @@ impl Client { ); // 3. Collect candidate pools from the network (all pools in parallel). - // Each candidate's ADR-0003 binding is verified during collection and + // Each candidate's ADR-0004 binding is verified during collection and // its commitment sidecar captured (keyed by peer, pin). let (candidate_pools, commitment_sidecars) = self .build_candidate_pools( @@ -695,7 +695,7 @@ impl Client { } let mut pools = Vec::with_capacity(midpoint_proofs.len()); - // ADR-0003: merged map of every validated candidate's commitment sidecar, + // ADR-0004: merged map of every validated candidate's commitment sidecar, // keyed by (peer, pin). At proof time the winner pool's candidates look // up their sidecars here to forward in the merkle PUT bundle. let mut sidecars: HashMap<(PeerId, [u8; 32]), Vec> = HashMap::new(); @@ -888,7 +888,7 @@ impl Client { failures.push(format!("{peer_id}: candidate pub_key/peer mismatch")); continue; } - // ADR-0003: the FULL resolve-before-pay binding check, same as + // ADR-0004: the FULL resolve-before-pay binding check, same as // the single-node path — a candidate priced off its committed // count, or shipping an unresolvable/forged commitment, is // dropped before it can enter a pool the client pays. Checked @@ -896,7 +896,7 @@ impl Client { if let Err(detail) = merkle_candidate_binding_is_valid(&candidate_peer, &candidate, &commitment) { - warn!("Dropping merkle candidate {peer_id} — ADR-0003 binding invalid: {detail}"); + warn!("Dropping merkle candidate {peer_id} — ADR-0004 binding invalid: {detail}"); failures.push(format!("{peer_id}: bad commitment binding ({detail})")); continue; } @@ -1446,7 +1446,7 @@ pub fn finalize_merkle_batch( )) })?; - // ADR-0003: collect the winner pool's candidate commitment sidecars (those + // ADR-0004: collect the winner pool's candidate commitment sidecars (those // that were bound + validated during collection), to forward in each proof // so the storer can cross-check synchronously. Built once for the pool. let winner_sidecars: Vec> = winner_pool diff --git a/ant-core/src/data/client/payment.rs b/ant-core/src/data/client/payment.rs index 3d2fb3e7..859ded4a 100644 --- a/ant-core/src/data/client/payment.rs +++ b/ant-core/src/data/client/payment.rs @@ -68,7 +68,7 @@ impl Client { // 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()); - // ADR-0003: forward the signed commitment each bound quote shipped, so + // ADR-0004: forward the signed commitment each bound quote shipped, so // the storers can cross-check the quote's count against the original // commitment synchronously ("the commitment arrived with the quote"). // A baseline quote ships none. `get_store_quotes` already verified each diff --git a/ant-core/src/data/client/quote.rs b/ant-core/src/data/client/quote.rs index 2737f191..4d9d29d4 100644 --- a/ant-core/src/data/client/quote.rs +++ b/ant-core/src/data/client/quote.rs @@ -61,7 +61,7 @@ const QUOTE_COLLECTION_TIMEOUT_SECS: u64 = 120; const ML_DSA_PUB_KEY_LEN: usize = 1952; /// One collected quote: the responding peer, its addresses, the signed quote, -/// the price it demands, and (ADR-0003) the opaque signed-commitment blob the +/// the price it demands, and (ADR-0004) the opaque signed-commitment blob the /// node shipped alongside the quote (`None` for a baseline quote), to be /// forwarded as a sidecar in the PUT bundle. type QuotedPeer = ( @@ -72,9 +72,6 @@ type QuotedPeer = ( Option>, ); -/// Per-peer classification outcome: `(quote, price, commitment)` on success. -type QuoteResult = std::result::Result<(PaymentQuote, Amount, Option>), Error>; - /// Check that a quote's `pub_key` is well-formed and BLAKE3-hashes to the /// claimed `peer_id`. /// @@ -85,7 +82,7 @@ type QuoteResult = std::result::Result<(PaymentQuote, Amount, Option>), /// failing either check causes the storer to reject the entire close-group /// proof and burn the chunk's payment. /// -/// This is the cheap structural pre-check. ADR-0003 additionally has the client +/// This is the cheap structural pre-check. ADR-0004 additionally has the client /// run `verify_quote_content` + `verify_quote_signature` (the full ML-DSA check) /// in [`classify_quote_response`] before paying, so a quote the storer would /// reject never gets paid. @@ -96,7 +93,7 @@ fn quote_binding_is_valid(peer_id: &PeerId, quote: &PaymentQuote) -> bool { compute_address("e.pub_key) == *peer_id.as_bytes() } -/// ADR-0003 client-side resolve-before-pay gate — "the client pays nothing it +/// ADR-0004 client-side resolve-before-pay gate — "the client pays nothing it /// cannot resolve", the ceiling's load-bearing wall (ADR §"The client pays /// nothing it cannot resolve"). /// @@ -206,7 +203,7 @@ fn quote_commitment_binding_is_valid( /// `ant-node/src/payment/verifier.rs` — the cheap BLAKE3 binding — /// so we drop misbehaving peers' quotes before payment. /// -/// ADR-0003: the client now ALSO runs the storer's `verify_quote_content` and +/// ADR-0004: the client now ALSO runs the storer's `verify_quote_content` and /// `verify_quote_signature` (ML-DSA-65) before paying, so "the client pays /// nothing it cannot resolve" covers the quote's own validity too, not just the /// commitment binding. This matches what the merkle path already does @@ -224,7 +221,7 @@ fn quote_commitment_binding_is_valid( /// - `Err(Error::BadQuoteBinding { .. })` — bad binding (mirrors the /// storer-side rejection). Outer collector counts these via the typed /// variant (no string matching). -/// - `Err(Error::BadQuoteCommitment { .. })` — ADR-0003 forced-price binding +/// - `Err(Error::BadQuoteCommitment { .. })` — ADR-0004 forced-price binding /// failed (price off the curve, incoherent shape, or a bound quote that did /// not ship its commitment); dropped before payment like a bad binding. /// - `Err(Error::Serialization(...))` — the quote bytes did not deserialize. @@ -264,7 +261,7 @@ fn classify_quote_response( }); } - // ADR-0003 "the client runs the full binding check": verify the quote's OWN + // ADR-0004 "the client runs the full binding check": verify the quote's OWN // ML-DSA-65 signature and that it is for THIS content, before paying — // exactly what the storer checks and what the merkle path already does // client-side. A quote with a valid pub_key binding but a bad signature or @@ -282,13 +279,13 @@ fn classify_quote_response( }); } - // ADR-0003 forced-price gate: drop a quote whose price is not exactly the + // ADR-0004 forced-price gate: drop a quote whose price is not exactly the // public formula of its committed count, whose (count, pin) shape is // incoherent, or which is bound but did not ship its commitment. The storer // re-runs the arithmetic and would reject the bundle; we drop it here so we // never pay a quote we cannot resolve. if let Err(detail) = quote_commitment_binding_is_valid(peer_id, &payment_quote, &commitment) { - warn!("Dropping response from {peer_id} — ADR-0003 binding invalid: {detail}"); + warn!("Dropping response from {peer_id} — ADR-0004 binding invalid: {detail}"); return Err(Error::BadQuoteCommitment { peer_id: peer_id.to_string(), detail, @@ -368,7 +365,14 @@ async fn request_store_quote_from_peer( ChunkMessageBody::QuoteResponse(ChunkQuoteResponse::Success { quote, already_stored, - }) => Some(classify_quote_response(&peer_id, "e, already_stored)), + commitment, + }) => Some(classify_quote_response( + &peer_id, + &address, + "e, + already_stored, + commitment, + )), ChunkMessageBody::QuoteResponse(ChunkQuoteResponse::Error(e)) => Some(Err( Error::Protocol(format!("Quote error from {peer_id}: {e}")), )), @@ -386,7 +390,7 @@ async fn request_store_quote_from_peer( fn record_store_quote_result( peer_id: PeerId, addrs: Vec, - quote_result: Result<(PaymentQuote, Amount)>, + quote_result: Result<(PaymentQuote, Amount, Option>)>, address: &[u8; 32], quotes: &mut Vec, already_stored_peers: &mut Vec<(PeerId, [u8; 32])>, @@ -394,8 +398,8 @@ fn record_store_quote_result( bad_quote_count: &mut usize, ) { match quote_result { - Ok((quote, price)) => { - quotes.push((peer_id, addrs, quote, price)); + Ok((quote, price, commitment)) => { + quotes.push((peer_id, addrs, quote, price, commitment)); } Err(Error::AlreadyStored) => { info!("Peer {peer_id} reports chunk already stored"); @@ -485,8 +489,12 @@ fn peer_list(peers: &[PeerId]) -> Vec { peers.iter().map(ToString::to_string).collect() } -pub(crate) type StoreQuote = (PeerId, Vec, PaymentQuote, Amount); -type StoreQuoteRequestResult = (PeerId, Vec, Result<(PaymentQuote, Amount)>); +/// One collected store quote, carrying (ADR-0004) the opaque signed-commitment +/// sidecar the node shipped with its quote (`None` for a baseline quote), to be +/// forwarded in the PUT bundle and cross-checked by storers. +pub(crate) type StoreQuote = (PeerId, Vec, PaymentQuote, Amount, Option>); +type StoreQuoteRequestResult = + (PeerId, Vec, Result<(PaymentQuote, Amount, Option>)>); type VotersByPeer = HashMap>; type WitnessedVoteData = (HashMap, VotersByPeer, Vec<(PeerId, usize)>); @@ -720,9 +728,7 @@ fn witnessed_quote_selection_or_error( }) } -pub(crate) fn median_paid_quote_issuer( - quotes: &[(PeerId, Vec, PaymentQuote, Amount)], -) -> Option<(PeerId, Amount)> { +pub(crate) fn median_paid_quote_issuer(quotes: &[StoreQuote]) -> Option<(PeerId, Amount)> { if quotes.len() <= MEDIAN_QUOTE_INDEX { return None; } @@ -730,7 +736,7 @@ pub(crate) fn median_paid_quote_issuer( let mut by_price: Vec<(usize, PeerId, Amount)> = quotes .iter() .enumerate() - .map(|(index, (peer_id, _, _, price))| (index, *peer_id, *price)) + .map(|(index, (peer_id, _, _, price, _))| (index, *peer_id, *price)) .collect(); by_price.sort_by_key(|(index, _, price)| (*price, *index)); by_price @@ -758,7 +764,7 @@ fn median_paid_quote_issuer_for_indices( .iter() .enumerate() .map(|(selected_index, quote_index)| { - let (peer_id, _, _, price) = "es[*quote_index]; + let (peer_id, _, _, price, _) = "es[*quote_index]; (selected_index, *peer_id, *price) }) .collect(); @@ -900,7 +906,7 @@ impl Client { address: &[u8; 32], data_size: u64, data_type: u32, - ) -> Result, PaymentQuote, Amount)>> { + ) -> Result> { Ok(self .get_store_quote_plan(address, data_size, data_type) .await? @@ -974,7 +980,7 @@ impl Client { address: &[u8; 32], data_size: u64, data_type: u32, - ) -> Result, PaymentQuote, Amount)>> { + ) -> Result> { let peer_query_count = fault_tolerant_quote_query_count(); let remote_peers = self .network() @@ -1085,7 +1091,7 @@ impl Client { data_type: u32, remote_peers: Vec<(PeerId, Vec)>, quote_selection_policy: QuoteSelectionPolicy, - ) -> Result, PaymentQuote, Amount)>> { + ) -> Result> { let peer_query_count = remote_peers.len(); let node = self.network().node(); @@ -1388,7 +1394,7 @@ mod tests { /// Build a quote tuple whose `pub_key` correctly hashes to its peer_id. /// Signature is left empty: this filter does not verify signatures. /// - /// The quote is a valid ADR-0003 **baseline**: `(0, None)` priced at + /// The quote is a valid ADR-0004 **baseline**: `(0, None)` priced at /// `calculate_price(0)`, so it passes the forced-price gate in /// `classify_quote_response`. The 5th tuple element is the (absent) /// commitment sidecar. @@ -1454,7 +1460,10 @@ mod tests { PeerId::from_bytes([seed; 32]) } - fn synthetic_quote(seed: u8, price: u64) -> (PeerId, Vec, PaymentQuote, Amount) { + fn synthetic_quote( + seed: u8, + price: u64, + ) -> (PeerId, Vec, PaymentQuote, Amount, Option>) { let amount = Amount::from(price); let quote = PaymentQuote { content: XorName([0u8; 32]), @@ -1463,18 +1472,20 @@ mod tests { rewards_address: RewardsAddress::new([0u8; 20]), pub_key: Vec::new(), signature: Vec::new(), + committed_key_count: 0, + commitment_pin: None, }; - (synthetic_peer(seed), Vec::new(), quote, amount) + (synthetic_peer(seed), Vec::new(), quote, amount, None) } fn synthetic_voters(seeds: &[u8]) -> HashSet { seeds.iter().copied().map(synthetic_peer).collect() } - fn quote_peer_seeds(quotes: &[(PeerId, Vec, PaymentQuote, Amount)]) -> Vec { + fn quote_peer_seeds(quotes: &[StoreQuote]) -> Vec { quotes .iter() - .map(|(peer_id, _, _, _)| peer_id.as_bytes()[0]) + .map(|(peer_id, _, _, _, _)| peer_id.as_bytes()[0]) .collect() } @@ -1950,7 +1961,7 @@ mod tests { median_paid_quote_issuer(&selected).expect("selected quotes have a median"); let selected_peers = selected .iter() - .map(|(peer_id, _, _, _)| *peer_id) + .map(|(peer_id, _, _, _, _)| *peer_id) .collect::>(); assert_eq!(median_peer_id, synthetic_peer(MEDIAN_ISSUER_SEED)); assert_eq!( @@ -2371,7 +2382,7 @@ mod tests { } // ============================================================ - // ADR-0003: quote_commitment_binding_is_valid (forced-price gate) + // ADR-0004: quote_commitment_binding_is_valid (forced-price gate) // // Mirrors the storer-side `binding_violation` in // `ant-node/src/payment/verifier.rs`. The client runs this before @@ -2380,7 +2391,7 @@ mod tests { // and for bound quotes: parse + peer-binding + signature + hash==pin + // count==key_count) using the shared ant-protocol commitment type, so an // unresolvable/forged commitment is never paid. A live resolve against a - // REAL signed commitment is proven in the e2e suite (e2e_adr0003.rs). + // REAL signed commitment is proven in the e2e suite (e2e_adr0004.rs). // ============================================================ /// A throwaway peer id for tests that fail BEFORE commitment resolution diff --git a/ant-core/src/data/error.rs b/ant-core/src/data/error.rs index 8f7ed960..e0f49b7f 100644 --- a/ant-core/src/data/error.rs +++ b/ant-core/src/data/error.rs @@ -119,7 +119,7 @@ pub enum Error { detail: String, }, - /// ADR-0003: a quote's commitment binding does not hold — its price is not + /// ADR-0004: a quote's commitment binding does not hold — its price is not /// `calculate_price(committed_key_count)`, its `(count, pin)` shape is /// incoherent, or a shipped commitment does not match the pinned count/hash. /// The storer's arithmetic gate would reject such a quote, so the client diff --git a/ant-core/tests/e2e_adr0004.rs b/ant-core/tests/e2e_adr0004.rs index 448047c4..43121e1b 100644 --- a/ant-core/tests/e2e_adr0004.rs +++ b/ant-core/tests/e2e_adr0004.rs @@ -1,11 +1,11 @@ -//! ADR-0003 end-to-end: commitment-bound quote pricing. +//! ADR-0004 end-to-end: commitment-bound quote pricing. //! //! Proves, against a real in-process QUIC testnet + Anvil EVM, that: //! //! 1. A node carrying a live storage commitment emits a COMMITMENT-BOUND quote: //! `committed_key_count == N`, `commitment_pin == Some`, the price is exactly //! `calculate_price(N)`, and the signed commitment is SHIPPED in the quote -//! response (ADR-0003 "the commitment arrived with the quote"). +//! response (ADR-0004 "the commitment arrived with the quote"). //! 2. The client's forced-price gate ACCEPTS those bound quotes (they are //! self-consistent) and a full pay → store → retrieve round-trip succeeds — //! so the storer accepts the bound quotes and the forwarded sidecars too. @@ -39,7 +39,7 @@ const COMMITTED_KEYS: u32 = 9_000; #[tokio::test(flavor = "multi_thread")] #[serial] -async fn adr0003_bound_quotes_are_shipped_priced_and_resolve() { +async fn adr0004_bound_quotes_are_shipped_priced_and_resolve() { let testnet = MiniTestnet::start_with_commitments(DEFAULT_NODE_COUNT, COMMITTED_KEYS).await; let node = testnet.node(3).expect("node 3 exists"); let client = Client::from_node(Arc::clone(&node), test_client_config()) @@ -48,7 +48,7 @@ async fn adr0003_bound_quotes_are_shipped_priced_and_resolve() { let content = Bytes::from("adr-0003 bound-quote payload"); let address = compute_address(&content); - // Collect quotes directly so we can inspect the ADR-0003 binding the client + // Collect quotes directly so we can inspect the ADR-0004 binding the client // verified before it would pay. let quotes = client .get_store_quotes(&address, content.len() as u64, 0) @@ -141,7 +141,7 @@ async fn adr0003_bound_quotes_are_shipped_priced_and_resolve() { /// This guards the baseline branch of the same forced-price gate. #[tokio::test(flavor = "multi_thread")] #[serial] -async fn adr0003_baseline_quotes_still_work() { +async fn adr0004_baseline_quotes_still_work() { let testnet = MiniTestnet::start(DEFAULT_NODE_COUNT).await; let node = testnet.node(3).expect("node 3 exists"); let client = Client::from_node(Arc::clone(&node), test_client_config()) diff --git a/ant-core/tests/support/mod.rs b/ant-core/tests/support/mod.rs index 69e93a5a..9486fecc 100644 --- a/ant-core/tests/support/mod.rs +++ b/ant-core/tests/support/mod.rs @@ -118,7 +118,7 @@ impl MiniTestnet { Self::start_inner(node_count, None).await } - /// ADR-0003: start a testnet where every node carries a live storage + /// ADR-0004: start a testnet where every node carries a live storage /// commitment over `key_count` synthetic keys, so they emit COMMITMENT-BOUND /// quotes (price = `calculate_price(key_count)`, pinned, commitment shipped /// in the quote response). Exercises the full node→client→storer binding @@ -373,7 +373,7 @@ impl MiniTestnet { // and payment closeness checks use the node's live DHT view. protocol.attach_p2p_node(Arc::clone(&node)); - // ADR-0003: optionally give this node a live storage commitment so it + // ADR-0004: optionally give this node a live storage commitment so it // emits COMMITMENT-BOUND quotes (price = calculate_price(key_count), // pinned, with the signed commitment shipped in the quote response). // Without this the node has no commitment source and emits baseline @@ -483,7 +483,7 @@ impl MiniTestnet { } } -/// ADR-0003: build a live `ResponderCommitmentState` holding one current +/// ADR-0004: build a live `ResponderCommitmentState` holding one current /// commitment over `key_count` synthetic keys, signed by `identity`'s ML-DSA-65 /// key and bound to its peer id (`BLAKE3(pub_key)`). Returned as the /// `CommitmentSource` the quote generator prices against — so the node emits a From c73700e8389052225739548b2591461735a20e4c Mon Sep 17 00:00:00 2001 From: grumbach Date: Tue, 30 Jun 2026 17:48:16 +0200 Subject: [PATCH 04/11] fix(ci): rustfmt + bump quinn-proto 0.11.14 -> 0.11.15 (RUSTSEC-2026-0185) --- Cargo.lock | 4 ++-- ant-core/src/data/client/quote.rs | 23 +++++++++++++++++++---- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bfa5ef91..c79afbcf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4576,9 +4576,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" dependencies = [ "aws-lc-rs", "bytes", diff --git a/ant-core/src/data/client/quote.rs b/ant-core/src/data/client/quote.rs index 4d9d29d4..aa870377 100644 --- a/ant-core/src/data/client/quote.rs +++ b/ant-core/src/data/client/quote.rs @@ -492,9 +492,18 @@ fn peer_list(peers: &[PeerId]) -> Vec { /// One collected store quote, carrying (ADR-0004) the opaque signed-commitment /// sidecar the node shipped with its quote (`None` for a baseline quote), to be /// forwarded in the PUT bundle and cross-checked by storers. -pub(crate) type StoreQuote = (PeerId, Vec, PaymentQuote, Amount, Option>); -type StoreQuoteRequestResult = - (PeerId, Vec, Result<(PaymentQuote, Amount, Option>)>); +pub(crate) type StoreQuote = ( + PeerId, + Vec, + PaymentQuote, + Amount, + Option>, +); +type StoreQuoteRequestResult = ( + PeerId, + Vec, + Result<(PaymentQuote, Amount, Option>)>, +); type VotersByPeer = HashMap>; type WitnessedVoteData = (HashMap, VotersByPeer, Vec<(PeerId, usize)>); @@ -1463,7 +1472,13 @@ mod tests { fn synthetic_quote( seed: u8, price: u64, - ) -> (PeerId, Vec, PaymentQuote, Amount, Option>) { + ) -> ( + PeerId, + Vec, + PaymentQuote, + Amount, + Option>, + ) { let amount = Amount::from(price); let quote = PaymentQuote { content: XorName([0u8; 32]), From dc2771b0a7b1610b8edade883516cd52d4025623 Mon Sep 17 00:00:00 2001 From: grumbach Date: Wed, 1 Jul 2026 12:56:00 +0200 Subject: [PATCH 05/11] ci: run e2e_adr0004 in the E2E job The renamed ADR-0004 e2e target was auto-discovered but never listed in the CI E2E command, so ant-client could go green without exercising the main ADR-0004 end-to-end coverage. Add --test e2e_adr0004. --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d22d5761..2382b67c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,7 +92,7 @@ jobs: anvil --version forge --version - name: Run E2E tests (serial) - run: cargo test -p ant-core --features test-utils --test e2e_chunk --test e2e_data --test e2e_file --test e2e_payment --test e2e_security --test e2e_cost_estimate -- --test-threads=1 + run: cargo test -p ant-core --features test-utils --test e2e_chunk --test e2e_data --test e2e_file --test e2e_payment --test e2e_security --test e2e_cost_estimate --test e2e_adr0004 -- --test-threads=1 test-merkle: name: Merkle E2E (${{ matrix.os }}) From f2a8abef8f6f2397cb0efc27a05db55576422d1b Mon Sep 17 00:00:00 2001 From: grumbach Date: Wed, 1 Jul 2026 15:37:59 +0200 Subject: [PATCH 06/11] chore: bump ADR-0004 git deps to clean-rebased revs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Point the ant-node / ant-protocol / evmlib git-branch deps at the freshly clean-rebased ADR-0004 revisions (ant-node ff7a547 — the audit-code merge against current main resolved correctly; ant-protocol 3a4eb638 = 2.3.0; evmlib a7ac7e26 = 0.9.0). Recovers the workspace build now that ant-node's branch compiles again. --- Cargo.lock | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c79afbcf..faf38033 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -815,7 +815,7 @@ dependencies = [ [[package]] name = "ant-cli" -version = "0.2.8" +version = "0.2.9" dependencies = [ "ant-core", "anyhow", @@ -835,7 +835,7 @@ dependencies = [ [[package]] name = "ant-core" -version = "0.2.8" +version = "0.2.9" dependencies = [ "alloy", "ant-node", @@ -892,8 +892,8 @@ dependencies = [ [[package]] name = "ant-node" -version = "0.14.1" -source = "git+https://github.com/grumbach/ant-node?branch=adr-0003-commitment-bound-pricing#195981f53a92f1a6522f8bf96c903c2259c50a09" +version = "0.14.2" +source = "git+https://github.com/grumbach/ant-node?branch=adr-0003-commitment-bound-pricing#ff7a547b627f2a18e630802feb02adeedb34ec1c" dependencies = [ "ant-protocol", "blake3", @@ -941,8 +941,8 @@ dependencies = [ [[package]] name = "ant-protocol" -version = "2.2.1" -source = "git+https://github.com/WithAutonomi/ant-protocol?branch=adr-0003-commitment-bound-pricing#140adea15c4f7e3594d13a017a0770677171322b" +version = "2.3.0" +source = "git+https://github.com/WithAutonomi/ant-protocol?branch=adr-0003-commitment-bound-pricing#3a4eb638abbbec2ec88823f14bfe66e11d70d9b5" dependencies = [ "blake3", "bytes", @@ -2527,8 +2527,8 @@ dependencies = [ [[package]] name = "evmlib" -version = "0.8.1" -source = "git+https://github.com/WithAutonomi/evmlib?branch=adr-0003-signed-quote-fields#4adfdb5615271f716e4233681fb4cf640a0a34d0" +version = "0.9.0" +source = "git+https://github.com/WithAutonomi/evmlib?branch=adr-0003-signed-quote-fields#a7ac7e2620cb6a50151dd0c619fd75e03b02f05c" dependencies = [ "alloy", "ant-merkle", @@ -5247,9 +5247,9 @@ dependencies = [ [[package]] name = "saorsa-core" -version = "0.26.1" +version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3f44ef51271c8bfc5b5305f73831a5090fde070553e2a96636feae109b4fdc4" +checksum = "5d9c05644c46b8de670aa5c8eea037b90731f1f204e8aa5d8a2f03496d740401" dependencies = [ "anyhow", "async-trait", From bbf9f81b7ad1fc1d2cbc6fce446d881b5fa3e8a2 Mon Sep 17 00:00:00 2001 From: grumbach Date: Fri, 3 Jul 2026 18:26:44 +0200 Subject: [PATCH 07/11] test(adr-0004): cover client binding signature/hash/count checks Add negative unit tests for the three resolve-before-pay commitment sub-checks that previously had no coverage (deleting any of them still passed all tests): invalid commitment signature, commitment that does not hash to the quote's pin, and quote count disagreeing with the committed key_count. Each constructs a validly-signed, peer-bound commitment and breaks exactly one field to isolate its check. Rename binding_rejects_garbage_and_wrong_pin_commitment to binding_rejects_unparseable_and_peer_unbound_commitment to reflect what it actually exercises (the 'wrong pin' case failed peer-binding first), and fix the stale 'adr-0003' e2e test payload strings. --- ant-core/src/data/client/quote.rs | 94 +++++++++++++++++++++++++++++-- ant-core/tests/e2e_adr0004.rs | 4 +- 2 files changed, 91 insertions(+), 7 deletions(-) diff --git a/ant-core/src/data/client/quote.rs b/ant-core/src/data/client/quote.rs index aa870377..87dc59de 100644 --- a/ant-core/src/data/client/quote.rs +++ b/ant-core/src/data/client/quote.rs @@ -2433,6 +2433,36 @@ mod tests { } } + /// Build a VALIDLY-SIGNED `StorageCommitment` bound to `kp`'s peer id, so a + /// test can pass peer-binding + signature and isolate the `hash == pin` and + /// `count == key_count` sub-checks. Mirrors ant-node's commitment signing: + /// the canonical payload (`root || key_count(LE) || peer_id || pk_len(LE) || + /// pub_key`) signed under `DOMAIN_COMMITMENT`. + fn signed_commitment(kp: &Keypair, root: [u8; 32], key_count: u32) -> StorageCommitment { + use ant_protocol::payment::commitment::DOMAIN_COMMITMENT; + use ant_protocol::pqc::api::{ml_dsa_65, MlDsaSecretKey as ApiSecretKey, MlDsaVariant}; + let peer = compute_address(&kp.pub_key_bytes); + let mut payload = Vec::with_capacity(32 + 4 + 32 + 4 + kp.pub_key_bytes.len()); + payload.extend_from_slice(&root); + payload.extend_from_slice(&key_count.to_le_bytes()); + payload.extend_from_slice(&peer); + payload.extend_from_slice(&(kp.pub_key_bytes.len() as u32).to_le_bytes()); + payload.extend_from_slice(&kp.pub_key_bytes); + let sk = ApiSecretKey::from_bytes(MlDsaVariant::MlDsa65, &kp.secret_key_bytes) + .expect("api secret key"); + let signature = ml_dsa_65() + .sign_with_context(&sk, &payload, DOMAIN_COMMITMENT) + .expect("sign commitment") + .to_bytes(); + StorageCommitment { + root, + key_count, + sender_peer_id: peer, + sender_public_key: kp.pub_key_bytes.clone(), + signature, + } + } + #[test] fn binding_baseline_ok_only_at_baseline_price() { // (0, None) priced at calculate_price(0) is the valid baseline. @@ -2485,7 +2515,7 @@ mod tests { } #[test] - fn binding_rejects_garbage_and_wrong_pin_commitment() { + fn binding_rejects_unparseable_and_peer_unbound_commitment() { // A bound quote whose shipped commitment is garbage (doesn't even // deserialize) is rejected — the client never pays an unresolvable pin. let q = quote_with_binding(500, Some([9u8; 32]), calculate_price(500)); @@ -2494,10 +2524,11 @@ mod tests { "an unparseable commitment must be rejected before payment" ); - // A well-formed-but-wrong commitment (valid StorageCommitment bytes that - // do NOT hash to the quote's pin / aren't bound to the peer) is also - // rejected. We serialize a real StorageCommitment shape with mismatched - // fields; it fails peer-binding first, then would fail hash==pin. + // A well-formed StorageCommitment that is NOT bound to the quoting peer + // (its sender_peer_id / pubkey don't derive the peer id) is rejected at + // the peer-binding check. The signature / hash==pin / count==key_count + // sub-checks are covered by the dedicated tests below, which pass + // peer-binding first so each isolates exactly one sub-check. let bogus = StorageCommitment { root: [1u8; 32], key_count: 500, @@ -2512,6 +2543,59 @@ mod tests { ); } + #[test] + fn binding_rejects_commitment_with_invalid_signature() { + // Correctly-bound commitment (passes peer-binding) but with a corrupted + // signature: must be rejected at the signature check. Deleting that check + // would let a peer attest any (root, key_count) without holding the key. + let kp = gen_keypair(); + let mut commitment = signed_commitment(&kp, [6u8; 32], 500); + commitment.signature[0] ^= 0xFF; // still 3293 bytes, no longer valid + // Pin the (corrupted) commitment so the hash==pin check would pass; the + // only thing wrong is the signature, isolating that sub-check. + let pin = commitment_hash(&commitment).expect("hash"); + let blob = rmp_serde::to_vec(&commitment).expect("serialize commitment"); + let q = quote_with_binding(500, Some(pin), calculate_price(500)); + let res = quote_commitment_binding_is_valid(&kp.peer_id, &q, &Some(blob)); + let err = res.expect_err("commitment with an invalid signature must be rejected"); + assert!(err.contains("signature"), "should fail at the signature check: {err}"); + } + + #[test] + fn binding_rejects_commitment_that_does_not_hash_to_pin() { + // Validly-signed, correctly-bound commitment, but the quote pins a + // DIFFERENT hash: must be rejected. Deleting the hash==pin check would + // let a peer ship any commitment it holds for a pin it doesn't back. + let kp = gen_keypair(); + let commitment = signed_commitment(&kp, [5u8; 32], 500); + let wrong_pin = [0xAB; 32]; + assert_ne!(commitment_hash(&commitment), Some(wrong_pin)); + let blob = rmp_serde::to_vec(&commitment).expect("serialize commitment"); + let q = quote_with_binding(500, Some(wrong_pin), calculate_price(500)); + let res = quote_commitment_binding_is_valid(&kp.peer_id, &q, &Some(blob)); + let err = res.expect_err("commitment that does not hash to the pin must be rejected"); + assert!(err.contains("hash"), "should fail at the hash==pin check: {err}"); + } + + #[test] + fn binding_rejects_count_disagreeing_with_commitment() { + // Validly-signed, correctly-bound, correctly-pinned commitment attesting + // key_count=400, but the quote claims 500 (priced on-curve for 500): + // must be rejected. Deleting the count==key_count check would let a peer + // price against an inflated count while committing to fewer keys. + let kp = gen_keypair(); + let commitment = signed_commitment(&kp, [7u8; 32], 400); + let pin = commitment_hash(&commitment).expect("hash"); + let blob = rmp_serde::to_vec(&commitment).expect("serialize commitment"); + let q = quote_with_binding(500, Some(pin), calculate_price(500)); + let res = quote_commitment_binding_is_valid(&kp.peer_id, &q, &Some(blob)); + let err = res.expect_err("a quote count disagreeing with the commitment must be rejected"); + assert!( + err.contains("key_count") || err.contains("attests"), + "should fail at the count==key_count check: {err}" + ); + } + #[test] fn binding_rejects_oversized_commitment_before_parsing() { // A bound quote shipping a blob larger than the sidecar cap is rejected diff --git a/ant-core/tests/e2e_adr0004.rs b/ant-core/tests/e2e_adr0004.rs index 43121e1b..d019c614 100644 --- a/ant-core/tests/e2e_adr0004.rs +++ b/ant-core/tests/e2e_adr0004.rs @@ -45,7 +45,7 @@ async fn adr0004_bound_quotes_are_shipped_priced_and_resolve() { let client = Client::from_node(Arc::clone(&node), test_client_config()) .with_wallet(testnet.wallet().clone()); - let content = Bytes::from("adr-0003 bound-quote payload"); + let content = Bytes::from("adr-0004 bound-quote payload"); let address = compute_address(&content); // Collect quotes directly so we can inspect the ADR-0004 binding the client @@ -147,7 +147,7 @@ async fn adr0004_baseline_quotes_still_work() { let client = Client::from_node(Arc::clone(&node), test_client_config()) .with_wallet(testnet.wallet().clone()); - let content = Bytes::from("adr-0003 baseline payload"); + let content = Bytes::from("adr-0004 baseline payload"); let address = compute_address(&content); let quotes = client From 99c38ada3cf1bcd11428ee55b9b0690e3ef4369c Mon Sep 17 00:00:00 2001 From: grumbach Date: Fri, 3 Jul 2026 18:47:41 +0200 Subject: [PATCH 08/11] chore(deps): regenerate Cargo.lock after rebase onto main (0.3.0) --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index faf38033..f4f50fa2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -815,7 +815,7 @@ dependencies = [ [[package]] name = "ant-cli" -version = "0.2.9" +version = "0.2.10" dependencies = [ "ant-core", "anyhow", @@ -835,7 +835,7 @@ dependencies = [ [[package]] name = "ant-core" -version = "0.2.9" +version = "0.3.0" dependencies = [ "alloy", "ant-node", From 9d8defaeac868f1266cf06e9976a6c2e0ceab148 Mon Sep 17 00:00:00 2001 From: grumbach Date: Fri, 3 Jul 2026 18:56:15 +0200 Subject: [PATCH 09/11] style: rustfmt the ADR-0004 binding tests --- ant-core/src/data/client/quote.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/ant-core/src/data/client/quote.rs b/ant-core/src/data/client/quote.rs index 87dc59de..3143d71d 100644 --- a/ant-core/src/data/client/quote.rs +++ b/ant-core/src/data/client/quote.rs @@ -2551,14 +2551,17 @@ mod tests { let kp = gen_keypair(); let mut commitment = signed_commitment(&kp, [6u8; 32], 500); commitment.signature[0] ^= 0xFF; // still 3293 bytes, no longer valid - // Pin the (corrupted) commitment so the hash==pin check would pass; the - // only thing wrong is the signature, isolating that sub-check. + // Pin the (corrupted) commitment so the hash==pin check would pass; the + // only thing wrong is the signature, isolating that sub-check. let pin = commitment_hash(&commitment).expect("hash"); let blob = rmp_serde::to_vec(&commitment).expect("serialize commitment"); let q = quote_with_binding(500, Some(pin), calculate_price(500)); let res = quote_commitment_binding_is_valid(&kp.peer_id, &q, &Some(blob)); let err = res.expect_err("commitment with an invalid signature must be rejected"); - assert!(err.contains("signature"), "should fail at the signature check: {err}"); + assert!( + err.contains("signature"), + "should fail at the signature check: {err}" + ); } #[test] @@ -2574,7 +2577,10 @@ mod tests { let q = quote_with_binding(500, Some(wrong_pin), calculate_price(500)); let res = quote_commitment_binding_is_valid(&kp.peer_id, &q, &Some(blob)); let err = res.expect_err("commitment that does not hash to the pin must be rejected"); - assert!(err.contains("hash"), "should fail at the hash==pin check: {err}"); + assert!( + err.contains("hash"), + "should fail at the hash==pin check: {err}" + ); } #[test] From b4ce6ed900ea822ab8775242bd640c8734d7736b Mon Sep 17 00:00:00 2001 From: grumbach Date: Tue, 7 Jul 2026 09:53:28 +0900 Subject: [PATCH 10/11] fix(client): drop commitment sidecars from per-chunk merkle proofs Once nodes rotate their first storage commitment, every merkle candidate is bound and finalize_merkle_batch copied all 16 winner-pool commitment sidecars into EVERY per-chunk proof, growing it from ~128 KB to ~342 KB, past the storer's 256 KB payment-proof cap. Every merkle chunk PUT was rejected AFTER the on-chain payment, so forced-merkle uploads burned their payment and then failed every store attempt (DEV-01, 2026-07-06 19:38 UTC: both forced-merkle clients flipped to 100% failure the moment 855 nodes rotated their first commitment; single-node payments were unaffected). The sidecars are not needed in the bundle: the client fully resolves every candidate's shipped commitment before paying, and the storer's cross-check is best-effort with a gossip-cache / GetCommitmentByPin fallback. Drop the forwarding and the dead plumbing that threaded the sidecar map from pool collection into finalization. Receipts cached by pre-fix clients still carry the fat proofs, so a resume would replay the rejection: strip sidecars from cached merkle receipts on load, with an atomic (create_new tmp + fsync + rename) write-back that can never truncate the only copy of a paid receipt. New coverage: - e2e: forced-merkle upload against 35 committed nodes; failed with the exact production signature before this fix, passes after, against the unchanged 256 KB storer cap - unit: finalize_merkle_batch ships no sidecars (bound candidates) - unit: cached receipt strip + idempotency + non-merkle passthrough - unit: atomic overwrite lands and leaves no tmp sibling --- ant-core/src/data/client/cached_merkle.rs | 203 +++++++++++++++++++++- ant-core/src/data/client/merkle.rs | 129 +++++++------- ant-core/tests/e2e_adr0004.rs | 44 ++++- 3 files changed, 308 insertions(+), 68 deletions(-) diff --git a/ant-core/src/data/client/cached_merkle.rs b/ant-core/src/data/client/cached_merkle.rs index 78b83beb..358711a3 100644 --- a/ant-core/src/data/client/cached_merkle.rs +++ b/ant-core/src/data/client/cached_merkle.rs @@ -49,7 +49,8 @@ use crate::config; use crate::data::client::merkle::MerkleBatchPaymentResult; use crate::error::Result; -use std::fs::{self, DirEntry, File}; +use ant_protocol::payment::{deserialize_merkle_proof, serialize_merkle_proof}; +use std::fs::{self, DirEntry, File, OpenOptions}; use std::hash::{Hash, Hasher}; use std::io::{BufReader, BufWriter}; use std::path::{Path, PathBuf}; @@ -283,11 +284,112 @@ fn is_expired_filename(name: &str) -> bool { fn read_receipt(path: &Path) -> Result { let handle = File::open(path)?; - let receipt: MerkleBatchPaymentResult = rmp_serde::decode::from_read(BufReader::new(handle)) - .map_err(|e| crate::error::Error::Io(std::io::Error::other(e.to_string())))?; + let mut receipt: MerkleBatchPaymentResult = + rmp_serde::decode::from_read(BufReader::new(handle)) + .map_err(|e| crate::error::Error::Io(std::io::Error::other(e.to_string())))?; + + if strip_commitment_sidecars(&mut receipt) { + info!( + "Stripped legacy commitment sidecars from cached merkle receipt at {}", + path.display() + ); + // Best-effort write-back so the strip happens once; a failure here + // only means we re-strip on the next load. + if let Err(e) = overwrite_receipt(path, &receipt) { + warn!( + "Failed to persist slimmed merkle receipt at {}: {e}", + path.display() + ); + } + } + Ok(receipt) } +/// Strip ADR-0004 commitment sidecars from every proof in a cached receipt. +/// +/// Receipts saved by clients built before sidecars were dropped from the +/// per-chunk merkle proofs carry all 16 winner-pool sidecars in EVERY proof +/// (~214 KB per proof), which pushed the proof past the storer's +/// payment-proof size cap — resuming with them would replay the exact +/// failure the slim proofs fixed. Stripping is always safe: the pool hash +/// and address branch stay exactly as paid on-chain, and storers resolve +/// commitment pins from gossip or a `GetCommitmentByPin` fetch. Returns +/// whether anything was stripped. +fn strip_commitment_sidecars(receipt: &mut MerkleBatchPaymentResult) -> bool { + let mut stripped = false; + for proof_bytes in receipt.proofs.values_mut() { + // Non-merkle or unreadable proof bytes are left untouched; the + // storer remains the judge of those. + let Ok(mut proof) = deserialize_merkle_proof(proof_bytes) else { + continue; + }; + if proof.commitment_sidecars.is_empty() { + continue; + } + proof.commitment_sidecars.clear(); + match serialize_merkle_proof(&proof) { + Ok(slim) => { + *proof_bytes = slim; + stripped = true; + } + Err(e) => warn!("Failed to re-serialize slimmed cached merkle proof: {e}"), + } + } + stripped +} + +/// Overwrite a cached receipt via `tmp + fsync + rename` (same canonical +/// path), mirroring `cached_single::write_receipt_atomic`: an interrupted +/// write must never truncate the only copy of a paid receipt — losing it +/// forces the user to re-pay. The tmp name carries pid + nanos and is opened +/// with `create_new`, so concurrent migrations (threads or processes) can +/// never share a tmp inode — a residual name collision fails this best-effort +/// write instead of corrupting it, and the next load simply re-strips. A +/// leftover tmp from a crash is harmless (the canonical stays intact until +/// rename) and ages out with the same `_` filename prefix. +fn overwrite_receipt(path: &Path, receipt: &MerkleBatchPaymentResult) -> Result<()> { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let pid = std::process::id(); + let tmp_path = path.with_extension(format!("{pid}-{nanos}.tmp")); + { + let handle = OpenOptions::new() + .write(true) + .create_new(true) + .open(&tmp_path)?; + let mut writer = BufWriter::new(handle); + if let Err(e) = rmp_serde::encode::write(&mut writer, receipt) { + let _ = fs::remove_file(&tmp_path); + return Err(crate::error::Error::Io(std::io::Error::other( + e.to_string(), + ))); + } + // `into_inner` flushes; a swallowed flush error here would defeat + // the atomicity, so surface it. + let handle = match writer.into_inner() { + Ok(handle) => handle, + Err(e) => { + let _ = fs::remove_file(&tmp_path); + return Err(crate::error::Error::Io(std::io::Error::other(format!( + "BufWriter flush failed: {e}" + )))); + } + }; + if let Err(e) = handle.sync_all() { + let _ = fs::remove_file(&tmp_path); + return Err(e.into()); + } + } + if let Err(e) = fs::rename(&tmp_path, path) { + let _ = fs::remove_file(&tmp_path); + return Err(e.into()); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -305,6 +407,101 @@ mod tests { } } + /// A serialized merkle proof carrying legacy commitment sidecars, as saved + /// by clients built before sidecars were dropped from per-chunk proofs. + fn fat_merkle_proof_bytes(ts: u64) -> Vec { + use ant_protocol::evm::{ + Amount, MerklePaymentCandidateNode, MerklePaymentCandidatePool, MerklePaymentProof, + MerkleTree, RewardsAddress, CANDIDATES_PER_POOL, + }; + use xor_name::XorName; + + let xornames: Vec = (0..4u8).map(|i| XorName([i; 32])).collect(); + let tree = MerkleTree::from_xornames(xornames.clone()).unwrap(); + let midpoint = tree.reward_candidates(ts).unwrap().remove(0); + let candidate_nodes: [MerklePaymentCandidateNode; CANDIDATES_PER_POOL] = + std::array::from_fn(|i| MerklePaymentCandidateNode { + pub_key: vec![i as u8; 32], + price: Amount::from(1024u64), + reward_address: RewardsAddress::new([i as u8; 20]), + merkle_payment_timestamp: ts, + signature: vec![i as u8; 64], + committed_key_count: 9_000, + commitment_pin: Some([7u8; 32]), + }); + let pool = MerklePaymentCandidatePool { + midpoint_proof: midpoint, + candidate_nodes, + }; + let address_proof = tree.generate_address_proof(0, xornames[0]).unwrap(); + let mut proof = MerklePaymentProof::new(xornames[0], address_proof, pool); + proof.commitment_sidecars = vec![vec![0xAB; 5_000]; CANDIDATES_PER_POOL]; + serialize_merkle_proof(&proof).unwrap() + } + + /// DEV-01 recovery: a receipt cached by a pre-fix client carries proofs + /// with all 16 commitment sidecars (~342 KB each on the wire) — resuming + /// with them would replay the storer's size rejection. Loading must strip + /// the sidecars while leaving the paid pool and address branch intact. + #[test] + fn strip_removes_legacy_sidecars_and_is_idempotent() { + let ts = 1_000_000; + let fat = fat_merkle_proof_bytes(ts); + let mut proofs: HashMap<[u8; 32], Vec> = HashMap::new(); + proofs.insert([0u8; 32], fat.clone()); + // A non-merkle blob must pass through untouched. + proofs.insert([1u8; 32], vec![1, 2, 3]); + let mut receipt = MerkleBatchPaymentResult { + proofs, + chunk_count: 2, + storage_cost_atto: "0".to_string(), + gas_cost_wei: 0, + merkle_payment_timestamp: ts, + }; + + assert!(strip_commitment_sidecars(&mut receipt)); + + let slim = receipt.proofs.get(&[0u8; 32]).unwrap(); + assert!(slim.len() < fat.len(), "stripped proof must shrink"); + let proof = deserialize_merkle_proof(slim).unwrap(); + assert!(proof.commitment_sidecars.is_empty()); + assert_eq!(receipt.proofs.get(&[1u8; 32]).unwrap(), &vec![1, 2, 3]); + + // Second pass finds nothing left to strip. + assert!(!strip_commitment_sidecars(&mut receipt)); + } + + /// The strip write-back must replace the canonical receipt atomically: + /// new content lands, and no `.tmp` sibling survives a successful write. + #[test] + fn overwrite_receipt_is_atomic_and_leaves_no_tmp() -> Result<()> { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let dir = std::env::temp_dir().join(format!("anselme-merkle-overwrite-test-{nanos}")); + fs::create_dir_all(&dir)?; + let path = dir.join("123_abcd"); + fs::write(&path, b"pre-fix receipt bytes")?; + + overwrite_receipt(&path, &dummy_receipt(42))?; + + let reloaded = read_receipt(&path)?; + assert_eq!(reloaded.merkle_payment_timestamp, 42); + let leftover_tmps = fs::read_dir(&dir)? + .flatten() + .filter(|e| { + e.path() + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("tmp")) + }) + .count(); + assert_eq!(leftover_tmps, 0, "no tmp sibling may survive"); + + fs::remove_dir_all(&dir).ok(); + Ok(()) + } + #[test] fn file_hash_key_is_stable() { let a = file_hash_key("/tmp/some/file.bin"); diff --git a/ant-core/src/data/client/merkle.rs b/ant-core/src/data/client/merkle.rs index 091a3f6d..af0e44ba 100644 --- a/ant-core/src/data/client/merkle.rs +++ b/ant-core/src/data/client/merkle.rs @@ -163,10 +163,6 @@ pub struct PreparedMerkleBatch { tree: MerkleTree, /// Internal: chunk addresses in order. addresses: Vec<[u8; 32]>, - /// ADR-0004: validated commitment sidecars keyed by `(peer, pin)`, collected - /// during candidate validation. At proof time the winner pool's candidates - /// forward theirs in `MerklePaymentProof.commitment_sidecars`. - commitment_sidecars: HashMap<(PeerId, [u8; 32]), Vec>, } /// Result of checking a merkle upload batch before payment. @@ -528,9 +524,11 @@ impl Client { ); // 3. Collect candidate pools from the network (all pools in parallel). - // Each candidate's ADR-0004 binding is verified during collection and - // its commitment sidecar captured (keyed by peer, pin). - let (candidate_pools, commitment_sidecars) = self + // Each candidate's ADR-0004 binding is fully verified during + // collection (shape, cap, exact price, commitment resolution); the + // sidecars themselves are consumed by that validation and NOT + // forwarded in the PUT bundles (see `finalize_merkle_batch`). + let candidate_pools = self .build_candidate_pools( &midpoint_proofs, data_type, @@ -552,7 +550,6 @@ impl Client { candidate_pools, tree, addresses: addresses.to_vec(), - commitment_sidecars, }) } @@ -666,17 +663,14 @@ impl Client { data_type: u32, data_size: u64, merkle_payment_timestamp: u64, - ) -> Result<( - Vec, - HashMap<(PeerId, [u8; 32]), Vec>, - )> { + ) -> Result> { let mut pool_futures = FuturesUnordered::new(); for midpoint_proof in midpoint_proofs { let pool_address = midpoint_proof.address(); let mp = midpoint_proof.clone(); pool_futures.push(async move { - let (candidate_nodes, sidecars) = self + let candidate_nodes = self .get_merkle_candidate_pool( &pool_address.0, data_type, @@ -684,28 +678,19 @@ impl Client { merkle_payment_timestamp, ) .await?; - Ok::<_, Error>(( - MerklePaymentCandidatePool { - midpoint_proof: mp, - candidate_nodes, - }, - sidecars, - )) + Ok::<_, Error>(MerklePaymentCandidatePool { + midpoint_proof: mp, + candidate_nodes, + }) }); } let mut pools = Vec::with_capacity(midpoint_proofs.len()); - // ADR-0004: merged map of every validated candidate's commitment sidecar, - // keyed by (peer, pin). At proof time the winner pool's candidates look - // up their sidecars here to forward in the merkle PUT bundle. - let mut sidecars: HashMap<(PeerId, [u8; 32]), Vec> = HashMap::new(); while let Some(result) = pool_futures.next().await { - let (pool, pool_sidecars) = result?; - pools.push(pool); - sidecars.extend(pool_sidecars); + pools.push(result?); } - Ok((pools, sidecars)) + Ok(pools) } /// Collect `CANDIDATES_PER_POOL` (16) merkle candidate quotes from the network. @@ -716,10 +701,7 @@ impl Client { data_type: u32, data_size: u64, merkle_payment_timestamp: u64, - ) -> Result<( - [MerklePaymentCandidateNode; CANDIDATES_PER_POOL], - HashMap<(PeerId, [u8; 32]), Vec>, - )> { + ) -> Result<[MerklePaymentCandidateNode; CANDIDATES_PER_POOL]> { let node = self.network().node(); let timeout = Duration::from_secs(self.config().quote_timeout_secs); @@ -853,13 +835,9 @@ impl Client { >, target_address: &[u8; 32], merkle_payment_timestamp: u64, - ) -> Result<( - [MerklePaymentCandidateNode; CANDIDATES_PER_POOL], - HashMap<(PeerId, [u8; 32]), Vec>, - )> { + ) -> Result<[MerklePaymentCandidateNode; CANDIDATES_PER_POOL]> { let mut valid: Vec<(PeerId, MerklePaymentCandidateNode)> = Vec::new(); let mut failures: Vec = Vec::new(); - let mut sidecars: HashMap<(PeerId, [u8; 32]), Vec> = HashMap::new(); while let Some((peer_id, result)) = futures.next().await { match result { @@ -875,10 +853,9 @@ impl Client { continue; } // The candidate's identity is `BLAKE3(candidate.pub_key)` — - // this is what the storer derives (verifier.rs) and what proof - // finalization keys the sidecar by. Require it to equal the - // network responder so a two-identity operator cannot answer - // as B while shipping A's commitment. + // this is what the storer derives (verifier.rs). Require it + // to equal the network responder so a two-identity operator + // cannot answer as B while shipping A's commitment. let candidate_peer = PeerId::from_bytes(compute_address(&candidate.pub_key)); if candidate_peer != peer_id { warn!( @@ -892,7 +869,10 @@ impl Client { // the single-node path — a candidate priced off its committed // count, or shipping an unresolvable/forged commitment, is // dropped before it can enter a pool the client pays. Checked - // against the CANDIDATE peer (the one the storer audits). + // against the CANDIDATE peer (the one the storer audits). The + // shipped commitment is consumed here (resolution only) and + // not forwarded in the PUT bundles (see + // `finalize_merkle_batch`). if let Err(detail) = merkle_candidate_binding_is_valid(&candidate_peer, &candidate, &commitment) { @@ -900,11 +880,6 @@ impl Client { failures.push(format!("{peer_id}: bad commitment binding ({detail})")); continue; } - // Key the sidecar by the CANDIDATE peer (== BLAKE3(pub_key)) so - // proof finalization, which derives the same key, finds it. - if let (Some(pin), Some(blob)) = (candidate.commitment_pin, commitment) { - sidecars.insert((candidate_peer, pin), blob); - } valid.push((candidate_peer, candidate)); } Err(e) => { @@ -935,7 +910,7 @@ impl Client { candidates.try_into().map_err(|_| { Error::Payment("Failed to convert candidates to fixed array".to_string()) })?; - Ok((array, sidecars)) + Ok(array) } /// Upload chunks using pre-computed merkle proofs from a batch payment. @@ -1446,18 +1421,14 @@ pub fn finalize_merkle_batch( )) })?; - // ADR-0004: collect the winner pool's candidate commitment sidecars (those - // that were bound + validated during collection), to forward in each proof - // so the storer can cross-check synchronously. Built once for the pool. - let winner_sidecars: Vec> = winner_pool - .candidate_nodes - .iter() - .filter_map(|c| { - let pin = c.commitment_pin?; - let peer = PeerId::from_bytes(compute_address(&c.pub_key)); - prepared.commitment_sidecars.get(&(peer, pin)).cloned() - }) - .collect(); + // ADR-0004: commitment sidecars are deliberately NOT forwarded in the + // per-chunk proofs. Sixteen sidecars are ~214 KB serialized, and copying + // them into every chunk's bundle pushed the proof past the storer's + // payment-proof size cap, rejecting every merkle PUT once nodes carried + // live commitments. The client still fully resolves every candidate's + // commitment before paying (during pool collection); the storer's + // cross-check is best-effort and resolves pins from its gossip cache or a + // `GetCommitmentByPin` fetch when no sidecar is shipped. // Generate proofs for each chunk info!("Generating merkle proofs for {chunk_count} chunks"); @@ -1473,9 +1444,7 @@ pub fn finalize_merkle_batch( )) })?; - let mut merkle_proof = - MerklePaymentProof::new(*xorname, address_proof, winner_pool.clone()); - merkle_proof.commitment_sidecars = winner_sidecars.clone(); + let merkle_proof = MerklePaymentProof::new(*xorname, address_proof, winner_pool.clone()); let tagged_bytes = serialize_merkle_proof(&merkle_proof) .map_err(|e| Error::Serialization(format!("Failed to serialize merkle proof: {e}")))?; @@ -1866,7 +1835,6 @@ mod tests { candidate_pools, tree, addresses: addrs, - commitment_sidecars: HashMap::new(), } } @@ -1891,6 +1859,39 @@ mod tests { } } + /// DEV-01 regression: per-chunk merkle proofs must never ship commitment + /// sidecars — all 16 winner-pool sidecars (~214 KB serialized) copied into + /// every chunk's proof pushed it past the storer's payment-proof size cap, + /// rejecting every merkle PUT once nodes carried live commitments. The + /// e2e (`adr0004_merkle_upload_against_bound_candidates`) proves the flow + /// end-to-end; this pins the wire invariant directly so it cannot slip + /// back in behind a raised node-side cap. + #[test] + fn test_finalize_merkle_batch_ships_no_commitment_sidecars() { + use ant_protocol::payment::deserialize_merkle_proof; + + let mut prepared = make_prepared_merkle_batch(4); + // Bind every candidate to a pin, mirroring a network where all nodes + // carry live commitments (the DEV-01 trigger state). + for pool in &mut prepared.candidate_pools { + for candidate in &mut pool.candidate_nodes { + candidate.committed_key_count = 9_000; + candidate.commitment_pin = Some([7u8; 32]); + } + } + let winner_hash = prepared.candidate_pools[0].hash(); + + let batch = finalize_merkle_batch(prepared, winner_hash).unwrap(); + assert_eq!(batch.proofs.len(), 4); + for proof_bytes in batch.proofs.values() { + let proof = deserialize_merkle_proof(proof_bytes).unwrap(); + assert!( + proof.commitment_sidecars.is_empty(), + "per-chunk merkle proofs must not ship commitment sidecars" + ); + } + } + #[test] fn test_finalize_merkle_batch_with_invalid_winner() { let prepared = make_prepared_merkle_batch(4); diff --git a/ant-core/tests/e2e_adr0004.rs b/ant-core/tests/e2e_adr0004.rs index d019c614..9a49dace 100644 --- a/ant-core/tests/e2e_adr0004.rs +++ b/ant-core/tests/e2e_adr0004.rs @@ -22,13 +22,15 @@ mod support; -use ant_core::data::{compute_address, Client}; +use ant_core::data::client::merkle::PaymentMode; +use ant_core::data::{compute_address, Client, ClientConfig}; use ant_protocol::payment::calculate_price; use ant_protocol::payment::commitment::{ commitment_hash, verify_commitment_signature, StorageCommitment, }; use bytes::Bytes; use serial_test::serial; +use std::io::Write; use std::sync::Arc; use support::{test_client_config, MiniTestnet, DEFAULT_NODE_COUNT}; @@ -184,3 +186,43 @@ async fn adr0004_baseline_quotes_still_work() { drop(client); testnet.teardown().await; } + +/// DEV-01 regression: a FORCED-MERKLE upload against a network where every +/// node carries a live storage commitment (bound candidates). On DEV-01 this +/// exact combination started failing the moment nodes rotated their first +/// commitment (2026-07-06 19:38 UTC) while single-node payments kept working. +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn adr0004_merkle_upload_against_bound_candidates() { + // 35 nodes: merkle pools need 16 valid candidates per pool. + let testnet = MiniTestnet::start_with_commitments(35, COMMITTED_KEYS).await; + let node = testnet.node(5).expect("node 5 exists"); + let config = ClientConfig { + quote_timeout_secs: 120, + store_timeout_secs: 120, + close_group_size: 20, + ..Default::default() + }; + let client = Client::from_node(Arc::clone(&node), config).with_wallet(testnet.wallet().clone()); + + // 500KB file — self-encryption produces 3+ chunks (>=2 needed for merkle). + let data: Vec = (0u8..=255).cycle().take(500_000).collect(); + let mut input_file = tempfile::NamedTempFile::new().expect("create temp file"); + input_file.write_all(&data).expect("write temp file"); + input_file.flush().expect("flush temp file"); + + let result = client + .file_upload_with_mode(input_file.path(), PaymentMode::Merkle) + .await + .expect("merkle upload against bound candidates must succeed"); + + assert_eq!( + result.payment_mode_used, + PaymentMode::Merkle, + "payment_mode_used must be Merkle" + ); + assert!(result.chunks_stored >= 3, "chunks must be stored"); + + drop(client); + testnet.teardown().await; +} From 059cc0a532d63f65cb831e02b81ab8fe22d2c666 Mon Sep 17 00:00:00 2001 From: Chris O'Neil Date: Thu, 9 Jul 2026 17:45:17 +0100 Subject: [PATCH 11/11] chore(deps): cut over to published ADR-0004 crates Strip the temporary [patch.crates-io] git branches and repin ant-node from the grumbach ADR-0004 branch to the published 0.14.3, now that the coordinated ADR-0004 cutover crates are published in order evmlib 0.9.0 -> ant-protocol 2.3.0 -> ant-node 0.14.3. ant-protocol was already pinned to 2.3.0. Lockfile regenerated to registry sources; the workspace resolves a single saorsa-core 0.26.2 and `cargo check` builds clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 15 +++++++++------ Cargo.toml | 18 ------------------ ant-core/Cargo.toml | 10 +++++----- 3 files changed, 14 insertions(+), 29 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f4f50fa2..bcd15a8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -815,7 +815,7 @@ dependencies = [ [[package]] name = "ant-cli" -version = "0.2.10" +version = "0.2.11" dependencies = [ "ant-core", "anyhow", @@ -835,7 +835,7 @@ dependencies = [ [[package]] name = "ant-core" -version = "0.3.0" +version = "0.3.1" dependencies = [ "alloy", "ant-node", @@ -892,8 +892,9 @@ dependencies = [ [[package]] name = "ant-node" -version = "0.14.2" -source = "git+https://github.com/grumbach/ant-node?branch=adr-0003-commitment-bound-pricing#ff7a547b627f2a18e630802feb02adeedb34ec1c" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f7f5de9683a7ce865dbc34eb51356fc748a0ee5b274e4fb343aec4de5dc3c81" dependencies = [ "ant-protocol", "blake3", @@ -942,7 +943,8 @@ dependencies = [ [[package]] name = "ant-protocol" version = "2.3.0" -source = "git+https://github.com/WithAutonomi/ant-protocol?branch=adr-0003-commitment-bound-pricing#3a4eb638abbbec2ec88823f14bfe66e11d70d9b5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b665880152b4e124094acc1faaff5f20a275594e6364b8a729b50f3bc326674" dependencies = [ "blake3", "bytes", @@ -2528,7 +2530,8 @@ dependencies = [ [[package]] name = "evmlib" version = "0.9.0" -source = "git+https://github.com/WithAutonomi/evmlib?branch=adr-0003-signed-quote-fields#a7ac7e2620cb6a50151dd0c619fd75e03b02f05c" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8da0d9ad5b5cab92cc92ddac9afb884f86b226340caf17f6e5c41d51175dcaa1" dependencies = [ "alloy", "ant-merkle", diff --git a/Cargo.toml b/Cargo.toml index 745a1102..f3978b22 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,21 +1,3 @@ [workspace] members = ["ant-core", "ant-cli"] resolver = "2" - -# ─── TEMPORARY: STRIP BEFORE MERGE — ADR-0004 coordinated cutover ─── -# ant-protocol/evmlib's ADR-0004 commitment-bound-quote types (the signed -# `committed_key_count`/`commitment_pin` fields, the commitment sidecar, and -# `BadQuoteCommitment`) are not yet published to crates.io, so this workspace -# cannot build against the published `ant-protocol`/`evmlib`. These git patches -# point those deps at their PR branches so CI can build the coordinated change. -# -# AT RELEASE (once the ADR-0004 versions are published, in order -# evmlib → ant-protocol): -# 1. delete this entire [patch.crates-io] block, AND -# 2. bump the `ant-protocol` pin in ant-core/Cargo.toml to the published -# version (evmlib is re-exported through ant-protocol, no direct pin): -# ant-protocol = "2.3.0" # <- published ADR-0004 ant-protocol -# # (which in turn depends on the published evmlib = "0.9.0") -[patch.crates-io] -evmlib = { git = "https://github.com/WithAutonomi/evmlib", branch = "adr-0003-signed-quote-fields" } -ant-protocol = { git = "https://github.com/WithAutonomi/ant-protocol", branch = "adr-0003-commitment-bound-pricing" } diff --git a/ant-core/Cargo.toml b/ant-core/Cargo.toml index fd944420..a783c3bf 100644 --- a/ant-core/Cargo.toml +++ b/ant-core/Cargo.toml @@ -62,10 +62,10 @@ sysinfo = { version = "0.32", default-features = false, features = ["system"] } # `ant-protocol` pin above — a version skew pulls a second copy of # `saorsa-core` into the graph and makes `ant_node`'s and `ant_protocol`'s # `MultiAddr` mutually incompatible in `node/devnet.rs`. While the runtime -# `ant-protocol` pin above points at a git branch, this ant-node must point at -# the matching ant-node branch carrying the same saorsa-core / ant-protocol -# lineage rather than a released version. -ant-node = { git = "https://github.com/grumbach/ant-node", branch = "adr-0003-commitment-bound-pricing", optional = true } +# `ant-protocol` pin above points at a released version, this ant-node must +# track the matching released version carrying the same saorsa-core / +# ant-protocol lineage. +ant-node = { version = "0.14.3", optional = true } tracing-subscriber = { version = "0.3", features = ["env-filter"] } [target.'cfg(unix)'.dependencies] @@ -96,7 +96,7 @@ test-utils = [] # always compile even without the `devnet` feature. Pinned to the same # version as the runtime dep so there is a single ant-node / # saorsa-core version across the whole graph. -ant-node = { git = "https://github.com/grumbach/ant-node", branch = "adr-0003-commitment-bound-pricing", features = ["test-utils"] } +ant-node = { version = "0.14.3", features = ["test-utils"] } serial_test = "3" anyhow = "1" alloy = { version = "1.6", features = ["node-bindings"] }