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 }}) diff --git a/Cargo.lock b/Cargo.lock index 31e7aa25..bcd15a8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -892,9 +892,9 @@ dependencies = [ [[package]] name = "ant-node" -version = "0.14.2" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbbc1f23610ffb700cd85bbf7519ebe29c767ccffc85480c29277da627de41de" +checksum = "3f7f5de9683a7ce865dbc34eb51356fc748a0ee5b274e4fb343aec4de5dc3c81" dependencies = [ "ant-protocol", "blake3", @@ -942,9 +942,9 @@ dependencies = [ [[package]] name = "ant-protocol" -version = "2.2.2" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a35986649c5963ec808efb44621d8b94716cc2ef5a041ea9a1875487578097c" +checksum = "5b665880152b4e124094acc1faaff5f20a275594e6364b8a729b50f3bc326674" dependencies = [ "blake3", "bytes", @@ -2010,9 +2010,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", ] @@ -2529,9 +2529,9 @@ dependencies = [ [[package]] name = "evmlib" -version = "0.8.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebd96cf24017cd31cd422c80de3078a821b7884a5b1feb00087e98209326c676" +checksum = "8da0d9ad5b5cab92cc92ddac9afb884f86b226340caf17f6e5c41d51175dcaa1" dependencies = [ "alloy", "ant-merkle", @@ -3261,7 +3261,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.57.0", + "windows-core 0.58.0", ] [[package]] diff --git a/ant-core/Cargo.toml b/ant-core/Cargo.toml index 2153839d..a783c3bf 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" @@ -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 = { version = "0.14.2", 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 = { version = "0.14.2", features = ["test-utils"] } +ant-node = { version = "0.14.3", features = ["test-utils"] } serial_test = "3" anyhow = "1" alloy = { version = "1.6", features = ["node-bindings"] } 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..a30f67b3 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-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>, } /// 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-0004: 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-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(); - 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/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/file.rs b/ant-core/src/data/client/file.rs index c5c28ccf..960399fd 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-0004) +/// the opaque signed-commitment blob the node shipped with the quote. +type QuoteEntry = ( + PeerId, + Vec, + PaymentQuote, + Amount, + Option>, +); type DownloadBatchEntry = (usize, std::result::Result); @@ -970,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. @@ -1330,7 +1344,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..af0e44ba 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-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 +/// `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")] @@ -443,7 +523,11 @@ impl Client { midpoint_proofs.len() ); - // 3. Collect candidate pools from the network (all pools in parallel) + // 3. Collect candidate pools from the network (all pools in parallel). + // 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, @@ -685,12 +769,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,7 +829,7 @@ impl Client { impl std::future::Future< Output = ( PeerId, - std::result::Result, + std::result::Result<(MerklePaymentCandidateNode, Option>), Error>, ), >, >, @@ -754,7 +841,7 @@ impl Client { 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 +852,35 @@ 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). 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-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 + // 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) + { + warn!("Dropping merkle candidate {peer_id} — ADR-0004 binding invalid: {detail}"); + failures.push(format!("{peer_id}: bad commitment binding ({detail})")); + continue; + } + valid.push((candidate_peer, candidate)); } Err(e) => { debug!("Failed to get merkle candidate from {peer_id}: {e}"); @@ -791,9 +906,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) } /// Upload chunks using pre-computed merkle proofs from a batch payment. @@ -1304,6 +1421,15 @@ pub fn finalize_merkle_batch( )) })?; + // 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"); let mut proofs = HashMap::with_capacity(chunk_count); @@ -1611,6 +1737,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 +1777,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 +1798,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, }) } @@ -1727,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/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..859ded4a 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-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 + // 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..3143d71d 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,18 @@ 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-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 = ( + PeerId, + Vec, + PaymentQuote, + Amount, + Option>, +); + /// Check that a quote's `pub_key` is well-formed and BLAKE3-hashes to the /// claimed `peer_id`. /// @@ -64,11 +82,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-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. 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 +93,122 @@ fn quote_binding_is_valid(peer_id: &PeerId, quote: &PaymentQuote) -> bool { compute_address("e.pub_key) == *peer_id.as_bytes() } +/// 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"). +/// +/// 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-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 +/// 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 +221,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-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. +/// +/// 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 +261,51 @@ fn classify_quote_response( }); } + // 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 + // 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-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-0004 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 { @@ -207,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}")), )), @@ -225,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])>, @@ -233,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"); @@ -324,8 +489,21 @@ 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)>); @@ -559,9 +737,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; } @@ -569,7 +745,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 @@ -597,7 +773,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(); @@ -739,7 +915,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? @@ -813,7 +989,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() @@ -924,7 +1100,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(); @@ -1093,7 +1269,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 +1362,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-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. + 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 +1434,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 { @@ -1259,7 +1469,16 @@ 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]), @@ -1268,18 +1487,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() } @@ -1319,7 +1540,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 +1550,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 +1569,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 +1593,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)); @@ -1751,7 +1976,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!( @@ -1854,7 +2079,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 +2094,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 +2119,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 +2186,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 +2198,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 +2234,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 +2259,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 +2334,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 +2349,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 +2361,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 +2374,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 +2395,257 @@ mod tests { ); } } + + // ============================================================ + // 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 + // 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_adr0004.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, + } + } + + /// 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. + 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_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)); + 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 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, + 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_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 + // 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..e0f49b7f 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-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 + /// 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_adr0004.rs b/ant-core/tests/e2e_adr0004.rs new file mode 100644 index 00000000..9a49dace --- /dev/null +++ b/ant-core/tests/e2e_adr0004.rs @@ -0,0 +1,228 @@ +//! 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-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. +//! 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::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}; + +/// 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 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()) + .with_wallet(testnet.wallet().clone()); + + 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 + // 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 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()) + .with_wallet(testnet.wallet().clone()); + + let content = Bytes::from("adr-0004 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; +} + +/// 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; +} 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..9486fecc 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-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 + /// 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-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 + // `(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-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 +/// 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) +}