Skip to main content

ant_core/data/client/
quote.rs

1//! Quote and payment operations.
2//!
3//! Handles requesting storage quotes from network nodes and
4//! managing payment for data storage.
5
6use crate::data::client::peer_xor_distance;
7use crate::data::client::Client;
8use crate::data::client::PUT_TARGET_WIDTH;
9use crate::data::error::{Error, Result};
10use ant_protocol::evm::{Amount, PaymentQuote};
11use ant_protocol::payment::calculate_price;
12use ant_protocol::payment::commitment::{
13    commitment_hash, verify_commitment_signature, StorageCommitment, MAX_COMMITMENT_KEY_COUNT,
14    MAX_COMMITMENT_SIDECAR_BYTES,
15};
16use ant_protocol::payment::{verify_quote_content, verify_quote_signature};
17use ant_protocol::transport::{
18    DHTNode, MultiAddr, P2PNode, PeerId, ResponderView, WitnessedCloseGroup,
19};
20use ant_protocol::{
21    compute_address, send_and_await_chunk_response, ChunkMessage, ChunkMessageBody,
22    ChunkQuoteRequest, ChunkQuoteResponse, CLOSE_GROUP_MAJORITY, CLOSE_GROUP_SIZE,
23};
24use futures::stream::{FuturesUnordered, StreamExt};
25use std::collections::{HashMap, HashSet};
26use std::sync::Arc;
27use std::time::Duration;
28use tracing::{debug, info, warn};
29
30/// Fault-tolerant quote collection asks one extra close group of peers and
31/// keeps the closest successful `CLOSE_GROUP_SIZE` responders. This remains
32/// useful for merkle preflight probes, but single-node payments deliberately
33/// ask only the actual close group.
34const FAULT_TOLERANT_QUOTE_QUERY_MULTIPLIER: usize = 2;
35
36/// Witnessed close-group quorum as a fraction of the initial close group.
37/// For today's `CLOSE_GROUP_SIZE = 7`, this yields the requested 5-of-7
38/// quorum.
39const WITNESSED_QUORUM_NUMERATOR: usize = 2;
40const WITNESSED_QUORUM_DENOMINATOR: usize = 3;
41
42/// Number of closest nodes each initial witnessed responder contributes.
43const SINGLE_NODE_WITNESSED_VIEW_COUNT: usize = 20;
44
45/// Index of the paid median quote after sorting by quoted price.
46const MEDIAN_QUOTE_INDEX: usize = CLOSE_GROUP_SIZE / 2;
47
48/// Overall timeout for collecting quote responses. Must accommodate
49/// connect_with_fallback cascade (direct 5s + hole-punch 15s×3 + relay 30s ≈
50/// 80s) plus the per-peer quote timeout.
51const QUOTE_COLLECTION_TIMEOUT_SECS: u64 = 120;
52
53/// ML-DSA-65 public key length in bytes. Mirrors the same value defined as
54/// `pub const ML_DSA_65_PUBLIC_KEY_SIZE` in `saorsa-pqc::pqc::types`, which
55/// the storer's `peer_id_from_public_key_bytes` enforces. We keep a local
56/// copy here rather than adding a direct `saorsa-pqc` dep — the constant
57/// is FIPS-mandated for ML-DSA-65 and won't change unless we change variant.
58///
59/// TODO: switch to `saorsa_pqc::pqc::types::ML_DSA_65_PUBLIC_KEY_SIZE` once
60/// `ant-protocol` re-exports it (`pqc::ops::ML_DSA_65_PUBLIC_KEY_SIZE`).
61const ML_DSA_PUB_KEY_LEN: usize = 1952;
62
63/// One collected quote: the responding peer, its addresses, the signed quote,
64/// the price it demands, and (ADR-0004) the opaque signed-commitment blob the
65/// node shipped alongside the quote (`None` for a baseline quote), to be
66/// forwarded as a sidecar in the PUT bundle.
67type QuotedPeer = (
68    PeerId,
69    Vec<MultiAddr>,
70    PaymentQuote,
71    Amount,
72    Option<Vec<u8>>,
73);
74
75/// Check that a quote's `pub_key` is well-formed and BLAKE3-hashes to the
76/// claimed `peer_id`.
77///
78/// The storer node enforces both constraints in `ant-node/src/payment/verifier.rs`
79/// (via `peer_id_from_public_key_bytes` and `validate_peer_bindings`): every
80/// quote inside a `ProofOfPayment` must (a) have a 1952-byte `pub_key` parsable
81/// as ML-DSA-65 and (b) satisfy `BLAKE3(pub_key) == peer_id`. A single quote
82/// failing either check causes the storer to reject the entire close-group
83/// proof and burn the chunk's payment.
84///
85/// This is the cheap structural pre-check. ADR-0004 additionally has the client
86/// run `verify_quote_content` + `verify_quote_signature` (the full ML-DSA check)
87/// in [`classify_quote_response`] before paying, so a quote the storer would
88/// reject never gets paid.
89fn quote_binding_is_valid(peer_id: &PeerId, quote: &PaymentQuote) -> bool {
90    if quote.pub_key.len() != ML_DSA_PUB_KEY_LEN {
91        return false;
92    }
93    compute_address(&quote.pub_key) == *peer_id.as_bytes()
94}
95
96/// ADR-0004 client-side resolve-before-pay gate — "the client pays nothing it
97/// cannot resolve", the ceiling's load-bearing wall (ADR §"The client pays
98/// nothing it cannot resolve").
99///
100/// Runs the **full** binding check before paying, identical to the storer's,
101/// using the shared `ant-protocol` commitment type + verifier so client and
102/// node can never disagree:
103/// 1. **Shape.** `(0, None)` baseline or `(n>0, Some(pin))` bound; the mixed
104///    shapes `(n>0, None)` (unauditable count) and `(0, Some)` (incoherent
105///    baseline) are rejected.
106/// 2. **Cap.** `committed_key_count <= MAX_COMMITMENT_KEY_COUNT` — a count a
107///    commitment could never legitimately attest is rejected before pricing.
108/// 3. **Forced price.** `price == calculate_price(committed_key_count)`, by
109///    exact recomputation with the shared `calculate_price` — never inverted.
110/// 4. **Resolution (bound quotes).** The shipped commitment must: parse as a
111///    `StorageCommitment`, be bound to the quoting peer
112///    (`BLAKE3(sender_public_key) == sender_peer_id`), have a valid ML-DSA-65
113///    signature, hash to the quote's `commitment_pin`
114///    (`commitment_hash == pin`), and attest exactly the claimed count
115///    (`key_count == committed_key_count`). A withheld, unparseable, wrong-pin,
116///    mis-bound, or count-mismatched commitment is unresolvable → the quote is
117///    dropped before payment.
118///
119/// Returns `Ok(())` if the binding fully resolves, or `Err(detail)` naming the
120/// rule that failed.
121fn quote_commitment_binding_is_valid(
122    peer_id: &PeerId,
123    quote: &PaymentQuote,
124    commitment: &Option<Vec<u8>>,
125) -> std::result::Result<(), String> {
126    let count = quote.committed_key_count;
127    let pin = quote.commitment_pin;
128    match (count, pin.is_some()) {
129        (0, false) | (1.., true) => {}
130        (1.., false) => {
131            return Err(format!(
132                "committed_key_count={count} > 0 but commitment_pin is None (unauditable count)"
133            ));
134        }
135        (0, true) => {
136            return Err("committed_key_count=0 with a commitment_pin (incoherent baseline)".into());
137        }
138    }
139    if count > MAX_COMMITMENT_KEY_COUNT {
140        return Err(format!(
141            "committed_key_count={count} exceeds MAX_COMMITMENT_KEY_COUNT={MAX_COMMITMENT_KEY_COUNT}"
142        ));
143    }
144    // Forced price: exact recomputation, never inversion.
145    let expected = calculate_price(count as usize);
146    if quote.price != expected {
147        return Err(format!(
148            "price {} does not equal calculate_price(committed_key_count={count}) = {expected}",
149            quote.price
150        ));
151    }
152
153    // Baseline `(0, None)` pins nothing — fully resolved by the checks above.
154    let Some(pin) = pin else {
155        return Ok(());
156    };
157
158    // Bound quote: the commitment MUST have arrived and MUST resolve the pin.
159    let Some(blob) = commitment else {
160        return Err(
161            "bound quote did not ship its commitment; the pin is unresolvable so the quote \
162             is dropped before payment"
163                .into(),
164        );
165    };
166    // Cap before parsing: bound the deserialize work a malicious responder can
167    // force, and never forward an oversized blob in the PUT bundle.
168    if blob.len() > MAX_COMMITMENT_SIDECAR_BYTES {
169        return Err(format!(
170            "shipped commitment is {} bytes, exceeds MAX_COMMITMENT_SIDECAR_BYTES={MAX_COMMITMENT_SIDECAR_BYTES}",
171            blob.len()
172        ));
173    }
174    let commitment: StorageCommitment = rmp_serde::from_slice(blob).map_err(|e| {
175        format!("shipped commitment did not deserialize as a StorageCommitment: {e}")
176    })?;
177
178    // Peer binding: the commitment must belong to the quoting peer, exactly as
179    // the storer derives a candidate's peer id (`BLAKE3(pub_key)`).
180    if compute_address(&commitment.sender_public_key) != *peer_id.as_bytes()
181        || commitment.sender_peer_id != *peer_id.as_bytes()
182    {
183        return Err("shipped commitment is not bound to the quoting peer".into());
184    }
185    if !verify_commitment_signature(&commitment) {
186        return Err("shipped commitment has an invalid signature".into());
187    }
188    if commitment_hash(&commitment) != Some(pin) {
189        return Err("shipped commitment does not hash to the quote's pin".into());
190    }
191    if commitment.key_count != count {
192        return Err(format!(
193            "shipped commitment attests key_count={} but the quote claims {count}",
194            commitment.key_count
195        ));
196    }
197    Ok(())
198}
199
200/// Classification of a `ChunkQuoteResponse::Success` body for a single peer.
201///
202/// Mirrors the storer-side `validate_peer_bindings` check from
203/// `ant-node/src/payment/verifier.rs` — the cheap BLAKE3 binding —
204/// so we drop misbehaving peers' quotes before payment.
205///
206/// ADR-0004: the client now ALSO runs the storer's `verify_quote_content` and
207/// `verify_quote_signature` (ML-DSA-65) before paying, so "the client pays
208/// nothing it cannot resolve" covers the quote's own validity too, not just the
209/// commitment binding. This matches what the merkle path already does
210/// client-side and costs ~1 ms × CLOSE_GROUP_SIZE per chunk — accepted, since
211/// paying a quote the storer then rejects burns the on-chain payment.
212///
213/// Pulling the logic out of the async closure lets us unit-test the
214/// primary defense (not just the post-collect defensive filter).
215///
216/// # Returns
217///
218/// - `Ok((quote, price))` — the response is honoured as a quote.
219/// - `Err(Error::AlreadyStored)` — the peer claims the chunk is already
220///   present AND the quote it provided binds to its peer ID. Vote counts.
221/// - `Err(Error::BadQuoteBinding { .. })` — bad binding (mirrors the
222///   storer-side rejection). Outer collector counts these via the typed
223///   variant (no string matching).
224/// - `Err(Error::BadQuoteCommitment { .. })` — ADR-0004 forced-price binding
225///   failed (price off the curve, incoherent shape, or a bound quote that did
226///   not ship its commitment); dropped before payment like a bad binding.
227/// - `Err(Error::Serialization(...))` — the quote bytes did not deserialize.
228///
229/// On success the returned commitment is the opaque signed-commitment blob the
230/// node shipped with the quote (`None` for a baseline quote), to be forwarded
231/// as a sidecar in the PUT bundle.
232fn classify_quote_response(
233    peer_id: &PeerId,
234    expected_content: &[u8; 32],
235    quote_bytes: &[u8],
236    already_stored: bool,
237    commitment: Option<Vec<u8>>,
238) -> std::result::Result<(PaymentQuote, Amount, Option<Vec<u8>>), Error> {
239    let payment_quote = rmp_serde::from_slice::<PaymentQuote>(quote_bytes).map_err(|e| {
240        Error::Serialization(format!("Failed to deserialize quote from {peer_id}: {e}"))
241    })?;
242
243    // Peer binding: BLAKE3(pub_key) must equal peer_id. This is the
244    // exact mitigation Chris and the AI investigation requested for the
245    // 2026-04-30 production failure: drop crossed-key peers before they
246    // poison the close-group ProofOfPayment.
247    if !quote_binding_is_valid(peer_id, &payment_quote) {
248        let derived = compute_address(&payment_quote.pub_key);
249        warn!(
250            "Dropping response from {peer_id} — quote.pub_key BLAKE3 mismatch \
251             (peer is signing quotes with another peer's key); the storer \
252             would reject this proof"
253        );
254        return Err(Error::BadQuoteBinding {
255            peer_id: peer_id.to_string(),
256            detail: format!(
257                "BLAKE3(pub_key)={} pub_key_len={}",
258                hex::encode(derived),
259                payment_quote.pub_key.len(),
260            ),
261        });
262    }
263
264    // ADR-0004 "the client runs the full binding check": verify the quote's OWN
265    // ML-DSA-65 signature and that it is for THIS content, before paying —
266    // exactly what the storer checks and what the merkle path already does
267    // client-side. A quote with a valid pub_key binding but a bad signature or
268    // wrong content would otherwise be paid and then rejected by the storer.
269    if !verify_quote_content(&payment_quote, expected_content) {
270        return Err(Error::BadQuoteBinding {
271            peer_id: peer_id.to_string(),
272            detail: "quote content does not match the requested address".to_string(),
273        });
274    }
275    if !verify_quote_signature(&payment_quote) {
276        return Err(Error::BadQuoteBinding {
277            peer_id: peer_id.to_string(),
278            detail: "quote ML-DSA-65 signature is invalid".to_string(),
279        });
280    }
281
282    // ADR-0004 forced-price gate: drop a quote whose price is not exactly the
283    // public formula of its committed count, whose (count, pin) shape is
284    // incoherent, or which is bound but did not ship its commitment. The storer
285    // re-runs the arithmetic and would reject the bundle; we drop it here so we
286    // never pay a quote we cannot resolve.
287    if let Err(detail) = quote_commitment_binding_is_valid(peer_id, &payment_quote, &commitment) {
288        warn!("Dropping response from {peer_id} — ADR-0004 binding invalid: {detail}");
289        return Err(Error::BadQuoteCommitment {
290            peer_id: peer_id.to_string(),
291            detail,
292        });
293    }
294
295    if already_stored {
296        debug!("Peer {peer_id} already has chunk");
297        return Err(Error::AlreadyStored);
298    }
299    let price = payment_quote.price;
300    debug!("Received quote from {peer_id}: price = {price}");
301    Ok((payment_quote, price, commitment))
302}
303
304/// Drop quotes whose `pub_key` does not BLAKE3-hash to the peer that supplied
305/// them. Logs each dropped quote at WARN.
306fn drop_quotes_with_bad_bindings(quotes: &mut Vec<QuotedPeer>) -> usize {
307    let before = quotes.len();
308    quotes.retain(|(peer_id, _, quote, _, _)| {
309        if quote_binding_is_valid(peer_id, quote) {
310            true
311        } else {
312            warn!(
313                "Dropping quote from peer {peer_id} — quote.pub_key BLAKE3 mismatch \
314                 (peer is signing quotes with another peer's key); the storer would \
315                 reject this proof"
316            );
317            false
318        }
319    });
320    before - quotes.len()
321}
322
323#[allow(clippy::too_many_arguments)]
324async fn request_store_quote_from_peer(
325    node: Arc<P2PNode>,
326    peer_id: PeerId,
327    peer_addrs: Vec<MultiAddr>,
328    request_id: u64,
329    address: [u8; 32],
330    data_size: u64,
331    data_type: u32,
332    per_peer_timeout: Duration,
333) -> StoreQuoteRequestResult {
334    let request = ChunkQuoteRequest {
335        address,
336        data_size,
337        data_type,
338    };
339    let message = ChunkMessage {
340        request_id,
341        body: ChunkMessageBody::QuoteRequest(request),
342    };
343
344    let message_bytes = match message.encode() {
345        Ok(bytes) => bytes,
346        Err(e) => {
347            return (
348                peer_id,
349                peer_addrs,
350                Err(Error::Protocol(format!(
351                    "Failed to encode quote request for {peer_id}: {e}"
352                ))),
353            );
354        }
355    };
356
357    let result = send_and_await_chunk_response(
358        &node,
359        &peer_id,
360        message_bytes,
361        request_id,
362        per_peer_timeout,
363        &peer_addrs,
364        |body| match body {
365            ChunkMessageBody::QuoteResponse(ChunkQuoteResponse::Success {
366                quote,
367                already_stored,
368                commitment,
369            }) => Some(classify_quote_response(
370                &peer_id,
371                &address,
372                &quote,
373                already_stored,
374                commitment,
375            )),
376            ChunkMessageBody::QuoteResponse(ChunkQuoteResponse::Error(e)) => Some(Err(
377                Error::Protocol(format!("Quote error from {peer_id}: {e}")),
378            )),
379            _ => None,
380        },
381        |e| Error::Network(format!("Failed to send quote request to {peer_id}: {e}")),
382        || Error::Timeout(format!("Timeout waiting for quote from {peer_id}")),
383    )
384    .await;
385
386    (peer_id, peer_addrs, result)
387}
388
389#[allow(clippy::too_many_arguments)]
390fn record_store_quote_result(
391    peer_id: PeerId,
392    addrs: Vec<MultiAddr>,
393    quote_result: Result<(PaymentQuote, Amount, Option<Vec<u8>>)>,
394    address: &[u8; 32],
395    quotes: &mut Vec<StoreQuote>,
396    already_stored_peers: &mut Vec<(PeerId, [u8; 32])>,
397    failures: &mut Vec<String>,
398    bad_quote_count: &mut usize,
399) {
400    match quote_result {
401        Ok((quote, price, commitment)) => {
402            quotes.push((peer_id, addrs, quote, price, commitment));
403        }
404        Err(Error::AlreadyStored) => {
405            info!("Peer {peer_id} reports chunk already stored");
406            let dist = peer_xor_distance(&peer_id, address);
407            already_stored_peers.push((peer_id, dist));
408        }
409        Err(e) => {
410            if matches!(&e, Error::BadQuoteBinding { .. }) {
411                *bad_quote_count += 1;
412            }
413            warn!("Failed to get quote from {peer_id}: {e}");
414            failures.push(format!("{peer_id}: {e}"));
415        }
416    }
417}
418
419fn witnessed_quote_launch_budget(
420    successful_quotes: usize,
421    in_flight: usize,
422    remaining_peers: usize,
423) -> usize {
424    CLOSE_GROUP_SIZE
425        .saturating_sub(successful_quotes.saturating_add(in_flight))
426        .min(remaining_peers)
427}
428
429fn single_node_quote_query_count() -> usize {
430    CLOSE_GROUP_SIZE
431}
432
433fn fault_tolerant_quote_query_count() -> usize {
434    CLOSE_GROUP_SIZE * FAULT_TOLERANT_QUOTE_QUERY_MULTIPLIER
435}
436
437fn witnessed_close_group_quorum() -> usize {
438    (CLOSE_GROUP_SIZE * WITNESSED_QUORUM_NUMERATOR).div_ceil(WITNESSED_QUORUM_DENOMINATOR)
439}
440
441fn witnessed_close_group_quorum_for_missing_views(missing_views: usize) -> usize {
442    witnessed_close_group_quorum()
443        .saturating_sub(missing_views)
444        .max(1)
445}
446
447fn missing_witnessed_responder_views(witnessed: &WitnessedCloseGroup) -> usize {
448    witnessed
449        .initial_closest
450        .len()
451        .saturating_sub(witnessed.responder_views.len())
452}
453
454fn witnessed_close_group_quorum_for_transcript(witnessed: &WitnessedCloseGroup) -> usize {
455    witnessed_close_group_quorum_for_missing_views(missing_witnessed_responder_views(witnessed))
456}
457
458/// Restrict a witnessed transcript to its closest `CLOSE_GROUP_SIZE` peers.
459///
460/// The witnessed query is widened to `PUT_TARGET_WIDTH` peers so we
461/// have addresses for the full PUT-target set, but the consensus/quorum/quote
462/// logic must still run on the close group only. Keeping just the closest-7
463/// initial peers and the responder views contributed by those peers leaves the
464/// `missing_witnessed_responder_views` math — and the quorum derived from it —
465/// byte-for-byte identical to a `CLOSE_GROUP_SIZE`-wide query.
466fn scope_witnessed_to_close_group(witnessed: &WitnessedCloseGroup) -> WitnessedCloseGroup {
467    let initial_closest: Vec<DHTNode> = witnessed
468        .initial_closest
469        .iter()
470        .take(CLOSE_GROUP_SIZE)
471        .cloned()
472        .collect();
473    let scope: HashSet<PeerId> = initial_closest.iter().map(|node| node.peer_id).collect();
474    let responder_views: Vec<ResponderView> = witnessed
475        .responder_views
476        .iter()
477        .filter(|view| scope.contains(&view.responder))
478        .cloned()
479        .collect();
480    WitnessedCloseGroup {
481        target: witnessed.target,
482        k: CLOSE_GROUP_SIZE,
483        initial_closest,
484        responder_views,
485    }
486}
487
488fn peer_list(peers: &[PeerId]) -> Vec<String> {
489    peers.iter().map(ToString::to_string).collect()
490}
491
492/// One collected store quote, carrying (ADR-0004) the opaque signed-commitment
493/// sidecar the node shipped with its quote (`None` for a baseline quote), to be
494/// forwarded in the PUT bundle and cross-checked by storers.
495pub(crate) type StoreQuote = (
496    PeerId,
497    Vec<MultiAddr>,
498    PaymentQuote,
499    Amount,
500    Option<Vec<u8>>,
501);
502type StoreQuoteRequestResult = (
503    PeerId,
504    Vec<MultiAddr>,
505    Result<(PaymentQuote, Amount, Option<Vec<u8>>)>,
506);
507type VotersByPeer = HashMap<PeerId, HashSet<PeerId>>;
508type WitnessedVoteData = (HashMap<PeerId, DHTNode>, VotersByPeer, Vec<(PeerId, usize)>);
509
510pub(crate) struct StoreQuotePlan {
511    pub(crate) quotes: Vec<StoreQuote>,
512    pub(crate) put_peers: Vec<(PeerId, Vec<MultiAddr>)>,
513}
514
515#[derive(Debug, Clone)]
516struct WitnessedQuoteCandidate {
517    node: DHTNode,
518    votes: usize,
519    voters: HashSet<PeerId>,
520}
521
522#[derive(Debug, Clone)]
523struct WitnessedQuotePeer {
524    peer_id: PeerId,
525    addrs: Vec<MultiAddr>,
526    voters: HashSet<PeerId>,
527}
528
529#[derive(Debug, Clone)]
530struct WitnessedQuoteSelection {
531    quote_peers: Vec<WitnessedQuotePeer>,
532    initial_put_peers: Vec<(PeerId, Vec<MultiAddr>)>,
533    quorum: usize,
534}
535
536enum QuoteSelectionPolicy {
537    ClosestByDistance,
538    WitnessedMedianVoters {
539        voters_by_peer: VotersByPeer,
540        quorum: usize,
541    },
542}
543
544fn witnessed_initial_peers(witnessed: &WitnessedCloseGroup) -> Vec<String> {
545    witnessed
546        .initial_closest
547        .iter()
548        .map(|node| node.peer_id.to_string())
549        .collect()
550}
551
552fn witnessed_responder_views(witnessed: &WitnessedCloseGroup) -> Vec<String> {
553    witnessed
554        .responder_views
555        .iter()
556        .map(|view| {
557            let peers = view
558                .closest
559                .iter()
560                .map(|node| node.peer_id)
561                .collect::<Vec<_>>();
562            format!("{}=>{:?}", view.responder, peer_list(&peers))
563        })
564        .collect()
565}
566
567fn merge_witnessed_node(nodes: &mut HashMap<PeerId, DHTNode>, node: DHTNode) {
568    match nodes.entry(node.peer_id) {
569        std::collections::hash_map::Entry::Occupied(mut entry) => {
570            entry.get_mut().merge_from(node);
571        }
572        std::collections::hash_map::Entry::Vacant(entry) => {
573            entry.insert(node);
574        }
575    }
576}
577
578fn sort_vote_counts_by_distance(vote_counts: &mut [(PeerId, usize)], address: &[u8; 32]) {
579    vote_counts.sort_by(|left, right| {
580        peer_xor_distance(&left.0, address)
581            .cmp(&peer_xor_distance(&right.0, address))
582            .then_with(|| left.0.as_bytes().cmp(right.0.as_bytes()))
583    });
584}
585
586fn witnessed_vote_counts_and_nodes(
587    witnessed: &WitnessedCloseGroup,
588    address: &[u8; 32],
589) -> WitnessedVoteData {
590    let mut known_nodes = HashMap::new();
591    for node in &witnessed.initial_closest {
592        merge_witnessed_node(&mut known_nodes, node.clone());
593    }
594
595    let mut voters_by_peer: HashMap<PeerId, HashSet<PeerId>> = HashMap::new();
596    for view in &witnessed.responder_views {
597        let mut voted = HashSet::new();
598        for node in &view.closest {
599            merge_witnessed_node(&mut known_nodes, node.clone());
600            if voted.insert(node.peer_id) {
601                voters_by_peer
602                    .entry(node.peer_id)
603                    .or_default()
604                    .insert(view.responder);
605            }
606        }
607    }
608
609    let mut vote_counts: Vec<(PeerId, usize)> = voters_by_peer
610        .iter()
611        .map(|(peer_id, voters)| (*peer_id, voters.len()))
612        .collect();
613    sort_vote_counts_by_distance(&mut vote_counts, address);
614    (known_nodes, voters_by_peer, vote_counts)
615}
616
617fn witnessed_consensus_candidates(
618    witnessed: &WitnessedCloseGroup,
619    address: &[u8; 32],
620    quorum: usize,
621) -> Vec<WitnessedQuoteCandidate> {
622    let (known_nodes, voters_by_peer, vote_counts) =
623        witnessed_vote_counts_and_nodes(witnessed, address);
624    let mut candidates = vote_counts
625        .iter()
626        .filter_map(|(peer_id, votes)| {
627            if *votes < quorum {
628                return None;
629            }
630            known_nodes.get(peer_id).cloned().and_then(|node| {
631                voters_by_peer
632                    .get(peer_id)
633                    .cloned()
634                    .map(|voters| WitnessedQuoteCandidate {
635                        node,
636                        votes: *votes,
637                        voters,
638                    })
639            })
640        })
641        .collect::<Vec<_>>();
642
643    candidates.sort_by(|left, right| {
644        peer_xor_distance(&left.node.peer_id, address)
645            .cmp(&peer_xor_distance(&right.node.peer_id, address))
646            .then_with(|| right.votes.cmp(&left.votes))
647            .then_with(|| {
648                left.node
649                    .peer_id
650                    .as_bytes()
651                    .cmp(right.node.peer_id.as_bytes())
652            })
653    });
654    candidates
655}
656
657fn witnessed_vote_counts(witnessed: &WitnessedCloseGroup, address: &[u8; 32]) -> Vec<String> {
658    let (_, _, vote_counts) = witnessed_vote_counts_and_nodes(witnessed, address);
659    vote_counts
660        .iter()
661        .map(|(peer_id, votes)| format!("{peer_id}:{votes}"))
662        .collect()
663}
664
665fn witnessed_consensus(
666    witnessed: &WitnessedCloseGroup,
667    address: &[u8; 32],
668    quorum: usize,
669) -> Vec<String> {
670    witnessed_consensus_candidates(witnessed, address, quorum)
671        .iter()
672        .map(|candidate| format!("{}:{}", candidate.node.peer_id, candidate.votes))
673        .collect()
674}
675
676fn witnessed_close_group_diagnostics(
677    address: &[u8; 32],
678    witnessed: &WitnessedCloseGroup,
679    quorum: usize,
680) -> String {
681    format!(
682        "target={}, initial={:?}, responder_views={:?}, vote_counts={:?}, quorum={}, final={:?}",
683        hex::encode(address),
684        witnessed_initial_peers(witnessed),
685        witnessed_responder_views(witnessed),
686        witnessed_vote_counts(witnessed, address),
687        quorum,
688        witnessed_consensus(witnessed, address, quorum)
689    )
690}
691
692fn witnessed_quote_selection_or_error(
693    address: &[u8; 32],
694    witnessed: &WitnessedCloseGroup,
695    required: usize,
696    quorum: usize,
697) -> Result<WitnessedQuoteSelection> {
698    let candidates = witnessed_consensus_candidates(witnessed, address, quorum);
699    if candidates.len() < required {
700        return Err(Error::InsufficientPeers(format!(
701            "Witnessed close group inconclusive before payment: got {}/{} quorum-recognised peers. {}",
702            candidates.len(),
703            required,
704            witnessed_close_group_diagnostics(address, witnessed, quorum)
705        )));
706    }
707
708    let initial_put_peers = witnessed
709        .initial_closest
710        .iter()
711        .take(CLOSE_GROUP_SIZE)
712        .map(|node| (node.peer_id, node.addresses_by_priority()))
713        .collect::<Vec<_>>();
714
715    if initial_put_peers.len() < CLOSE_GROUP_SIZE {
716        return Err(Error::InsufficientPeers(format!(
717            "Witnessed close group returned only {}/{} initial PUT peers before payment. {}",
718            initial_put_peers.len(),
719            CLOSE_GROUP_SIZE,
720            witnessed_close_group_diagnostics(address, witnessed, quorum)
721        )));
722    }
723
724    let quote_peers = candidates
725        .into_iter()
726        .map(|candidate| WitnessedQuotePeer {
727            peer_id: candidate.node.peer_id,
728            addrs: candidate.node.addresses_by_priority(),
729            voters: candidate.voters,
730        })
731        .collect();
732
733    Ok(WitnessedQuoteSelection {
734        quote_peers,
735        initial_put_peers,
736        quorum,
737    })
738}
739
740pub(crate) fn median_paid_quote_issuer(quotes: &[StoreQuote]) -> Option<(PeerId, Amount)> {
741    if quotes.len() <= MEDIAN_QUOTE_INDEX {
742        return None;
743    }
744
745    let mut by_price: Vec<(usize, PeerId, Amount)> = quotes
746        .iter()
747        .enumerate()
748        .map(|(index, (peer_id, _, _, price, _))| (index, *peer_id, *price))
749        .collect();
750    by_price.sort_by_key(|(index, _, price)| (*price, *index));
751    by_price
752        .get(MEDIAN_QUOTE_INDEX)
753        .map(|(_, peer_id, price)| (*peer_id, *price))
754}
755
756fn sort_quotes_by_distance(quotes: &mut [StoreQuote], address: &[u8; 32]) {
757    quotes.sort_by(|left, right| {
758        peer_xor_distance(&left.0, address)
759            .cmp(&peer_xor_distance(&right.0, address))
760            .then_with(|| left.0.as_bytes().cmp(right.0.as_bytes()))
761    });
762}
763
764fn median_paid_quote_issuer_for_indices(
765    quotes: &[StoreQuote],
766    indices: &[usize],
767) -> Option<(PeerId, Amount)> {
768    if indices.len() <= MEDIAN_QUOTE_INDEX {
769        return None;
770    }
771
772    let mut by_price: Vec<(usize, PeerId, Amount)> = indices
773        .iter()
774        .enumerate()
775        .map(|(selected_index, quote_index)| {
776            let (peer_id, _, _, price, _) = &quotes[*quote_index];
777            (selected_index, *peer_id, *price)
778        })
779        .collect();
780    by_price.sort_by_key(|(selected_index, _, price)| (*price, *selected_index));
781    by_price
782        .get(MEDIAN_QUOTE_INDEX)
783        .map(|(_, peer_id, price)| (*peer_id, *price))
784}
785
786fn median_issuer_voter_support(
787    quotes: &[StoreQuote],
788    indices: &[usize],
789    voters_by_peer: &VotersByPeer,
790) -> Option<(PeerId, usize)> {
791    let (median_peer_id, _) = median_paid_quote_issuer_for_indices(quotes, indices)?;
792    let voters = voters_by_peer.get(&median_peer_id)?;
793    Some((median_peer_id, voters.len()))
794}
795
796fn visit_quote_subsets<F>(
797    quote_count: usize,
798    subset_size: usize,
799    start_index: usize,
800    current: &mut Vec<usize>,
801    visit: &mut F,
802) where
803    F: FnMut(&[usize]),
804{
805    if current.len() == subset_size {
806        visit(current);
807        return;
808    }
809
810    let remaining = subset_size - current.len();
811    let last_start = quote_count - remaining;
812    for index in start_index..=last_start {
813        current.push(index);
814        visit_quote_subsets(quote_count, subset_size, index + 1, current, visit);
815        current.pop();
816    }
817}
818
819fn select_closest_quotes(mut quotes: Vec<StoreQuote>, address: &[u8; 32]) -> Vec<StoreQuote> {
820    sort_quotes_by_distance(&mut quotes, address);
821    quotes.truncate(CLOSE_GROUP_SIZE);
822    quotes
823}
824
825fn select_witnessed_median_voter_quotes(
826    mut quotes: Vec<StoreQuote>,
827    address: &[u8; 32],
828    voters_by_peer: &VotersByPeer,
829    required_support: usize,
830) -> Option<Vec<StoreQuote>> {
831    if quotes.len() < CLOSE_GROUP_SIZE {
832        return None;
833    }
834
835    sort_quotes_by_distance(&mut quotes, address);
836
837    let mut best_indices: Option<(usize, Vec<usize>)> = None;
838    let mut current_indices = Vec::with_capacity(CLOSE_GROUP_SIZE);
839    visit_quote_subsets(
840        quotes.len(),
841        CLOSE_GROUP_SIZE,
842        0,
843        &mut current_indices,
844        &mut |indices| {
845            let Some((_, support)) = median_issuer_voter_support(&quotes, indices, voters_by_peer)
846            else {
847                return;
848            };
849            if support < required_support {
850                return;
851            }
852            match &best_indices {
853                Some((best_support, best)) if *best_support > support => {}
854                Some((best_support, best))
855                    if *best_support == support && best.as_slice() <= indices => {}
856                _ => best_indices = Some((support, indices.to_vec())),
857            }
858        },
859    );
860
861    best_indices.map(|(_, indices)| {
862        indices
863            .into_iter()
864            .map(|index| quotes[index].clone())
865            .collect()
866    })
867}
868
869fn put_peers_with_median_voters_first(
870    quotes: &[StoreQuote],
871    put_peers: &[(PeerId, Vec<MultiAddr>)],
872    voters_by_peer: &VotersByPeer,
873    required_support: usize,
874) -> Option<Vec<(PeerId, Vec<MultiAddr>)>> {
875    let (median_peer_id, _) = median_paid_quote_issuer(quotes)?;
876    let voters = voters_by_peer.get(&median_peer_id)?;
877
878    let mut supporting_peers = Vec::new();
879    let mut fallback_peers = Vec::new();
880    for (peer_id, addrs) in put_peers {
881        let peer = (*peer_id, addrs.clone());
882        if voters.contains(peer_id) {
883            supporting_peers.push(peer);
884        } else {
885            fallback_peers.push(peer);
886        }
887    }
888
889    if supporting_peers.len() < required_support {
890        return None;
891    }
892
893    supporting_peers.extend(fallback_peers);
894    Some(supporting_peers)
895}
896
897impl Client {
898    /// Get storage quotes from the closest peers for a given address.
899    ///
900    /// Builds a quorum-witnessed candidate set with at least
901    /// `CLOSE_GROUP_SIZE` peers, requests quotes from all of them concurrently,
902    /// and returns the closest supported `CLOSE_GROUP_SIZE` successful
903    /// responders. When multiple sets are possible, the client prefers the
904    /// one with the strongest paid-median voter support, then the closest
905    /// peers by XOR distance.
906    ///
907    /// Returns `Error::AlreadyStored` early if `CLOSE_GROUP_MAJORITY` peers
908    /// report the chunk is already stored.
909    ///
910    /// # Errors
911    ///
912    /// Returns an error if insufficient quotes can be collected.
913    pub async fn get_store_quotes(
914        &self,
915        address: &[u8; 32],
916        data_size: u64,
917        data_type: u32,
918    ) -> Result<Vec<StoreQuote>> {
919        Ok(self
920            .get_store_quote_plan(address, data_size, data_type)
921            .await?
922            .quotes)
923    }
924
925    /// Get storage quotes plus PUT targets ordered for paid-median acceptance.
926    ///
927    /// Quote order is preserved for proof construction because tied quote
928    /// prices rely on stable median selection. PUT target order is separate:
929    /// peers that voted for the paid median issuer are placed first so the
930    /// initial write wave is locally acceptable to a storage majority.
931    pub(crate) async fn get_store_quote_plan(
932        &self,
933        address: &[u8; 32],
934        data_size: u64,
935        data_type: u32,
936    ) -> Result<StoreQuotePlan> {
937        let witnessed_selection = self.select_witnessed_quote_selection(address).await?;
938        let voters_by_peer: VotersByPeer = witnessed_selection
939            .quote_peers
940            .iter()
941            .map(|peer| (peer.peer_id, peer.voters.clone()))
942            .collect();
943        let remote_peers = witnessed_selection
944            .quote_peers
945            .into_iter()
946            .map(|peer| (peer.peer_id, peer.addrs))
947            .collect();
948        let initial_put_peers = witnessed_selection.initial_put_peers;
949        let quorum = witnessed_selection.quorum;
950        let quotes = self
951            .collect_store_quotes_from_remote_peers(
952                address,
953                data_size,
954                data_type,
955                remote_peers,
956                QuoteSelectionPolicy::WitnessedMedianVoters {
957                    voters_by_peer: voters_by_peer.clone(),
958                    quorum,
959                },
960            )
961            .await?;
962        let put_peers = put_peers_with_median_voters_first(
963            &quotes,
964            &initial_put_peers,
965            &voters_by_peer,
966            quorum,
967        )
968        .ok_or_else(|| {
969            Error::InsufficientPeers(format!(
970                "Collected {} witnessed quotes, but fewer than {} initial witness PUT peers \
971                 voted for the paid median issuer for {}",
972                quotes.len(),
973                quorum,
974                hex::encode(address)
975            ))
976        })?;
977
978        Ok(StoreQuotePlan { quotes, put_peers })
979    }
980
981    /// Get storage quotes with the previous over-query behaviour.
982    ///
983    /// Merkle preflight uses quote responses only as an already-stored probe;
984    /// the actual payment still happens through merkle candidate pools. Keep
985    /// the extra peer buffer there so merkle upload behaviour remains
986    /// unchanged when a few peers are slow or return unusable quote bindings.
987    pub(crate) async fn get_store_quotes_with_fault_tolerance(
988        &self,
989        address: &[u8; 32],
990        data_size: u64,
991        data_type: u32,
992    ) -> Result<Vec<StoreQuote>> {
993        let peer_query_count = fault_tolerant_quote_query_count();
994        let remote_peers = self
995            .network()
996            .find_closest_peers(address, peer_query_count)
997            .await?;
998
999        self.collect_store_quotes_from_remote_peers(
1000            address,
1001            data_size,
1002            data_type,
1003            remote_peers,
1004            QuoteSelectionPolicy::ClosestByDistance,
1005        )
1006        .await
1007    }
1008
1009    async fn select_witnessed_quote_selection(
1010        &self,
1011        address: &[u8; 32],
1012    ) -> Result<WitnessedQuoteSelection> {
1013        // The quote/quorum/consensus scope is the closest CLOSE_GROUP_SIZE.
1014        let required = single_node_quote_query_count();
1015        // Contact the closest PUT_TARGET_WIDTH peers directly so the whole
1016        // PUT-target set's addresses arrive in this single query. A network
1017        // with fewer than that near the target can't satisfy the wide lookup,
1018        // so fall back to the close-group width — the upload still proceeds with
1019        // a narrower (but valid) PUT-target set rather than failing.
1020        let witnessed = match self
1021            .network()
1022            .find_witnessed_close_group_with_view_count(
1023                address,
1024                PUT_TARGET_WIDTH,
1025                SINGLE_NODE_WITNESSED_VIEW_COUNT,
1026            )
1027            .await
1028        {
1029            Ok(witnessed) => witnessed,
1030            Err(wide_err) => {
1031                debug!(
1032                    target = %hex::encode(address),
1033                    "Wide witnessed lookup ({PUT_TARGET_WIDTH}) failed ({wide_err}); \
1034                     retrying at close-group width ({required})"
1035                );
1036                self.network()
1037                    .find_witnessed_close_group_with_view_count(
1038                        address,
1039                        required,
1040                        SINGLE_NODE_WITNESSED_VIEW_COUNT,
1041                    )
1042                    .await
1043                    .map_err(|e| {
1044                        Error::InsufficientPeers(format!(
1045                            "Witnessed close group lookup failed before payment for target {}: {e}",
1046                            hex::encode(address)
1047                        ))
1048                    })?
1049            }
1050        };
1051        // Run quoting/quorum on the closest CLOSE_GROUP_SIZE only, so payment
1052        // semantics are unaffected by the wider PUT query.
1053        let witnessed_quote = scope_witnessed_to_close_group(&witnessed);
1054        let base_quorum = witnessed_close_group_quorum();
1055        let missing_views = missing_witnessed_responder_views(&witnessed_quote);
1056        let quorum = witnessed_close_group_quorum_for_transcript(&witnessed_quote);
1057
1058        if missing_views > 0 {
1059            warn!(
1060                target = %hex::encode(address),
1061                initial = witnessed_quote.initial_closest.len(),
1062                responder_views = witnessed_quote.responder_views.len(),
1063                missing_views = missing_views,
1064                base_quorum = base_quorum,
1065                adjusted_quorum = quorum,
1066                "Witnessed close group transcript is missing responder views; lowering SNP witness quorum"
1067            );
1068        }
1069
1070        debug!(
1071            target = %hex::encode(address),
1072            quorum = quorum,
1073            view_count = SINGLE_NODE_WITNESSED_VIEW_COUNT,
1074            initial = ?witnessed_initial_peers(&witnessed_quote),
1075            responder_views = ?witnessed_responder_views(&witnessed_quote),
1076            vote_counts = ?witnessed_vote_counts(&witnessed_quote, address),
1077            final_witnessed_set = ?witnessed_consensus(&witnessed_quote, address, quorum),
1078            "Witnessed close group selected for SNP quote collection"
1079        );
1080
1081        let mut selection =
1082            witnessed_quote_selection_or_error(address, &witnessed_quote, required, quorum)?;
1083        // Widen the PUT-target set to the closest PUT_TARGET_WIDTH
1084        // directly-contacted peers; the quote set above stays the closest
1085        // CLOSE_GROUP_SIZE. The same proof is reused on all of them.
1086        selection.initial_put_peers = witnessed
1087            .initial_closest
1088            .iter()
1089            .take(PUT_TARGET_WIDTH)
1090            .map(|node| (node.peer_id, node.addresses_by_priority()))
1091            .collect();
1092        Ok(selection)
1093    }
1094
1095    #[allow(clippy::too_many_lines)]
1096    async fn collect_store_quotes_from_remote_peers(
1097        &self,
1098        address: &[u8; 32],
1099        data_size: u64,
1100        data_type: u32,
1101        remote_peers: Vec<(PeerId, Vec<MultiAddr>)>,
1102        quote_selection_policy: QuoteSelectionPolicy,
1103    ) -> Result<Vec<StoreQuote>> {
1104        let peer_query_count = remote_peers.len();
1105
1106        let node = self.network().node();
1107
1108        debug!(
1109            "Requesting quotes from up to {peer_query_count} peers for address {} (size: {data_size})",
1110            hex::encode(address)
1111        );
1112
1113        if remote_peers.len() < CLOSE_GROUP_SIZE {
1114            return Err(Error::InsufficientPeers(format!(
1115                "Found {} peers, need {CLOSE_GROUP_SIZE}",
1116                remote_peers.len()
1117            )));
1118        }
1119        debug_assert!(peer_query_count >= CLOSE_GROUP_SIZE);
1120
1121        let per_peer_timeout = Duration::from_secs(self.config().quote_timeout_secs);
1122        let overall_timeout = Duration::from_secs(QUOTE_COLLECTION_TIMEOUT_SECS);
1123
1124        // Collect quote responses. SNP/witnessed collection deliberately tries
1125        // the closest witnessed peers first and only falls back to further
1126        // witnessed peers when a closer peer fails to produce a usable quote.
1127        let mut quotes = Vec::with_capacity(peer_query_count);
1128        let mut already_stored_peers: Vec<(PeerId, [u8; 32])> = Vec::new();
1129        let mut failures: Vec<String> = Vec::new();
1130
1131        // Track storer-rejecting peers separately (binding, content, signature
1132        // failures) so we can surface their count in diagnostics — they're a
1133        // special class of failure (peer misconfigured or hostile, not
1134        // network-broken) and the user benefits from seeing them called out.
1135        let mut bad_quote_count = 0usize;
1136
1137        let staged_witnessed_collection = matches!(
1138            &quote_selection_policy,
1139            QuoteSelectionPolicy::WitnessedMedianVoters { .. }
1140        );
1141
1142        if staged_witnessed_collection {
1143            let mut quote_futures = FuturesUnordered::new();
1144            let mut next_peer_index = 0usize;
1145            let collect_result: std::result::Result<std::result::Result<(), Error>, _> =
1146                tokio::time::timeout(overall_timeout, async {
1147                    loop {
1148                        let launch_count = witnessed_quote_launch_budget(
1149                            quotes.len(),
1150                            quote_futures.len(),
1151                            remote_peers.len().saturating_sub(next_peer_index),
1152                        );
1153                        for _ in 0..launch_count {
1154                            let (peer_id, peer_addrs) = &remote_peers[next_peer_index];
1155                            next_peer_index += 1;
1156                            quote_futures.push(request_store_quote_from_peer(
1157                                node.clone(),
1158                                *peer_id,
1159                                peer_addrs.clone(),
1160                                self.next_request_id(),
1161                                *address,
1162                                data_size,
1163                                data_type,
1164                                per_peer_timeout,
1165                            ));
1166                        }
1167
1168                        if quotes.len() >= CLOSE_GROUP_SIZE || quote_futures.is_empty() {
1169                            break;
1170                        }
1171
1172                        let Some((peer_id, addrs, quote_result)) = quote_futures.next().await
1173                        else {
1174                            break;
1175                        };
1176                        record_store_quote_result(
1177                            peer_id,
1178                            addrs,
1179                            quote_result,
1180                            address,
1181                            &mut quotes,
1182                            &mut already_stored_peers,
1183                            &mut failures,
1184                            &mut bad_quote_count,
1185                        );
1186                    }
1187                    Ok(())
1188                })
1189                .await;
1190
1191            match collect_result {
1192                Err(_elapsed) => {
1193                    warn!(
1194                        "Quote collection timed out after {overall_timeout:?} for address {}",
1195                        hex::encode(address)
1196                    );
1197                }
1198                Ok(Err(e)) => return Err(e),
1199                Ok(Ok(())) => {}
1200            }
1201        } else {
1202            // Merkle preflight keeps the previous behaviour: query the full
1203            // over-query set concurrently because those quote responses are
1204            // only used as an already-stored probe.
1205            let mut quote_futures = FuturesUnordered::new();
1206
1207            for (peer_id, peer_addrs) in &remote_peers {
1208                quote_futures.push(request_store_quote_from_peer(
1209                    node.clone(),
1210                    *peer_id,
1211                    peer_addrs.clone(),
1212                    self.next_request_id(),
1213                    *address,
1214                    data_size,
1215                    data_type,
1216                    per_peer_timeout,
1217                ));
1218            }
1219
1220            let collect_result: std::result::Result<std::result::Result<(), Error>, _> =
1221                tokio::time::timeout(overall_timeout, async {
1222                    while let Some((peer_id, addrs, quote_result)) = quote_futures.next().await {
1223                        record_store_quote_result(
1224                            peer_id,
1225                            addrs,
1226                            quote_result,
1227                            address,
1228                            &mut quotes,
1229                            &mut already_stored_peers,
1230                            &mut failures,
1231                            &mut bad_quote_count,
1232                        );
1233                    }
1234                    Ok(())
1235                })
1236                .await;
1237
1238            match collect_result {
1239                Err(_elapsed) => {
1240                    warn!(
1241                        "Quote collection timed out after {overall_timeout:?} for address {}",
1242                        hex::encode(address)
1243                    );
1244                    // Fall through to check if we have enough quotes despite timeout.
1245                    // The timeout fires when slow peers haven't responded yet, but we
1246                    // may already have enough successful quotes from fast peers.
1247                }
1248                Ok(Err(e)) => return Err(e),
1249                Ok(Ok(())) => {}
1250            }
1251        }
1252
1253        // Defensive double-check: the per-peer handler already filters
1254        // bad-binding responses into `failures`, but if any path slipped a bad
1255        // quote into `quotes` (e.g. a future refactor) this catches it before
1256        // we sort by distance and return. `bad_dropped` should be 0 in normal
1257        // operation; non-zero indicates an upstream regression worth investigating.
1258        let bad_dropped = drop_quotes_with_bad_bindings(&mut quotes);
1259        if bad_dropped > 0 {
1260            warn!(
1261                "Defensive filter dropped {bad_dropped} quotes with mismatched peer bindings \
1262                 for address {} — the per-peer handler should have caught these earlier \
1263                 (this indicates an upstream regression)",
1264                hex::encode(address),
1265            );
1266            bad_quote_count += bad_dropped;
1267        }
1268
1269        // Check already-stored: only count votes from the closest CLOSE_GROUP_SIZE peers.
1270        if !already_stored_peers.is_empty() {
1271            let mut all_peers_by_distance: Vec<(bool, [u8; 32])> = Vec::new();
1272            for (peer_id, _, _, _, _) in &quotes {
1273                all_peers_by_distance.push((false, peer_xor_distance(peer_id, address)));
1274            }
1275            for (_, dist) in &already_stored_peers {
1276                all_peers_by_distance.push((true, *dist));
1277            }
1278            all_peers_by_distance.sort_by_key(|a| a.1);
1279
1280            let close_group_stored = all_peers_by_distance
1281                .iter()
1282                .take(CLOSE_GROUP_SIZE)
1283                .filter(|(is_stored, _)| *is_stored)
1284                .count();
1285
1286            if close_group_stored >= CLOSE_GROUP_MAJORITY {
1287                debug!(
1288                    "Chunk {} already stored ({close_group_stored}/{CLOSE_GROUP_SIZE} close-group peers confirm)",
1289                    hex::encode(address)
1290                );
1291                return Err(Error::AlreadyStored);
1292            }
1293        }
1294
1295        let already_stored_count = already_stored_peers.len();
1296        let failure_count = failures.len();
1297        let quote_count = quotes.len();
1298        let total_responses = quote_count + failure_count + already_stored_count;
1299
1300        if quotes.len() >= CLOSE_GROUP_SIZE {
1301            let selected_quotes = match quote_selection_policy {
1302                QuoteSelectionPolicy::ClosestByDistance => select_closest_quotes(quotes, address),
1303                QuoteSelectionPolicy::WitnessedMedianVoters {
1304                    voters_by_peer,
1305                    quorum,
1306                } => select_witnessed_median_voter_quotes(quotes, address, &voters_by_peer, quorum)
1307                    .ok_or_else(|| {
1308                        Error::InsufficientPeers(format!(
1309                            "Got {quote_count} quotes, need {CLOSE_GROUP_SIZE} whose paid \
1310                                 median issuer is recognised by at least {} \
1311                                 selected witness peers ({total_responses} responses: \
1312                                 {already_stored_count} already_stored, {failure_count} failed \
1313                                 including {bad_quote_count} with mismatched peer bindings). \
1314                                 Failures: [{}]",
1315                            quorum,
1316                            failures.join("; ")
1317                        ))
1318                    })?,
1319            };
1320
1321            info!(
1322                "Collected {} quotes for address {} ({total_responses} responses: \
1323                 {quote_count} ok, {already_stored_count} already_stored, {failure_count} failed, \
1324                 {bad_quote_count} bad-binding)",
1325                selected_quotes.len(),
1326                hex::encode(address),
1327            );
1328            return Ok(selected_quotes);
1329        }
1330
1331        Err(Error::InsufficientPeers(format!(
1332            "Got {quote_count} quotes, need {CLOSE_GROUP_SIZE} ({total_responses} responses: \
1333             {already_stored_count} already_stored, {failure_count} failed including \
1334             {bad_quote_count} with mismatched peer bindings). Failures: [{}]",
1335            failures.join("; ")
1336        )))
1337    }
1338}
1339
1340#[cfg(test)]
1341#[allow(clippy::unwrap_used, clippy::expect_used)]
1342mod tests {
1343    //! Test fixtures use real ML-DSA-65 keypairs (1952-byte public keys), the
1344    //! same key material that ships on the wire. The "bad" quote is built by
1345    //! **swapping** the public key field with a different real keypair's
1346    //! public key — the exact shape produced by the Apr 30 production
1347    //! failure (an operator running two co-located identities with crossed
1348    //! quote-signing keys). Signatures are not exercised here because this
1349    //! filter only mirrors `validate_peer_bindings` (BLAKE3 binding); see
1350    //! the doc-comment on `quote_binding_is_valid` for why
1351    //! `verify_quote_signature` and `verify_quote_content` are deliberately
1352    //! NOT mirrored.
1353
1354    use super::*;
1355    use ant_protocol::evm::RewardsAddress;
1356    use ant_protocol::pqc::ops::{MlDsaOperations, MlDsaPublicKey};
1357    use ant_protocol::transport::{DHTNode, MlDsa65, ResponderView, WitnessedCloseGroup};
1358    use std::time::SystemTime;
1359    use xor_name::XorName;
1360
1361    /// A real ML-DSA-65 keypair plus its derived peer ID.
1362    struct Keypair {
1363        peer_id: PeerId,
1364        pub_key_bytes: Vec<u8>,
1365        secret_key_bytes: Vec<u8>,
1366    }
1367
1368    fn gen_keypair() -> Keypair {
1369        let ml_dsa = MlDsa65::new();
1370        let (pub_key, sk) = ml_dsa.generate_keypair().expect("ML-DSA-65 keygen");
1371        let pub_key_bytes = pub_key.as_bytes().to_vec();
1372        let peer_id = PeerId::from_bytes(compute_address(&pub_key_bytes));
1373        Keypair {
1374            peer_id,
1375            pub_key_bytes,
1376            secret_key_bytes: sk.as_bytes().to_vec(),
1377        }
1378    }
1379
1380    /// Build a PROPERLY-SIGNED baseline quote for `content`, signed by a real
1381    /// ML-DSA-65 key whose `BLAKE3(pub_key)` is the returned peer id. Passes the
1382    /// client's full classifier gate (binding + content + signature + price).
1383    fn signed_baseline_quote(content: [u8; 32]) -> (PeerId, PaymentQuote) {
1384        use ant_protocol::pqc::ops::MlDsaSecretKey;
1385        let kp = gen_keypair();
1386        let mut quote = PaymentQuote {
1387            content: XorName(content),
1388            timestamp: SystemTime::UNIX_EPOCH,
1389            price: calculate_price(0),
1390            rewards_address: RewardsAddress::new([0u8; 20]),
1391            pub_key: kp.pub_key_bytes.clone(),
1392            signature: Vec::new(),
1393            committed_key_count: 0,
1394            commitment_pin: None,
1395        };
1396        let ml_dsa = MlDsa65::new();
1397        let sk = MlDsaSecretKey::from_bytes(&kp.secret_key_bytes).expect("sk");
1398        let msg = quote.bytes_for_sig();
1399        quote.signature = ml_dsa.sign(&sk, &msg).expect("sign").as_bytes().to_vec();
1400        (kp.peer_id, quote)
1401    }
1402
1403    /// Build a quote tuple whose `pub_key` correctly hashes to its peer_id.
1404    /// Signature is left empty: this filter does not verify signatures.
1405    ///
1406    /// The quote is a valid ADR-0004 **baseline**: `(0, None)` priced at
1407    /// `calculate_price(0)`, so it passes the forced-price gate in
1408    /// `classify_quote_response`. The 5th tuple element is the (absent)
1409    /// commitment sidecar.
1410    fn good_quote_real() -> QuotedPeer {
1411        let kp = gen_keypair();
1412        let quote = PaymentQuote {
1413            content: XorName([0u8; 32]),
1414            timestamp: SystemTime::UNIX_EPOCH,
1415            price: calculate_price(0),
1416            rewards_address: RewardsAddress::new([0u8; 20]),
1417            pub_key: kp.pub_key_bytes,
1418            signature: Vec::new(),
1419            committed_key_count: 0,
1420            commitment_pin: None,
1421        };
1422        (kp.peer_id, Vec::new(), quote, calculate_price(0), None)
1423    }
1424
1425    /// Build a quote tuple where the quote carries a different keypair's
1426    /// `pub_key` than the peer_id derives from. Mirrors the production
1427    /// failure shape: peer A advertised on the transport, but the quote
1428    /// carries peer B's key.
1429    fn bad_quote_real() -> QuotedPeer {
1430        let claimed = gen_keypair();
1431        let signing = gen_keypair();
1432        assert_ne!(claimed.pub_key_bytes, signing.pub_key_bytes);
1433        assert_ne!(claimed.peer_id.as_bytes(), signing.peer_id.as_bytes());
1434        let quote = PaymentQuote {
1435            content: XorName([0u8; 32]),
1436            timestamp: SystemTime::UNIX_EPOCH,
1437            price: calculate_price(0),
1438            rewards_address: RewardsAddress::new([0u8; 20]),
1439            pub_key: signing.pub_key_bytes,
1440            signature: Vec::new(),
1441            committed_key_count: 0,
1442            commitment_pin: None,
1443        };
1444        (claimed.peer_id, Vec::new(), quote, calculate_price(0), None)
1445    }
1446
1447    fn witnessed_test_node(seed: u8) -> DHTNode {
1448        DHTNode {
1449            peer_id: PeerId::from_bytes([seed; 32]),
1450            addresses: Vec::new(),
1451            address_types: Vec::new(),
1452            distance: None,
1453            reliability: 1.0,
1454        }
1455    }
1456
1457    fn witnessed_test_nodes(seeds: &[u8]) -> Vec<DHTNode> {
1458        seeds.iter().copied().map(witnessed_test_node).collect()
1459    }
1460
1461    fn witnessed_test_view(responder: u8, closest: &[u8]) -> ResponderView {
1462        ResponderView {
1463            responder: PeerId::from_bytes([responder; 32]),
1464            closest: witnessed_test_nodes(closest),
1465        }
1466    }
1467
1468    fn synthetic_peer(seed: u8) -> PeerId {
1469        PeerId::from_bytes([seed; 32])
1470    }
1471
1472    fn synthetic_quote(
1473        seed: u8,
1474        price: u64,
1475    ) -> (
1476        PeerId,
1477        Vec<MultiAddr>,
1478        PaymentQuote,
1479        Amount,
1480        Option<Vec<u8>>,
1481    ) {
1482        let amount = Amount::from(price);
1483        let quote = PaymentQuote {
1484            content: XorName([0u8; 32]),
1485            timestamp: SystemTime::UNIX_EPOCH,
1486            price: amount,
1487            rewards_address: RewardsAddress::new([0u8; 20]),
1488            pub_key: Vec::new(),
1489            signature: Vec::new(),
1490            committed_key_count: 0,
1491            commitment_pin: None,
1492        };
1493        (synthetic_peer(seed), Vec::new(), quote, amount, None)
1494    }
1495
1496    fn synthetic_voters(seeds: &[u8]) -> HashSet<PeerId> {
1497        seeds.iter().copied().map(synthetic_peer).collect()
1498    }
1499
1500    fn quote_peer_seeds(quotes: &[StoreQuote]) -> Vec<u8> {
1501        quotes
1502            .iter()
1503            .map(|(peer_id, _, _, _, _)| peer_id.as_bytes()[0])
1504            .collect()
1505    }
1506
1507    fn put_peer_seeds(peers: &[(PeerId, Vec<MultiAddr>)]) -> Vec<u8> {
1508        peers
1509            .iter()
1510            .map(|(peer_id, _)| peer_id.as_bytes()[0])
1511            .collect()
1512    }
1513
1514    fn put_peers_from_seeds(seeds: &[u8]) -> Vec<(PeerId, Vec<MultiAddr>)> {
1515        seeds
1516            .iter()
1517            .copied()
1518            .map(|seed| (synthetic_peer(seed), Vec::new()))
1519            .collect()
1520    }
1521
1522    /// Independent re-implementation of the storer-side binding spec
1523    /// (`ant-node/src/payment/verifier.rs::validate_peer_bindings` +
1524    /// `peer_id_from_public_key_bytes`):
1525    /// (a) `pub_key` parses as ML-DSA-65 (length 1952), and
1526    /// (b) `BLAKE3(pub_key) == peer_id`.
1527    ///
1528    /// Re-derived from spec, NOT delegating to `quote_binding_is_valid`,
1529    /// so cross-checks are not "function == itself".
1530    fn storer_binding_would_accept(peer_id: &PeerId, quote: &PaymentQuote) -> bool {
1531        if MlDsaPublicKey::from_bytes(&quote.pub_key).is_err() {
1532            return false;
1533        }
1534        compute_address(&quote.pub_key) == *peer_id.as_bytes()
1535    }
1536
1537    // ============================================================
1538    // Tests for `quote_binding_is_valid` (the predicate)
1539    // ============================================================
1540
1541    #[test]
1542    fn binding_accepts_real_self_consistent_keypair() {
1543        let (peer_id, _, quote, _, _) = good_quote_real();
1544        // Property under test: the predicate accepts a quote whose pub_key
1545        // genuinely belongs to the claimed peer.
1546        assert!(quote_binding_is_valid(&peer_id, &quote));
1547        // Cross-check against the independent full storer-spec implementation.
1548        assert!(storer_binding_would_accept(&peer_id, &quote));
1549    }
1550
1551    #[test]
1552    fn binding_rejects_real_crossed_keypair() {
1553        let (peer_id, _, quote, _, _) = bad_quote_real();
1554        assert!(!quote_binding_is_valid(&peer_id, &quote));
1555        assert!(!storer_binding_would_accept(&peer_id, &quote));
1556    }
1557
1558    #[test]
1559    fn binding_rejects_oversize_pubkey() {
1560        // A pub_key longer than ML-DSA-65 (1952 bytes) must be rejected
1561        // even if BLAKE3 happens to agree, because the storer rejects on
1562        // length first via `peer_id_from_public_key_bytes`.
1563        let oversized = vec![0u8; ML_DSA_PUB_KEY_LEN + 1];
1564        let peer_id = PeerId::from_bytes(compute_address(&oversized));
1565        let quote = PaymentQuote {
1566            content: XorName([0u8; 32]),
1567            timestamp: SystemTime::UNIX_EPOCH,
1568            price: Amount::ZERO,
1569            rewards_address: RewardsAddress::new([0u8; 20]),
1570            pub_key: oversized,
1571            signature: Vec::new(),
1572            committed_key_count: 0,
1573            commitment_pin: None,
1574        };
1575        // BLAKE3(pub_key) DOES equal the peer_id we constructed, so the
1576        // bare hash check would pass — but the length guard must reject.
1577        assert_eq!(compute_address(&quote.pub_key), *peer_id.as_bytes());
1578        assert!(
1579            !quote_binding_is_valid(&peer_id, &quote),
1580            "predicate must reject oversize pub_key even when BLAKE3 happens to match"
1581        );
1582        assert!(!storer_binding_would_accept(&peer_id, &quote));
1583    }
1584
1585    #[test]
1586    fn binding_rejects_undersize_pubkey() {
1587        let undersized = vec![0u8; ML_DSA_PUB_KEY_LEN - 1];
1588        let peer_id = PeerId::from_bytes(compute_address(&undersized));
1589        let quote = PaymentQuote {
1590            content: XorName([0u8; 32]),
1591            timestamp: SystemTime::UNIX_EPOCH,
1592            price: Amount::ZERO,
1593            rewards_address: RewardsAddress::new([0u8; 20]),
1594            pub_key: undersized,
1595            signature: Vec::new(),
1596            committed_key_count: 0,
1597            commitment_pin: None,
1598        };
1599        assert!(!quote_binding_is_valid(&peer_id, &quote));
1600        assert!(!storer_binding_would_accept(&peer_id, &quote));
1601    }
1602
1603    // ============================================================
1604    // Tests for the filter (`drop_quotes_with_bad_bindings`)
1605    // ============================================================
1606
1607    #[test]
1608    fn quote_query_counts_keep_single_node_close_group_only() {
1609        assert_eq!(single_node_quote_query_count(), CLOSE_GROUP_SIZE);
1610        assert_eq!(SINGLE_NODE_WITNESSED_VIEW_COUNT, 20);
1611        assert!(SINGLE_NODE_WITNESSED_VIEW_COUNT > single_node_quote_query_count());
1612        assert_eq!(witnessed_close_group_quorum(), 5);
1613        assert_eq!(witnessed_close_group_quorum_for_missing_views(0), 5);
1614        assert_eq!(witnessed_close_group_quorum_for_missing_views(1), 4);
1615        assert_eq!(witnessed_close_group_quorum_for_missing_views(2), 3);
1616        assert_eq!(
1617            fault_tolerant_quote_query_count(),
1618            CLOSE_GROUP_SIZE * FAULT_TOLERANT_QUOTE_QUERY_MULTIPLIER
1619        );
1620        assert!(fault_tolerant_quote_query_count() > single_node_quote_query_count());
1621    }
1622
1623    #[test]
1624    fn witnessed_quote_launch_budget_keeps_exact_quote_window() {
1625        assert_eq!(
1626            witnessed_quote_launch_budget(0, 0, CLOSE_GROUP_SIZE * 2),
1627            CLOSE_GROUP_SIZE,
1628            "initial SNP quote fetch should launch the closest seven peers"
1629        );
1630        assert_eq!(
1631            witnessed_quote_launch_budget(1, CLOSE_GROUP_SIZE - 1, CLOSE_GROUP_SIZE),
1632            0,
1633            "a successful quote should not launch an extra fallback"
1634        );
1635        assert_eq!(
1636            witnessed_quote_launch_budget(0, CLOSE_GROUP_SIZE - 1, CLOSE_GROUP_SIZE),
1637            1,
1638            "a failed in-flight quote should launch the next closest fallback"
1639        );
1640        assert_eq!(
1641            witnessed_quote_launch_budget(CLOSE_GROUP_SIZE - 1, 0, 3),
1642            1,
1643            "only one more peer is needed for the seventh quote"
1644        );
1645        assert_eq!(
1646            witnessed_quote_launch_budget(0, 0, CLOSE_GROUP_SIZE - 1),
1647            CLOSE_GROUP_SIZE - 1,
1648            "launch budget is capped by remaining candidates"
1649        );
1650    }
1651
1652    #[test]
1653    fn witnessed_candidates_sort_by_xor_distance_then_votes() {
1654        let address = [0u8; 32];
1655        let witnessed = WitnessedCloseGroup {
1656            target: address,
1657            k: CLOSE_GROUP_SIZE,
1658            initial_closest: witnessed_test_nodes(&[1, 2, 3, 4, 5, 6, 7]),
1659            responder_views: vec![
1660                witnessed_test_view(1, &[1, 9]),
1661                witnessed_test_view(2, &[1, 9]),
1662                witnessed_test_view(3, &[1, 9]),
1663                witnessed_test_view(4, &[1, 9]),
1664                witnessed_test_view(5, &[1, 9]),
1665                witnessed_test_view(6, &[9]),
1666                witnessed_test_view(7, &[9]),
1667            ],
1668        };
1669
1670        let candidates =
1671            witnessed_consensus_candidates(&witnessed, &address, witnessed_close_group_quorum());
1672
1673        assert_eq!(
1674            candidates
1675                .iter()
1676                .map(|candidate| candidate.node.peer_id.as_bytes()[0])
1677                .collect::<Vec<_>>(),
1678            vec![1, 9],
1679            "XOR closeness must be the primary sort before quote collection"
1680        );
1681    }
1682
1683    /// Ascending seeds `1..=count`, each a valid `u8` peer seed.
1684    fn ascending_seeds(count: usize) -> Vec<u8> {
1685        (1..=count)
1686            .map(|n| u8::try_from(n).expect("test seed fits in u8"))
1687            .collect()
1688    }
1689
1690    #[test]
1691    fn scope_witnessed_to_close_group_matches_native_close_group_query() {
1692        // How many of the closest-`CLOSE_GROUP_SIZE` responders returned a view.
1693        // The remainder are "missing", so the scoped transcript also exercises
1694        // the missing-views quorum adjustment.
1695        const RESPONDED_IN_SCOPE: usize = 5;
1696        // Responders past the close group whose views scoping must drop.
1697        const OUT_OF_SCOPE_RESPONDERS: usize = 2;
1698
1699        let address = [0u8; 32];
1700        let close_seeds = ascending_seeds(CLOSE_GROUP_SIZE);
1701        // Each view's closest list mixes in-group (1, 2) and far (8, 9) peers so
1702        // candidate selection is non-trivial and must survive scoping verbatim.
1703        let view_closest = [1, 2, 8, 9];
1704        let in_scope_views = || -> Vec<ResponderView> {
1705            ascending_seeds(RESPONDED_IN_SCOPE)
1706                .into_iter()
1707                .map(|responder| witnessed_test_view(responder, &view_closest))
1708                .collect()
1709        };
1710
1711        // A wide PUT_TARGET_WIDTH-peer transcript, ordered closest-first (seed n
1712        // == PeerId [n; 32], whose XOR distance to the zero address is n). Two
1713        // responders past the close group (out of scope) must be dropped.
1714        let mut wide_views = in_scope_views();
1715        for offset in 1..=OUT_OF_SCOPE_RESPONDERS {
1716            let responder =
1717                u8::try_from(CLOSE_GROUP_SIZE + offset).expect("out-of-scope seed fits in u8");
1718            wide_views.push(witnessed_test_view(responder, &[1, 2, 3]));
1719        }
1720        let wide = WitnessedCloseGroup {
1721            target: address,
1722            k: PUT_TARGET_WIDTH,
1723            initial_closest: witnessed_test_nodes(&ascending_seeds(PUT_TARGET_WIDTH)),
1724            responder_views: wide_views,
1725        };
1726
1727        // The hand-built equivalent: a native CLOSE_GROUP_SIZE-wide query with
1728        // the same in-scope responders.
1729        let native = WitnessedCloseGroup {
1730            target: address,
1731            k: CLOSE_GROUP_SIZE,
1732            initial_closest: witnessed_test_nodes(&close_seeds),
1733            responder_views: in_scope_views(),
1734        };
1735
1736        let scoped = scope_witnessed_to_close_group(&wide);
1737
1738        // Target preserved; k and the initial set collapse to the close group.
1739        assert_eq!(scoped.target, wide.target);
1740        assert_eq!(scoped.k, CLOSE_GROUP_SIZE);
1741        assert_eq!(
1742            scoped
1743                .initial_closest
1744                .iter()
1745                .map(|node| node.peer_id.as_bytes()[0])
1746                .collect::<Vec<_>>(),
1747            close_seeds,
1748            "initial set must be the closest CLOSE_GROUP_SIZE, in order"
1749        );
1750
1751        // Out-of-close-group responder views are dropped; the in-scope ones keep
1752        // their closest lists untouched (scoping filters by responder only).
1753        assert_eq!(
1754            scoped
1755                .responder_views
1756                .iter()
1757                .map(|view| view.responder.as_bytes()[0])
1758                .collect::<Vec<_>>(),
1759            ascending_seeds(RESPONDED_IN_SCOPE),
1760            "only responders inside the close group survive"
1761        );
1762        assert_eq!(
1763            scoped.responder_views[0]
1764                .closest
1765                .iter()
1766                .map(|node| node.peer_id.as_bytes()[0])
1767                .collect::<Vec<_>>(),
1768            view_closest.to_vec(),
1769            "a surviving view's closest set must be preserved verbatim"
1770        );
1771
1772        // The quorum math and candidate consensus run on the close group only
1773        // and are byte-for-byte identical to the native CLOSE_GROUP_SIZE query.
1774        assert_eq!(
1775            missing_witnessed_responder_views(&scoped),
1776            missing_witnessed_responder_views(&native),
1777        );
1778        let quorum = witnessed_close_group_quorum_for_transcript(&scoped);
1779        assert_eq!(quorum, witnessed_close_group_quorum_for_transcript(&native));
1780        let candidate_seeds = |group: &WitnessedCloseGroup| {
1781            witnessed_consensus_candidates(group, &address, quorum)
1782                .iter()
1783                .map(|candidate| candidate.node.peer_id.as_bytes()[0])
1784                .collect::<Vec<_>>()
1785        };
1786        assert_eq!(
1787            candidate_seeds(&scoped),
1788            candidate_seeds(&native),
1789            "scoped consensus must match a native close-group query"
1790        );
1791    }
1792
1793    #[test]
1794    fn witnessed_quote_peers_error_is_typed_and_pre_payment_when_consensus_is_short() {
1795        let address = [0u8; 32];
1796        let responder_views = (1..=7)
1797            .map(|responder| witnessed_test_view(responder, &[1, 2, 3, 4]))
1798            .collect();
1799        let witnessed = WitnessedCloseGroup {
1800            target: address,
1801            k: CLOSE_GROUP_SIZE,
1802            initial_closest: witnessed_test_nodes(&[1, 2, 3, 4, 5, 6, 7]),
1803            responder_views,
1804        };
1805
1806        let err = witnessed_quote_selection_or_error(
1807            &address,
1808            &witnessed,
1809            CLOSE_GROUP_SIZE,
1810            witnessed_close_group_quorum(),
1811        )
1812        .expect_err("short witnessed consensus must fail before payment");
1813
1814        match err {
1815            Error::InsufficientPeers(message) => {
1816                assert!(message.contains("before payment"));
1817                assert!(message.contains("vote_counts"));
1818                assert!(message.contains("quorum"));
1819            }
1820            other => panic!("expected typed InsufficientPeers error, got {other:?}"),
1821        }
1822    }
1823
1824    #[test]
1825    fn witnessed_quote_peers_include_quorum_fallback_candidates() {
1826        const EXTRA_QUORUM_CANDIDATES: usize = 1;
1827
1828        let address = [0u8; 32];
1829        let witnessed = WitnessedCloseGroup {
1830            target: address,
1831            k: CLOSE_GROUP_SIZE,
1832            initial_closest: witnessed_test_nodes(&[1, 2, 3, 4, 5, 6, 7]),
1833            responder_views: vec![
1834                witnessed_test_view(1, &[1, 2, 3, 4, 5, 6, 7]),
1835                witnessed_test_view(2, &[1, 2, 3, 4, 5, 6, 8]),
1836                witnessed_test_view(3, &[1, 2, 3, 4, 5, 7, 8]),
1837                witnessed_test_view(4, &[1, 2, 3, 4, 6, 7, 8]),
1838                witnessed_test_view(5, &[1, 2, 3, 5, 6, 7, 8]),
1839                witnessed_test_view(6, &[1, 2, 4, 5, 6, 7, 8]),
1840                witnessed_test_view(7, &[1, 3, 4, 5, 6, 7, 8]),
1841            ],
1842        };
1843
1844        let selection = witnessed_quote_selection_or_error(
1845            &address,
1846            &witnessed,
1847            CLOSE_GROUP_SIZE,
1848            witnessed_close_group_quorum(),
1849        )
1850        .expect("fallback candidates should be retained for quote collection");
1851
1852        assert_eq!(
1853            selection.quote_peers.len(),
1854            CLOSE_GROUP_SIZE + EXTRA_QUORUM_CANDIDATES
1855        );
1856        assert_eq!(
1857            selection
1858                .quote_peers
1859                .iter()
1860                .map(|peer| peer.peer_id.as_bytes()[0])
1861                .collect::<Vec<_>>(),
1862            vec![1, 2, 3, 4, 5, 6, 7, 8]
1863        );
1864        assert_eq!(
1865            put_peer_seeds(&selection.initial_put_peers),
1866            vec![1, 2, 3, 4, 5, 6, 7]
1867        );
1868    }
1869
1870    #[test]
1871    fn witnessed_quote_peers_lower_quorum_for_missing_responder_views() {
1872        let address = [0u8; 32];
1873        let witnessed = WitnessedCloseGroup {
1874            target: address,
1875            k: CLOSE_GROUP_SIZE,
1876            initial_closest: witnessed_test_nodes(&[1, 2, 3, 4, 5, 6, 7]),
1877            responder_views: vec![
1878                witnessed_test_view(1, &[1, 2, 3, 4, 5, 6, 7]),
1879                witnessed_test_view(2, &[1, 2, 3, 4, 5, 6, 8]),
1880                witnessed_test_view(3, &[1, 2, 3, 4, 5, 7, 8]),
1881                witnessed_test_view(4, &[1, 2, 3, 4, 6, 7, 8]),
1882                witnessed_test_view(5, &[1, 2, 3, 5, 6, 7, 8]),
1883                witnessed_test_view(6, &[1, 2, 4, 5, 6, 7, 8]),
1884            ],
1885        };
1886        let quorum = witnessed_close_group_quorum_for_transcript(&witnessed);
1887
1888        assert_eq!(missing_witnessed_responder_views(&witnessed), 1);
1889        assert_eq!(quorum, 4);
1890
1891        let selection =
1892            witnessed_quote_selection_or_error(&address, &witnessed, CLOSE_GROUP_SIZE, quorum)
1893                .expect(
1894                    "one missing responder view should lower quorum and still select candidates",
1895                );
1896
1897        assert_eq!(
1898            selection
1899                .quote_peers
1900                .iter()
1901                .map(|peer| peer.peer_id.as_bytes()[0])
1902                .collect::<Vec<_>>(),
1903            vec![1, 2, 3, 4, 5, 6, 7, 8]
1904        );
1905        assert_eq!(selection.quorum, quorum);
1906    }
1907
1908    #[test]
1909    fn witnessed_quote_selection_keeps_closest_set_with_median_voter_quorum() {
1910        const MEDIAN_ISSUER_SEED: u8 = 7;
1911        const FAR_SUPPORTING_VOTER_SEED: u8 = 20;
1912        const UNSUCCESSFUL_SUPPORTING_VOTER_SEED: u8 = 21;
1913
1914        let address = [0u8; 32];
1915        let quotes = vec![
1916            synthetic_quote(1, 10),
1917            synthetic_quote(2, 20),
1918            synthetic_quote(3, 30),
1919            synthetic_quote(6, 50),
1920            synthetic_quote(MEDIAN_ISSUER_SEED, 40),
1921            synthetic_quote(8, 60),
1922            synthetic_quote(9, 70),
1923            synthetic_quote(FAR_SUPPORTING_VOTER_SEED, 80),
1924        ];
1925        let mut voters_by_peer = HashMap::new();
1926        voters_by_peer.insert(
1927            synthetic_peer(MEDIAN_ISSUER_SEED),
1928            synthetic_voters(&[
1929                1,
1930                2,
1931                3,
1932                MEDIAN_ISSUER_SEED,
1933                FAR_SUPPORTING_VOTER_SEED,
1934                UNSUCCESSFUL_SUPPORTING_VOTER_SEED,
1935            ]),
1936        );
1937
1938        let quorum = witnessed_close_group_quorum();
1939        let selected =
1940            select_witnessed_median_voter_quotes(quotes, &address, &voters_by_peer, quorum)
1941                .expect("a supported close-group quote set should be selected");
1942
1943        assert_eq!(quote_peer_seeds(&selected), vec![1, 2, 3, 6, 7, 8, 9]);
1944        let (median_peer_id, _) =
1945            median_paid_quote_issuer(&selected).expect("selected quotes have a median");
1946        assert_eq!(median_peer_id, synthetic_peer(MEDIAN_ISSUER_SEED));
1947        assert!(voters_by_peer[&median_peer_id].len() >= quorum);
1948    }
1949
1950    #[test]
1951    fn witnessed_quote_selection_uses_direct_median_witness_recognition() {
1952        const MEDIAN_ISSUER_SEED: u8 = 7;
1953
1954        let address = [0u8; 32];
1955        let quotes = vec![
1956            synthetic_quote(1, 10),
1957            synthetic_quote(2, 20),
1958            synthetic_quote(3, 30),
1959            synthetic_quote(4, 50),
1960            synthetic_quote(MEDIAN_ISSUER_SEED, 40),
1961            synthetic_quote(8, 60),
1962            synthetic_quote(9, 70),
1963        ];
1964        let mut voters_by_peer = HashMap::new();
1965        voters_by_peer.insert(
1966            synthetic_peer(MEDIAN_ISSUER_SEED),
1967            synthetic_voters(&[20, 21, 22, 23, 24]),
1968        );
1969
1970        let quorum = witnessed_close_group_quorum();
1971        let selected =
1972            select_witnessed_median_voter_quotes(quotes, &address, &voters_by_peer, quorum)
1973                .expect("direct witness recognition should support the paid median issuer");
1974
1975        let (median_peer_id, _) =
1976            median_paid_quote_issuer(&selected).expect("selected quotes have a median");
1977        let selected_peers = selected
1978            .iter()
1979            .map(|(peer_id, _, _, _, _)| *peer_id)
1980            .collect::<HashSet<_>>();
1981        assert_eq!(median_peer_id, synthetic_peer(MEDIAN_ISSUER_SEED));
1982        assert_eq!(
1983            voters_by_peer[&median_peer_id]
1984                .intersection(&selected_peers)
1985                .count(),
1986            0,
1987            "recognising witnesses need not also be selected quote issuers"
1988        );
1989        assert_eq!(voters_by_peer[&median_peer_id].len(), quorum);
1990    }
1991
1992    #[test]
1993    fn witnessed_quote_selection_rejects_median_without_witness_quorum() {
1994        const MEDIAN_ISSUER_SEED: u8 = 7;
1995
1996        let address = [0u8; 32];
1997        let quotes = vec![
1998            synthetic_quote(1, 10),
1999            synthetic_quote(2, 20),
2000            synthetic_quote(3, 30),
2001            synthetic_quote(6, 50),
2002            synthetic_quote(MEDIAN_ISSUER_SEED, 40),
2003            synthetic_quote(8, 60),
2004            synthetic_quote(9, 70),
2005            synthetic_quote(10, 80),
2006        ];
2007        let mut voters_by_peer = HashMap::new();
2008        voters_by_peer.insert(
2009            synthetic_peer(MEDIAN_ISSUER_SEED),
2010            synthetic_voters(&[1, 2, 3, 20]),
2011        );
2012
2013        let selected = select_witnessed_median_voter_quotes(
2014            quotes,
2015            &address,
2016            &voters_by_peer,
2017            witnessed_close_group_quorum(),
2018        );
2019
2020        assert!(
2021            selected.is_none(),
2022            "the selector must not return a paid quote set when fewer than the \
2023             witnessed median voter quorum recognised the paid median issuer"
2024        );
2025    }
2026
2027    #[test]
2028    fn put_peers_prioritise_median_voters_without_reordering_quotes() {
2029        const MEDIAN_ISSUER_SEED: u8 = 7;
2030
2031        let quotes = vec![
2032            synthetic_quote(1, 10),
2033            synthetic_quote(2, 20),
2034            synthetic_quote(3, 30),
2035            synthetic_quote(4, 50),
2036            synthetic_quote(5, 60),
2037            synthetic_quote(6, 70),
2038            synthetic_quote(MEDIAN_ISSUER_SEED, 40),
2039        ];
2040        let mut voters_by_peer = HashMap::new();
2041        voters_by_peer.insert(
2042            synthetic_peer(MEDIAN_ISSUER_SEED),
2043            synthetic_voters(&[3, 4, 5, 6, MEDIAN_ISSUER_SEED]),
2044        );
2045
2046        let put_candidates = put_peers_from_seeds(&[1, 2, 3, 4, 5, 6, 7]);
2047        let put_peers = put_peers_with_median_voters_first(
2048            &quotes,
2049            &put_candidates,
2050            &voters_by_peer,
2051            witnessed_close_group_quorum(),
2052        )
2053        .expect("median voters should produce an ordered PUT set");
2054
2055        assert_eq!(quote_peer_seeds(&quotes), vec![1, 2, 3, 4, 5, 6, 7]);
2056        let (median_peer_id, _) =
2057            median_paid_quote_issuer(&quotes).expect("selected quotes have a median");
2058        assert_eq!(median_peer_id, synthetic_peer(MEDIAN_ISSUER_SEED));
2059        assert_eq!(put_peer_seeds(&put_peers), vec![3, 4, 5, 6, 7, 1, 2]);
2060    }
2061
2062    #[test]
2063    fn filter_drops_only_bad_bindings_and_leaves_storer_acceptable_quotes() {
2064        let mut quotes = vec![
2065            good_quote_real(),
2066            bad_quote_real(),
2067            good_quote_real(),
2068            bad_quote_real(),
2069            good_quote_real(),
2070        ];
2071
2072        let dropped = drop_quotes_with_bad_bindings(&mut quotes);
2073
2074        assert_eq!(dropped, 2, "two crossed-key quotes must be dropped");
2075        assert_eq!(quotes.len(), 3, "three real-key quotes must remain");
2076
2077        // Cross-checked invariant: every retained quote would be accepted by
2078        // a storer running the full spec. The defensive filter only checks
2079        // the binding, so this asserts the binding-only filter is correct
2080        // for binding-only failures (other failure modes are filtered by
2081        // the per-peer classifier upstream).
2082        for (peer_id, _, quote, _, _) in &quotes {
2083            assert!(
2084                storer_binding_would_accept(peer_id, quote),
2085                "every retained quote must satisfy the full storer-side spec"
2086            );
2087        }
2088    }
2089
2090    #[test]
2091    fn filter_is_noop_when_all_quotes_are_storer_acceptable() {
2092        let mut quotes: Vec<_> = (0..5).map(|_| good_quote_real()).collect();
2093        let before = quotes.len();
2094        let dropped = drop_quotes_with_bad_bindings(&mut quotes);
2095        assert_eq!(dropped, 0);
2096        assert_eq!(quotes.len(), before);
2097        for (peer_id, _, quote, _, _) in &quotes {
2098            assert!(storer_binding_would_accept(peer_id, quote));
2099        }
2100    }
2101
2102    #[test]
2103    fn filter_drops_all_when_every_responder_is_bad() {
2104        // The "all hostile" case: every peer returned a bad binding. The
2105        // patch should leave us with zero quotes (not panic, not skip the
2106        // filter, not return malformed quotes). The caller then surfaces
2107        // InsufficientPeers.
2108        let mut quotes: Vec<_> = (0..fault_tolerant_quote_query_count())
2109            .map(|_| bad_quote_real())
2110            .collect();
2111        let dropped = drop_quotes_with_bad_bindings(&mut quotes);
2112        assert_eq!(dropped, fault_tolerant_quote_query_count());
2113        assert!(quotes.is_empty());
2114    }
2115
2116    #[test]
2117    fn filter_preserves_quote_payload_byte_for_byte() {
2118        // After filtering, the retained quotes must be untouched — pub_key,
2119        // signature, content, timestamp, price, rewards_address. The patch
2120        // is a filter, not a transformation; this test catches any future
2121        // regression that mutates a retained quote.
2122        let (peer_id, addrs, original_quote, amount, commitment) = good_quote_real();
2123        let mut quotes = vec![(
2124            peer_id,
2125            addrs.clone(),
2126            original_quote.clone(),
2127            amount,
2128            commitment,
2129        )];
2130        let _ = drop_quotes_with_bad_bindings(&mut quotes);
2131
2132        let (kept_peer, kept_addrs, kept_quote, kept_amount, _kept_commitment) =
2133            quotes.pop().expect("the good quote must survive filtering");
2134        assert_eq!(kept_peer.as_bytes(), peer_id.as_bytes());
2135        assert_eq!(kept_addrs.len(), addrs.len());
2136        assert_eq!(kept_amount, amount);
2137        assert_eq!(kept_quote.pub_key, original_quote.pub_key);
2138        assert_eq!(kept_quote.signature, original_quote.signature);
2139        assert_eq!(kept_quote.content.0, original_quote.content.0);
2140        assert_eq!(kept_quote.timestamp, original_quote.timestamp);
2141        assert_eq!(kept_quote.price, original_quote.price);
2142        assert_eq!(kept_quote.rewards_address, original_quote.rewards_address);
2143    }
2144
2145    // ============================================================
2146    // The Apr 30 production-failure repro
2147    // ============================================================
2148
2149    /// Repro of the production failure from 2026-04-30 testnet runs.
2150    ///
2151    /// An external operator on `75.48.86.24` ran two co-located ant-node
2152    /// identities (peer `0755ecb55b…` and peer `073db92f…`) that crossed
2153    /// their quote-signing keys. Every chunk whose XOR-closest set happened
2154    /// to include peer `0755ecb5` got a payment proof with one malformed
2155    /// quote, and the storer's `validate_peer_bindings` rejected the
2156    /// entire close-group proof — burning the chunk's payment.
2157    ///
2158    /// This test proves the fault-tolerant quote path still fixes that failure
2159    /// shape:
2160    ///
2161    /// 1. We assemble `2x CLOSE_GROUP_SIZE` real ML-DSA-65 quotes — the same
2162    ///    buffer merkle preflight and merkle-mode estimates retain for probes.
2163    /// 2. One of them is a *crossed-key* quote — the production failure shape.
2164    /// 3. We run an independent `storer_would_accept` check (re-derived from
2165    ///    the storer spec, not from `quote_binding_is_valid`) over the
2166    ///    pre-filter set; we confirm the bad peer is rejected, proving the
2167    ///    storer **would** burn the chunk's payment if we proceeded unfiltered.
2168    /// 4. We run `drop_quotes_with_bad_bindings`.
2169    /// 5. We re-run `storer_would_accept` over the post-filter set; we confirm
2170    ///    EVERY remaining quote would be accepted, proving the filtered set
2171    ///    will not trigger the `validate_peer_bindings` rejection that caused
2172    ///    the Apr 30 outage.
2173    /// 6. We confirm the post-filter set has at least `CLOSE_GROUP_SIZE`
2174    ///    quotes — the over-query buffer (2x) is sufficient.
2175    #[test]
2176    fn repro_apr_30_storer_would_have_rejected_pre_filter_and_accepts_post_filter() {
2177        let over_query_count = fault_tolerant_quote_query_count();
2178        let mut quotes: Vec<_> = (0..over_query_count - 1)
2179            .map(|_| good_quote_real())
2180            .collect();
2181        // Splice the crossed-key quote in the middle (mirrors the random
2182        // position the bad peer takes in the DHT-returned closest set).
2183        quotes.insert(over_query_count / 2, bad_quote_real());
2184        assert_eq!(quotes.len(), over_query_count);
2185
2186        // Step 1: prove the storer would reject the pre-filter set.
2187        let storer_would_reject_count = quotes
2188            .iter()
2189            .filter(|(p, _, q, _, _)| !storer_binding_would_accept(p, q))
2190            .count();
2191        assert_eq!(
2192            storer_would_reject_count, 1,
2193            "exactly one quote (the crossed-key one) must be rejected by the storer spec"
2194        );
2195
2196        // Step 2: run the patched filter.
2197        let dropped = drop_quotes_with_bad_bindings(&mut quotes);
2198        assert_eq!(dropped, 1, "exactly the crossed-key quote must be filtered");
2199
2200        // Step 3: prove the storer would accept every survivor under the FULL spec.
2201        for (peer_id, _, quote, _, _) in &quotes {
2202            assert!(
2203                storer_binding_would_accept(peer_id, quote),
2204                "every post-filter quote must be accepted by the storer spec — \
2205                 this is what the filter guarantees before any quote set is used"
2206            );
2207        }
2208
2209        // Step 4: prove the over-query buffer is sufficient to refill.
2210        assert!(
2211            quotes.len() >= CLOSE_GROUP_SIZE,
2212            "after filtering, at least CLOSE_GROUP_SIZE good quotes must remain \
2213             so a fault-tolerant probe can still return a full close group"
2214        );
2215    }
2216
2217    /// When more than the over-query buffer of peers misbehave, the filter
2218    /// must NOT silently produce a short proof. The downstream caller in
2219    /// `get_store_quotes` must see fewer than `CLOSE_GROUP_SIZE` survivors
2220    /// and return `InsufficientPeers`.
2221    #[test]
2222    fn filter_leaves_short_set_when_too_many_bad_peers() {
2223        let good_count = CLOSE_GROUP_SIZE - 1;
2224        let bad_count = fault_tolerant_quote_query_count() - good_count;
2225        let mut quotes: Vec<_> = std::iter::repeat_with(bad_quote_real)
2226            .take(bad_count)
2227            .chain(std::iter::repeat_with(good_quote_real).take(good_count))
2228            .collect();
2229
2230        let dropped = drop_quotes_with_bad_bindings(&mut quotes);
2231        assert_eq!(dropped, bad_count);
2232        assert!(
2233            quotes.len() < CLOSE_GROUP_SIZE,
2234            "this is the precondition for InsufficientPeers downstream"
2235        );
2236        // Sanity: every survivor is storer-acceptable under the full spec.
2237        for (peer_id, _, quote, _, _) in &quotes {
2238            assert!(storer_binding_would_accept(peer_id, quote));
2239        }
2240    }
2241
2242    // ============================================================
2243    // Tests for the per-peer response classifier (the PRIMARY defense).
2244    //
2245    // These tests exercise the production code path that runs inside
2246    // get_store_quotes' per-peer async closure. The defensive
2247    // `drop_quotes_with_bad_bindings` is a second line of defence —
2248    // these tests make sure the FIRST line is what actually catches
2249    // misbehaving peers in production. Without these, a regression
2250    // that removes the per-peer check could be masked by the post-
2251    // collect filter and pass the rest of the suite.
2252    // ============================================================
2253
2254    /// Helper: serialize a `PaymentQuote` to bytes the way the wire layer
2255    /// does (rmp_serde / msgpack), to feed into `classify_quote_response`.
2256    fn serialize_quote(quote: &PaymentQuote) -> Vec<u8> {
2257        rmp_serde::to_vec(quote).expect("serialize quote")
2258    }
2259
2260    #[test]
2261    fn classifier_accepts_real_self_consistent_quote() {
2262        // A properly-signed baseline quote for the requested content passes the
2263        // full client gate (binding + content + signature + price).
2264        let content = [7u8; 32];
2265        let (peer_id, quote) = signed_baseline_quote(content);
2266        let bytes = serialize_quote(&quote);
2267        let result = classify_quote_response(&peer_id, &content, &bytes, false, None);
2268        match result {
2269            Ok((q, price, commitment)) => {
2270                assert_eq!(q.pub_key, quote.pub_key);
2271                assert_eq!(price, quote.price);
2272                assert!(commitment.is_none(), "baseline quote ships no commitment");
2273            }
2274            Err(e) => panic!("expected Ok, got {e}"),
2275        }
2276    }
2277
2278    #[test]
2279    fn classifier_rejects_quote_with_invalid_signature() {
2280        // A quote whose pub_key binds correctly but whose signature is bogus is
2281        // dropped BEFORE payment (the storer would reject it and burn the pay).
2282        let content = [7u8; 32];
2283        let (peer_id, mut quote) = signed_baseline_quote(content);
2284        quote.signature = vec![0u8; quote.signature.len()]; // corrupt the signature
2285        let bytes = serialize_quote(&quote);
2286        let result = classify_quote_response(&peer_id, &content, &bytes, false, None);
2287        assert!(
2288            matches!(result, Err(Error::BadQuoteBinding { .. })),
2289            "a quote with an invalid signature must be rejected; got {result:?}"
2290        );
2291    }
2292
2293    #[test]
2294    fn classifier_rejects_quote_for_wrong_content() {
2295        // A validly-signed quote for a DIFFERENT address is dropped before pay.
2296        let (peer_id, quote) = signed_baseline_quote([7u8; 32]);
2297        let bytes = serialize_quote(&quote);
2298        let result = classify_quote_response(&peer_id, &[9u8; 32], &bytes, false, None);
2299        assert!(
2300            matches!(result, Err(Error::BadQuoteBinding { .. })),
2301            "a quote for the wrong content must be rejected; got {result:?}"
2302        );
2303    }
2304
2305    #[test]
2306    fn classifier_rejects_crossed_keypair_with_typed_error() {
2307        let (peer_id, _, quote, _, _) = bad_quote_real();
2308        let bytes = serialize_quote(&quote);
2309        let result = classify_quote_response(&peer_id, &[0u8; 32], &bytes, false, None);
2310        match result {
2311            Err(Error::BadQuoteBinding {
2312                peer_id: pid,
2313                detail,
2314            }) => {
2315                assert_eq!(pid, peer_id.to_string());
2316                assert!(
2317                    detail.contains("BLAKE3(pub_key)="),
2318                    "diagnostic detail must include the derived peer id: {detail}"
2319                );
2320            }
2321            other => panic!("expected BadQuoteBinding for crossed-key quote, got {other:?}"),
2322        }
2323    }
2324
2325    /// CRITICAL: a misbehaving peer that votes `already_stored=true` must
2326    /// NOT be allowed to influence the close-group "already stored"
2327    /// majority decision. The bind-check runs before the AlreadyStored
2328    /// short-circuit, so a crossed-key peer voting "already stored" is
2329    /// classified as `BadQuoteBinding`, not `AlreadyStored`.
2330    ///
2331    /// This locks in a specific reviewer concern from round 1:
2332    ///   "A peer with a crossed/garbage signing key could simply respond
2333    ///   already_stored=true and its vote enters already_stored_peers
2334    ///   unfiltered."
2335    #[test]
2336    fn classifier_rejects_already_stored_vote_from_bad_binding_peer() {
2337        let (peer_id, _, quote, _, _) = bad_quote_real();
2338        let bytes = serialize_quote(&quote);
2339        // The peer claims already_stored=true, but its quote has a crossed key.
2340        let result = classify_quote_response(&peer_id, &[0u8; 32], &bytes, true, None);
2341        assert!(
2342            matches!(result, Err(Error::BadQuoteBinding { .. })),
2343            "crossed-key peer must be classified BadQuoteBinding even when \
2344             voting already_stored=true; got {result:?}"
2345        );
2346    }
2347
2348    /// An honest peer's `already_stored=true` vote IS honoured (after
2349    /// passing the bind-check). This is the contrast to the test above.
2350    #[test]
2351    fn classifier_honours_already_stored_vote_from_good_binding_peer() {
2352        let content = [7u8; 32];
2353        let (peer_id, quote) = signed_baseline_quote(content);
2354        let bytes = serialize_quote(&quote);
2355        let result = classify_quote_response(&peer_id, &content, &bytes, true, None);
2356        assert!(
2357            matches!(result, Err(Error::AlreadyStored)),
2358            "honest peer's already_stored vote must be honoured; got {result:?}"
2359        );
2360    }
2361
2362    #[test]
2363    fn classifier_returns_serialization_error_on_bad_bytes() {
2364        let (peer_id, _, _, _, _) = good_quote_real();
2365        let garbage = b"this is not a valid msgpack PaymentQuote".to_vec();
2366        let result = classify_quote_response(&peer_id, &[0u8; 32], &garbage, false, None);
2367        assert!(
2368            matches!(result, Err(Error::Serialization(_))),
2369            "garbage bytes must produce a Serialization error; got {result:?}"
2370        );
2371    }
2372
2373    /// Cross-validate the classifier's binding verdict against the
2374    /// independent storer-spec re-derivation across mixed responders.
2375    #[test]
2376    fn classifier_verdict_matches_storer_binding_spec_for_mixed_responders() {
2377        let content = [7u8; 32];
2378        let mut responders: Vec<(PeerId, PaymentQuote)> =
2379            (0..12).map(|_| signed_baseline_quote(content)).collect();
2380        for _ in 0..4 {
2381            let (p, _, q, _, _) = bad_quote_real();
2382            responders.push((p, q));
2383        }
2384
2385        for (peer_id, quote) in &responders {
2386            let bytes = serialize_quote(quote);
2387            let storer_verdict = storer_binding_would_accept(peer_id, quote);
2388            let classifier_verdict =
2389                classify_quote_response(peer_id, &content, &bytes, false, None).is_ok();
2390            assert_eq!(
2391                classifier_verdict, storer_verdict,
2392                "classifier and storer-binding-spec must agree on every responder \
2393                 (peer_id={}, storer={storer_verdict}, classifier={classifier_verdict})",
2394                peer_id
2395            );
2396        }
2397    }
2398
2399    // ============================================================
2400    // ADR-0004: quote_commitment_binding_is_valid (forced-price gate)
2401    //
2402    // Mirrors the storer-side `binding_violation` in
2403    // `ant-node/src/payment/verifier.rs`. The client runs this before
2404    // paying so it never pays a quote the storer's arithmetic gate would
2405    // reject. The client now runs the FULL check (shape, cap, exact price,
2406    // and for bound quotes: parse + peer-binding + signature + hash==pin +
2407    // count==key_count) using the shared ant-protocol commitment type, so an
2408    // unresolvable/forged commitment is never paid. A live resolve against a
2409    // REAL signed commitment is proven in the e2e suite (e2e_adr0004.rs).
2410    // ============================================================
2411
2412    /// A throwaway peer id for tests that fail BEFORE commitment resolution
2413    /// (shape/cap/price checks don't depend on the peer).
2414    fn any_peer() -> PeerId {
2415        PeerId::from_bytes([0u8; 32])
2416    }
2417
2418    /// Build a quote carrying a specific `(count, pin, price)` binding.
2419    fn quote_with_binding(
2420        committed_key_count: u32,
2421        commitment_pin: Option<[u8; 32]>,
2422        price: Amount,
2423    ) -> PaymentQuote {
2424        PaymentQuote {
2425            content: XorName([0u8; 32]),
2426            timestamp: SystemTime::UNIX_EPOCH,
2427            price,
2428            rewards_address: RewardsAddress::new([0u8; 20]),
2429            pub_key: Vec::new(),
2430            signature: Vec::new(),
2431            committed_key_count,
2432            commitment_pin,
2433        }
2434    }
2435
2436    /// Build a VALIDLY-SIGNED `StorageCommitment` bound to `kp`'s peer id, so a
2437    /// test can pass peer-binding + signature and isolate the `hash == pin` and
2438    /// `count == key_count` sub-checks. Mirrors ant-node's commitment signing:
2439    /// the canonical payload (`root || key_count(LE) || peer_id || pk_len(LE) ||
2440    /// pub_key`) signed under `DOMAIN_COMMITMENT`.
2441    fn signed_commitment(kp: &Keypair, root: [u8; 32], key_count: u32) -> StorageCommitment {
2442        use ant_protocol::payment::commitment::DOMAIN_COMMITMENT;
2443        use ant_protocol::pqc::api::{ml_dsa_65, MlDsaSecretKey as ApiSecretKey, MlDsaVariant};
2444        let peer = compute_address(&kp.pub_key_bytes);
2445        let mut payload = Vec::with_capacity(32 + 4 + 32 + 4 + kp.pub_key_bytes.len());
2446        payload.extend_from_slice(&root);
2447        payload.extend_from_slice(&key_count.to_le_bytes());
2448        payload.extend_from_slice(&peer);
2449        payload.extend_from_slice(&(kp.pub_key_bytes.len() as u32).to_le_bytes());
2450        payload.extend_from_slice(&kp.pub_key_bytes);
2451        let sk = ApiSecretKey::from_bytes(MlDsaVariant::MlDsa65, &kp.secret_key_bytes)
2452            .expect("api secret key");
2453        let signature = ml_dsa_65()
2454            .sign_with_context(&sk, &payload, DOMAIN_COMMITMENT)
2455            .expect("sign commitment")
2456            .to_bytes();
2457        StorageCommitment {
2458            root,
2459            key_count,
2460            sender_peer_id: peer,
2461            sender_public_key: kp.pub_key_bytes.clone(),
2462            signature,
2463        }
2464    }
2465
2466    #[test]
2467    fn binding_baseline_ok_only_at_baseline_price() {
2468        // (0, None) priced at calculate_price(0) is the valid baseline.
2469        let q = quote_with_binding(0, None, calculate_price(0));
2470        assert!(quote_commitment_binding_is_valid(&any_peer(), &q, &None).is_ok());
2471
2472        // (0, None) priced above baseline is rejected — the forged-shape
2473        // bypass (strip the pin, charge more than the empty-node price).
2474        let q = quote_with_binding(0, None, calculate_price(500));
2475        assert!(quote_commitment_binding_is_valid(&any_peer(), &q, &None).is_err());
2476    }
2477
2478    #[test]
2479    fn binding_rejects_incoherent_shapes() {
2480        // count > 0 but no pin: unauditable.
2481        let q = quote_with_binding(500, None, calculate_price(500));
2482        assert!(quote_commitment_binding_is_valid(&any_peer(), &q, &None).is_err());
2483        // count 0 but a pin: incoherent baseline.
2484        let q = quote_with_binding(0, Some([9u8; 32]), calculate_price(0));
2485        assert!(quote_commitment_binding_is_valid(&any_peer(), &q, &None).is_err());
2486    }
2487
2488    #[test]
2489    fn binding_rejects_count_above_cap() {
2490        let over = MAX_COMMITMENT_KEY_COUNT + 1;
2491        let q = quote_with_binding(over, Some([9u8; 32]), calculate_price(over as usize));
2492        assert!(
2493            quote_commitment_binding_is_valid(&any_peer(), &q, &Some(vec![1u8; 16])).is_err(),
2494            "a count above MAX_COMMITMENT_KEY_COUNT must be rejected before payment"
2495        );
2496    }
2497
2498    #[test]
2499    fn binding_rejects_on_curve_wrong_count() {
2500        // Priced for 499 but claims count 500 — on a real price curve but the
2501        // wrong count. Rejected at the exact-price check, before resolution.
2502        let q = quote_with_binding(500, Some([9u8; 32]), calculate_price(499));
2503        assert!(quote_commitment_binding_is_valid(&any_peer(), &q, &Some(vec![1u8; 16])).is_err());
2504    }
2505
2506    #[test]
2507    fn binding_rejects_bound_quote_without_shipped_commitment() {
2508        // A bound quote whose commitment did not arrive is unresolvable, so it
2509        // is dropped before payment even though its price is on the curve.
2510        let q = quote_with_binding(500, Some([9u8; 32]), calculate_price(500));
2511        assert!(
2512            quote_commitment_binding_is_valid(&any_peer(), &q, &None).is_err(),
2513            "a bound quote missing its commitment must be rejected"
2514        );
2515    }
2516
2517    #[test]
2518    fn binding_rejects_unparseable_and_peer_unbound_commitment() {
2519        // A bound quote whose shipped commitment is garbage (doesn't even
2520        // deserialize) is rejected — the client never pays an unresolvable pin.
2521        let q = quote_with_binding(500, Some([9u8; 32]), calculate_price(500));
2522        assert!(
2523            quote_commitment_binding_is_valid(&any_peer(), &q, &Some(vec![0xFF; 8])).is_err(),
2524            "an unparseable commitment must be rejected before payment"
2525        );
2526
2527        // A well-formed StorageCommitment that is NOT bound to the quoting peer
2528        // (its sender_peer_id / pubkey don't derive the peer id) is rejected at
2529        // the peer-binding check. The signature / hash==pin / count==key_count
2530        // sub-checks are covered by the dedicated tests below, which pass
2531        // peer-binding first so each isolates exactly one sub-check.
2532        let bogus = StorageCommitment {
2533            root: [1u8; 32],
2534            key_count: 500,
2535            sender_peer_id: [2u8; 32], // not the quoting peer
2536            sender_public_key: vec![3u8; 1952],
2537            signature: vec![4u8; 3293],
2538        };
2539        let blob = rmp_serde::to_vec(&bogus).expect("serialize bogus commitment");
2540        assert!(
2541            quote_commitment_binding_is_valid(&any_peer(), &q, &Some(blob)).is_err(),
2542            "a commitment not bound to the quoting peer must be rejected before payment"
2543        );
2544    }
2545
2546    #[test]
2547    fn binding_rejects_commitment_with_invalid_signature() {
2548        // Correctly-bound commitment (passes peer-binding) but with a corrupted
2549        // signature: must be rejected at the signature check. Deleting that check
2550        // would let a peer attest any (root, key_count) without holding the key.
2551        let kp = gen_keypair();
2552        let mut commitment = signed_commitment(&kp, [6u8; 32], 500);
2553        commitment.signature[0] ^= 0xFF; // still 3293 bytes, no longer valid
2554                                         // Pin the (corrupted) commitment so the hash==pin check would pass; the
2555                                         // only thing wrong is the signature, isolating that sub-check.
2556        let pin = commitment_hash(&commitment).expect("hash");
2557        let blob = rmp_serde::to_vec(&commitment).expect("serialize commitment");
2558        let q = quote_with_binding(500, Some(pin), calculate_price(500));
2559        let res = quote_commitment_binding_is_valid(&kp.peer_id, &q, &Some(blob));
2560        let err = res.expect_err("commitment with an invalid signature must be rejected");
2561        assert!(
2562            err.contains("signature"),
2563            "should fail at the signature check: {err}"
2564        );
2565    }
2566
2567    #[test]
2568    fn binding_rejects_commitment_that_does_not_hash_to_pin() {
2569        // Validly-signed, correctly-bound commitment, but the quote pins a
2570        // DIFFERENT hash: must be rejected. Deleting the hash==pin check would
2571        // let a peer ship any commitment it holds for a pin it doesn't back.
2572        let kp = gen_keypair();
2573        let commitment = signed_commitment(&kp, [5u8; 32], 500);
2574        let wrong_pin = [0xAB; 32];
2575        assert_ne!(commitment_hash(&commitment), Some(wrong_pin));
2576        let blob = rmp_serde::to_vec(&commitment).expect("serialize commitment");
2577        let q = quote_with_binding(500, Some(wrong_pin), calculate_price(500));
2578        let res = quote_commitment_binding_is_valid(&kp.peer_id, &q, &Some(blob));
2579        let err = res.expect_err("commitment that does not hash to the pin must be rejected");
2580        assert!(
2581            err.contains("hash"),
2582            "should fail at the hash==pin check: {err}"
2583        );
2584    }
2585
2586    #[test]
2587    fn binding_rejects_count_disagreeing_with_commitment() {
2588        // Validly-signed, correctly-bound, correctly-pinned commitment attesting
2589        // key_count=400, but the quote claims 500 (priced on-curve for 500):
2590        // must be rejected. Deleting the count==key_count check would let a peer
2591        // price against an inflated count while committing to fewer keys.
2592        let kp = gen_keypair();
2593        let commitment = signed_commitment(&kp, [7u8; 32], 400);
2594        let pin = commitment_hash(&commitment).expect("hash");
2595        let blob = rmp_serde::to_vec(&commitment).expect("serialize commitment");
2596        let q = quote_with_binding(500, Some(pin), calculate_price(500));
2597        let res = quote_commitment_binding_is_valid(&kp.peer_id, &q, &Some(blob));
2598        let err = res.expect_err("a quote count disagreeing with the commitment must be rejected");
2599        assert!(
2600            err.contains("key_count") || err.contains("attests"),
2601            "should fail at the count==key_count check: {err}"
2602        );
2603    }
2604
2605    #[test]
2606    fn binding_rejects_oversized_commitment_before_parsing() {
2607        // A bound quote shipping a blob larger than the sidecar cap is rejected
2608        // before any deserialize attempt (DoS guard on the hot path).
2609        let q = quote_with_binding(500, Some([9u8; 32]), calculate_price(500));
2610        let huge = Some(vec![0u8; MAX_COMMITMENT_SIDECAR_BYTES + 1]);
2611        assert!(
2612            quote_commitment_binding_is_valid(&any_peer(), &q, &huge).is_err(),
2613            "an oversized commitment blob must be rejected before payment"
2614        );
2615    }
2616
2617    #[test]
2618    fn classifier_drops_off_curve_quote_with_typed_error() {
2619        // End-to-end through the classifier: a VALIDLY-SIGNED, correctly-bound
2620        // quote for the right content, but with an off-curve price, is dropped
2621        // as BadQuoteCommitment (the forced-price extraction guard fires after
2622        // the quote's own signature/content checks pass).
2623        use ant_protocol::pqc::ops::MlDsaSecretKey;
2624        let content = [7u8; 32];
2625        let kp = gen_keypair();
2626        let mut quote = PaymentQuote {
2627            content: XorName(content),
2628            timestamp: SystemTime::UNIX_EPOCH,
2629            // claims baseline shape but charges a non-baseline price
2630            price: calculate_price(500),
2631            rewards_address: RewardsAddress::new([0u8; 20]),
2632            pub_key: kp.pub_key_bytes.clone(),
2633            signature: Vec::new(),
2634            committed_key_count: 0,
2635            commitment_pin: None,
2636        };
2637        let ml_dsa = MlDsa65::new();
2638        let sk = MlDsaSecretKey::from_bytes(&kp.secret_key_bytes).expect("sk");
2639        quote.signature = ml_dsa
2640            .sign(&sk, &quote.bytes_for_sig())
2641            .expect("sign")
2642            .as_bytes()
2643            .to_vec();
2644        let bytes = serialize_quote(&quote);
2645        let result = classify_quote_response(&kp.peer_id, &content, &bytes, false, None);
2646        assert!(
2647            matches!(result, Err(Error::BadQuoteCommitment { .. })),
2648            "off-curve quote must be dropped as BadQuoteCommitment; got {result:?}"
2649        );
2650    }
2651}