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/// Minimum quote count accepted by the single-node payment path.
46const SINGLE_NODE_MIN_QUOTE_COUNT: usize = 1;
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.is_empty() {
742        return None;
743    }
744
745    let median_quote_index = quotes.len() / 2;
746
747    let mut by_price: Vec<(usize, PeerId, Amount)> = quotes
748        .iter()
749        .enumerate()
750        .map(|(index, (peer_id, _, _, price, _))| (index, *peer_id, *price))
751        .collect();
752    by_price.sort_by_key(|(index, _, price)| (*price, *index));
753    by_price
754        .get(median_quote_index)
755        .map(|(_, peer_id, price)| (*peer_id, *price))
756}
757
758fn sort_quotes_by_distance(quotes: &mut [StoreQuote], address: &[u8; 32]) {
759    quotes.sort_by(|left, right| {
760        peer_xor_distance(&left.0, address)
761            .cmp(&peer_xor_distance(&right.0, address))
762            .then_with(|| left.0.as_bytes().cmp(right.0.as_bytes()))
763    });
764}
765
766fn median_paid_quote_issuer_for_indices(
767    quotes: &[StoreQuote],
768    indices: &[usize],
769) -> Option<(PeerId, Amount)> {
770    if indices.is_empty() {
771        return None;
772    }
773
774    let median_quote_index = indices.len() / 2;
775
776    let mut by_price: Vec<(usize, PeerId, Amount)> = indices
777        .iter()
778        .enumerate()
779        .map(|(selected_index, quote_index)| {
780            let (peer_id, _, _, price, _) = &quotes[*quote_index];
781            (selected_index, *peer_id, *price)
782        })
783        .collect();
784    by_price.sort_by_key(|(selected_index, _, price)| (*price, *selected_index));
785    by_price
786        .get(median_quote_index)
787        .map(|(_, peer_id, price)| (*peer_id, *price))
788}
789
790fn median_issuer_voter_support(
791    quotes: &[StoreQuote],
792    indices: &[usize],
793    voters_by_peer: &VotersByPeer,
794) -> Option<(PeerId, usize)> {
795    let (median_peer_id, _) = median_paid_quote_issuer_for_indices(quotes, indices)?;
796    let voters = voters_by_peer.get(&median_peer_id)?;
797    Some((median_peer_id, voters.len()))
798}
799
800fn visit_quote_subsets<F>(
801    quote_count: usize,
802    subset_size: usize,
803    start_index: usize,
804    current: &mut Vec<usize>,
805    visit: &mut F,
806) where
807    F: FnMut(&[usize]),
808{
809    if current.len() == subset_size {
810        visit(current);
811        return;
812    }
813
814    let remaining = subset_size - current.len();
815    let last_start = quote_count - remaining;
816    for index in start_index..=last_start {
817        current.push(index);
818        visit_quote_subsets(quote_count, subset_size, index + 1, current, visit);
819        current.pop();
820    }
821}
822
823fn select_closest_quotes(mut quotes: Vec<StoreQuote>, address: &[u8; 32]) -> Vec<StoreQuote> {
824    sort_quotes_by_distance(&mut quotes, address);
825    quotes.truncate(CLOSE_GROUP_SIZE);
826    quotes
827}
828
829fn select_witnessed_median_voter_quotes(
830    mut quotes: Vec<StoreQuote>,
831    address: &[u8; 32],
832    voters_by_peer: &VotersByPeer,
833    required_support: usize,
834) -> Option<Vec<StoreQuote>> {
835    if quotes.is_empty() {
836        return None;
837    }
838
839    sort_quotes_by_distance(&mut quotes, address);
840
841    let max_quote_count = single_node_quote_query_count().min(quotes.len());
842    for quote_count in (SINGLE_NODE_MIN_QUOTE_COUNT..=max_quote_count).rev() {
843        let mut best_indices: Option<(usize, Vec<usize>)> = None;
844        let mut current_indices = Vec::with_capacity(quote_count);
845        visit_quote_subsets(
846            quotes.len(),
847            quote_count,
848            0,
849            &mut current_indices,
850            &mut |indices| {
851                let Some((_, support)) =
852                    median_issuer_voter_support(&quotes, indices, voters_by_peer)
853                else {
854                    return;
855                };
856                if support < required_support {
857                    return;
858                }
859                match &best_indices {
860                    Some((best_support, best)) if *best_support > support => {}
861                    Some((best_support, best))
862                        if *best_support == support && best.as_slice() <= indices => {}
863                    _ => best_indices = Some((support, indices.to_vec())),
864                }
865            },
866        );
867
868        if let Some((_, indices)) = best_indices {
869            return Some(
870                indices
871                    .into_iter()
872                    .map(|index| quotes[index].clone())
873                    .collect(),
874            );
875        }
876    }
877
878    None
879}
880
881fn put_peers_with_median_voters_first(
882    quotes: &[StoreQuote],
883    put_peers: &[(PeerId, Vec<MultiAddr>)],
884    voters_by_peer: &VotersByPeer,
885    required_support: usize,
886) -> Option<Vec<(PeerId, Vec<MultiAddr>)>> {
887    let (median_peer_id, _) = median_paid_quote_issuer(quotes)?;
888    let voters = voters_by_peer.get(&median_peer_id)?;
889
890    let mut supporting_peers = Vec::new();
891    let mut fallback_peers = Vec::new();
892    for (peer_id, addrs) in put_peers {
893        let peer = (*peer_id, addrs.clone());
894        if voters.contains(peer_id) {
895            supporting_peers.push(peer);
896        } else {
897            fallback_peers.push(peer);
898        }
899    }
900
901    if supporting_peers.len() < required_support {
902        return None;
903    }
904
905    supporting_peers.extend(fallback_peers);
906    Some(supporting_peers)
907}
908
909impl Client {
910    /// Get storage quotes from the closest peers for a given address.
911    ///
912    /// Builds a quorum-witnessed candidate set, still attempts to collect the
913    /// close-group quote count, and returns the largest supported successful
914    /// quote set. The single-node path now only requires one valid quote to
915    /// proceed, but still pays the median quote from the selected set when more
916    /// quotes were successfully fetched.
917    ///
918    /// Returns `Error::AlreadyStored` early if `CLOSE_GROUP_MAJORITY` peers
919    /// report the chunk is already stored.
920    ///
921    /// # Errors
922    ///
923    /// Returns an error if insufficient quotes can be collected.
924    pub async fn get_store_quotes(
925        &self,
926        address: &[u8; 32],
927        data_size: u64,
928        data_type: u32,
929    ) -> Result<Vec<StoreQuote>> {
930        Ok(self
931            .get_store_quote_plan(address, data_size, data_type)
932            .await?
933            .quotes)
934    }
935
936    /// Get storage quotes plus PUT targets ordered for paid-median acceptance.
937    ///
938    /// Quote order is preserved for proof construction because tied quote
939    /// prices rely on stable median selection. PUT target order is separate:
940    /// peers that voted for the paid median issuer are placed first so the
941    /// initial write wave is locally acceptable to a storage majority.
942    pub(crate) async fn get_store_quote_plan(
943        &self,
944        address: &[u8; 32],
945        data_size: u64,
946        data_type: u32,
947    ) -> Result<StoreQuotePlan> {
948        let witnessed_selection = self.select_witnessed_quote_selection(address).await?;
949        let voters_by_peer: VotersByPeer = witnessed_selection
950            .quote_peers
951            .iter()
952            .map(|peer| (peer.peer_id, peer.voters.clone()))
953            .collect();
954        let remote_peers = witnessed_selection
955            .quote_peers
956            .into_iter()
957            .map(|peer| (peer.peer_id, peer.addrs))
958            .collect();
959        let initial_put_peers = witnessed_selection.initial_put_peers;
960        let quorum = witnessed_selection.quorum;
961        let quotes = self
962            .collect_store_quotes_from_remote_peers(
963                address,
964                data_size,
965                data_type,
966                remote_peers,
967                QuoteSelectionPolicy::WitnessedMedianVoters {
968                    voters_by_peer: voters_by_peer.clone(),
969                    quorum,
970                },
971            )
972            .await?;
973        let put_peers = put_peers_with_median_voters_first(
974            &quotes,
975            &initial_put_peers,
976            &voters_by_peer,
977            quorum,
978        )
979        .ok_or_else(|| {
980            Error::InsufficientPeers(format!(
981                "Collected {} witnessed quotes, but fewer than {} initial witness PUT peers \
982                 voted for the paid median issuer for {}",
983                quotes.len(),
984                quorum,
985                hex::encode(address)
986            ))
987        })?;
988
989        Ok(StoreQuotePlan { quotes, put_peers })
990    }
991
992    /// Get storage quotes with the previous over-query behaviour.
993    ///
994    /// Merkle preflight uses quote responses only as an already-stored probe;
995    /// the actual payment still happens through merkle candidate pools. Keep
996    /// the extra peer buffer there so merkle upload behaviour remains
997    /// unchanged when a few peers are slow or return unusable quote bindings.
998    pub(crate) async fn get_store_quotes_with_fault_tolerance(
999        &self,
1000        address: &[u8; 32],
1001        data_size: u64,
1002        data_type: u32,
1003    ) -> Result<Vec<StoreQuote>> {
1004        let peer_query_count = fault_tolerant_quote_query_count();
1005        let remote_peers = self
1006            .network()
1007            .find_closest_peers(address, peer_query_count)
1008            .await?;
1009
1010        self.collect_store_quotes_from_remote_peers(
1011            address,
1012            data_size,
1013            data_type,
1014            remote_peers,
1015            QuoteSelectionPolicy::ClosestByDistance,
1016        )
1017        .await
1018    }
1019
1020    async fn select_witnessed_quote_selection(
1021        &self,
1022        address: &[u8; 32],
1023    ) -> Result<WitnessedQuoteSelection> {
1024        // Query the close-group width, but single-node payment now only needs
1025        // one valid witnessed quote to proceed.
1026        let close_group_query_count = single_node_quote_query_count();
1027        let required_quotes = SINGLE_NODE_MIN_QUOTE_COUNT;
1028        // Contact the closest PUT_TARGET_WIDTH peers directly so the whole
1029        // PUT-target set's addresses arrive in this single query. A network
1030        // with fewer than that near the target can't satisfy the wide lookup,
1031        // so fall back to the close-group width — the upload still proceeds with
1032        // a narrower (but valid) PUT-target set rather than failing.
1033        let witnessed = match self
1034            .network()
1035            .find_witnessed_close_group_with_view_count(
1036                address,
1037                PUT_TARGET_WIDTH,
1038                SINGLE_NODE_WITNESSED_VIEW_COUNT,
1039            )
1040            .await
1041        {
1042            Ok(witnessed) => witnessed,
1043            Err(wide_err) => {
1044                debug!(
1045                    target = %hex::encode(address),
1046                    "Wide witnessed lookup ({PUT_TARGET_WIDTH}) failed ({wide_err}); \
1047                     retrying at close-group width ({close_group_query_count})"
1048                );
1049                self.network()
1050                    .find_witnessed_close_group_with_view_count(
1051                        address,
1052                        close_group_query_count,
1053                        SINGLE_NODE_WITNESSED_VIEW_COUNT,
1054                    )
1055                    .await
1056                    .map_err(|e| {
1057                        Error::InsufficientPeers(format!(
1058                            "Witnessed close group lookup failed before payment for target {}: {e}",
1059                            hex::encode(address)
1060                        ))
1061                    })?
1062            }
1063        };
1064        // Run quoting/quorum on the closest CLOSE_GROUP_SIZE only, so payment
1065        // semantics are unaffected by the wider PUT query.
1066        let witnessed_quote = scope_witnessed_to_close_group(&witnessed);
1067        let base_quorum = witnessed_close_group_quorum();
1068        let missing_views = missing_witnessed_responder_views(&witnessed_quote);
1069        let quorum = witnessed_close_group_quorum_for_transcript(&witnessed_quote);
1070
1071        if missing_views > 0 {
1072            warn!(
1073                target = %hex::encode(address),
1074                initial = witnessed_quote.initial_closest.len(),
1075                responder_views = witnessed_quote.responder_views.len(),
1076                missing_views = missing_views,
1077                base_quorum = base_quorum,
1078                adjusted_quorum = quorum,
1079                "Witnessed close group transcript is missing responder views; lowering SNP witness quorum"
1080            );
1081        }
1082
1083        debug!(
1084            target = %hex::encode(address),
1085            quorum = quorum,
1086            view_count = SINGLE_NODE_WITNESSED_VIEW_COUNT,
1087            initial = ?witnessed_initial_peers(&witnessed_quote),
1088            responder_views = ?witnessed_responder_views(&witnessed_quote),
1089            vote_counts = ?witnessed_vote_counts(&witnessed_quote, address),
1090            final_witnessed_set = ?witnessed_consensus(&witnessed_quote, address, quorum),
1091            "Witnessed close group selected for SNP quote collection"
1092        );
1093
1094        let mut selection =
1095            witnessed_quote_selection_or_error(address, &witnessed_quote, required_quotes, quorum)?;
1096        // Widen the PUT-target set to the closest PUT_TARGET_WIDTH
1097        // directly-contacted peers; the quote set above stays the closest
1098        // CLOSE_GROUP_SIZE. The same proof is reused on all of them.
1099        selection.initial_put_peers = witnessed
1100            .initial_closest
1101            .iter()
1102            .take(PUT_TARGET_WIDTH)
1103            .map(|node| (node.peer_id, node.addresses_by_priority()))
1104            .collect();
1105        Ok(selection)
1106    }
1107
1108    #[allow(clippy::too_many_lines)]
1109    async fn collect_store_quotes_from_remote_peers(
1110        &self,
1111        address: &[u8; 32],
1112        data_size: u64,
1113        data_type: u32,
1114        remote_peers: Vec<(PeerId, Vec<MultiAddr>)>,
1115        quote_selection_policy: QuoteSelectionPolicy,
1116    ) -> Result<Vec<StoreQuote>> {
1117        let peer_query_count = remote_peers.len();
1118
1119        let node = self.network().node();
1120
1121        debug!(
1122            "Requesting quotes from up to {peer_query_count} peers for address {} (size: {data_size})",
1123            hex::encode(address)
1124        );
1125
1126        let (min_quote_count, target_quote_count, staged_witnessed_collection) =
1127            match &quote_selection_policy {
1128                QuoteSelectionPolicy::ClosestByDistance => {
1129                    (CLOSE_GROUP_SIZE, CLOSE_GROUP_SIZE, false)
1130                }
1131                QuoteSelectionPolicy::WitnessedMedianVoters { .. } => (
1132                    SINGLE_NODE_MIN_QUOTE_COUNT,
1133                    single_node_quote_query_count(),
1134                    true,
1135                ),
1136            };
1137        let target_quote_count = target_quote_count.min(peer_query_count);
1138
1139        if remote_peers.len() < min_quote_count {
1140            return Err(Error::InsufficientPeers(format!(
1141                "Found {} peers, need {min_quote_count}",
1142                remote_peers.len(),
1143            )));
1144        }
1145        debug_assert!(peer_query_count >= min_quote_count);
1146
1147        let per_peer_timeout = Duration::from_secs(self.config().quote_timeout_secs);
1148        let overall_timeout = Duration::from_secs(QUOTE_COLLECTION_TIMEOUT_SECS);
1149
1150        // Collect quote responses. SNP/witnessed collection deliberately tries
1151        // the closest witnessed peers first and only falls back to further
1152        // witnessed peers when a closer peer fails to produce a usable quote.
1153        let mut quotes = Vec::with_capacity(peer_query_count);
1154        let mut already_stored_peers: Vec<(PeerId, [u8; 32])> = Vec::new();
1155        let mut failures: Vec<String> = Vec::new();
1156
1157        // Track storer-rejecting peers separately (binding, content, signature
1158        // failures) so we can surface their count in diagnostics — they're a
1159        // special class of failure (peer misconfigured or hostile, not
1160        // network-broken) and the user benefits from seeing them called out.
1161        let mut bad_quote_count = 0usize;
1162
1163        if staged_witnessed_collection {
1164            let mut quote_futures = FuturesUnordered::new();
1165            let mut next_peer_index = 0usize;
1166            let collect_result: std::result::Result<std::result::Result<(), Error>, _> =
1167                tokio::time::timeout(overall_timeout, async {
1168                    loop {
1169                        let launch_count = witnessed_quote_launch_budget(
1170                            quotes.len(),
1171                            quote_futures.len(),
1172                            remote_peers.len().saturating_sub(next_peer_index),
1173                        );
1174                        for _ in 0..launch_count {
1175                            let (peer_id, peer_addrs) = &remote_peers[next_peer_index];
1176                            next_peer_index += 1;
1177                            quote_futures.push(request_store_quote_from_peer(
1178                                node.clone(),
1179                                *peer_id,
1180                                peer_addrs.clone(),
1181                                self.next_request_id(),
1182                                *address,
1183                                data_size,
1184                                data_type,
1185                                per_peer_timeout,
1186                            ));
1187                        }
1188
1189                        if quotes.len() >= target_quote_count || quote_futures.is_empty() {
1190                            break;
1191                        }
1192
1193                        let Some((peer_id, addrs, quote_result)) = quote_futures.next().await
1194                        else {
1195                            break;
1196                        };
1197                        record_store_quote_result(
1198                            peer_id,
1199                            addrs,
1200                            quote_result,
1201                            address,
1202                            &mut quotes,
1203                            &mut already_stored_peers,
1204                            &mut failures,
1205                            &mut bad_quote_count,
1206                        );
1207                    }
1208                    Ok(())
1209                })
1210                .await;
1211
1212            match collect_result {
1213                Err(_elapsed) => {
1214                    warn!(
1215                        "Quote collection timed out after {overall_timeout:?} for address {}",
1216                        hex::encode(address)
1217                    );
1218                }
1219                Ok(Err(e)) => return Err(e),
1220                Ok(Ok(())) => {}
1221            }
1222        } else {
1223            // Merkle preflight keeps the previous behaviour: query the full
1224            // over-query set concurrently because those quote responses are
1225            // only used as an already-stored probe.
1226            let mut quote_futures = FuturesUnordered::new();
1227
1228            for (peer_id, peer_addrs) in &remote_peers {
1229                quote_futures.push(request_store_quote_from_peer(
1230                    node.clone(),
1231                    *peer_id,
1232                    peer_addrs.clone(),
1233                    self.next_request_id(),
1234                    *address,
1235                    data_size,
1236                    data_type,
1237                    per_peer_timeout,
1238                ));
1239            }
1240
1241            let collect_result: std::result::Result<std::result::Result<(), Error>, _> =
1242                tokio::time::timeout(overall_timeout, async {
1243                    while let Some((peer_id, addrs, quote_result)) = quote_futures.next().await {
1244                        record_store_quote_result(
1245                            peer_id,
1246                            addrs,
1247                            quote_result,
1248                            address,
1249                            &mut quotes,
1250                            &mut already_stored_peers,
1251                            &mut failures,
1252                            &mut bad_quote_count,
1253                        );
1254                    }
1255                    Ok(())
1256                })
1257                .await;
1258
1259            match collect_result {
1260                Err(_elapsed) => {
1261                    warn!(
1262                        "Quote collection timed out after {overall_timeout:?} for address {}",
1263                        hex::encode(address)
1264                    );
1265                    // Fall through to check if we have enough quotes despite timeout.
1266                    // The timeout fires when slow peers haven't responded yet, but we
1267                    // may already have enough successful quotes from fast peers.
1268                }
1269                Ok(Err(e)) => return Err(e),
1270                Ok(Ok(())) => {}
1271            }
1272        }
1273
1274        // Defensive double-check: the per-peer handler already filters
1275        // bad-binding responses into `failures`, but if any path slipped a bad
1276        // quote into `quotes` (e.g. a future refactor) this catches it before
1277        // we sort by distance and return. `bad_dropped` should be 0 in normal
1278        // operation; non-zero indicates an upstream regression worth investigating.
1279        let bad_dropped = drop_quotes_with_bad_bindings(&mut quotes);
1280        if bad_dropped > 0 {
1281            warn!(
1282                "Defensive filter dropped {bad_dropped} quotes with mismatched peer bindings \
1283                 for address {} — the per-peer handler should have caught these earlier \
1284                 (this indicates an upstream regression)",
1285                hex::encode(address),
1286            );
1287            bad_quote_count += bad_dropped;
1288        }
1289
1290        // Check already-stored: only count votes from the closest CLOSE_GROUP_SIZE peers.
1291        if !already_stored_peers.is_empty() {
1292            let mut all_peers_by_distance: Vec<(bool, [u8; 32])> = Vec::new();
1293            for (peer_id, _, _, _, _) in &quotes {
1294                all_peers_by_distance.push((false, peer_xor_distance(peer_id, address)));
1295            }
1296            for (_, dist) in &already_stored_peers {
1297                all_peers_by_distance.push((true, *dist));
1298            }
1299            all_peers_by_distance.sort_by_key(|a| a.1);
1300
1301            let close_group_stored = all_peers_by_distance
1302                .iter()
1303                .take(CLOSE_GROUP_SIZE)
1304                .filter(|(is_stored, _)| *is_stored)
1305                .count();
1306
1307            if close_group_stored >= CLOSE_GROUP_MAJORITY {
1308                debug!(
1309                    "Chunk {} already stored ({close_group_stored}/{CLOSE_GROUP_SIZE} close-group peers confirm)",
1310                    hex::encode(address)
1311                );
1312                return Err(Error::AlreadyStored);
1313            }
1314        }
1315
1316        let already_stored_count = already_stored_peers.len();
1317        let failure_count = failures.len();
1318        let quote_count = quotes.len();
1319        let total_responses = quote_count + failure_count + already_stored_count;
1320
1321        if quotes.len() >= min_quote_count {
1322            let selected_quotes = match quote_selection_policy {
1323                QuoteSelectionPolicy::ClosestByDistance => select_closest_quotes(quotes, address),
1324                QuoteSelectionPolicy::WitnessedMedianVoters {
1325                    voters_by_peer,
1326                    quorum,
1327                } => select_witnessed_median_voter_quotes(quotes, address, &voters_by_peer, quorum)
1328                    .ok_or_else(|| {
1329                        Error::InsufficientPeers(format!(
1330                            "Got {quote_count} quotes, need at least {min_quote_count} whose paid \
1331                                 median issuer is recognised by at least {} \
1332                                 selected witness peers ({total_responses} responses: \
1333                                 {already_stored_count} already_stored, {failure_count} failed \
1334                                 including {bad_quote_count} with mismatched peer bindings). \
1335                                 Failures: [{}]",
1336                            quorum,
1337                            failures.join("; ")
1338                        ))
1339                    })?,
1340            };
1341
1342            info!(
1343                "Collected {} quotes for address {} ({total_responses} responses: \
1344                 {quote_count} ok, {already_stored_count} already_stored, {failure_count} failed, \
1345                 {bad_quote_count} bad-binding)",
1346                selected_quotes.len(),
1347                hex::encode(address),
1348            );
1349            return Ok(selected_quotes);
1350        }
1351
1352        Err(Error::InsufficientPeers(format!(
1353            "Got {quote_count} quotes, need {min_quote_count} ({total_responses} responses: \
1354             {already_stored_count} already_stored, {failure_count} failed including \
1355             {bad_quote_count} with mismatched peer bindings). Failures: [{}]",
1356            failures.join("; ")
1357        )))
1358    }
1359}
1360
1361#[cfg(test)]
1362#[allow(clippy::unwrap_used, clippy::expect_used)]
1363mod tests {
1364    //! Test fixtures use real ML-DSA-65 keypairs (1952-byte public keys), the
1365    //! same key material that ships on the wire. The "bad" quote is built by
1366    //! **swapping** the public key field with a different real keypair's
1367    //! public key — the exact shape produced by the Apr 30 production
1368    //! failure (an operator running two co-located identities with crossed
1369    //! quote-signing keys). Signatures are not exercised here because this
1370    //! filter only mirrors `validate_peer_bindings` (BLAKE3 binding); see
1371    //! the doc-comment on `quote_binding_is_valid` for why
1372    //! `verify_quote_signature` and `verify_quote_content` are deliberately
1373    //! NOT mirrored.
1374
1375    use super::*;
1376    use ant_protocol::evm::RewardsAddress;
1377    use ant_protocol::pqc::ops::{MlDsaOperations, MlDsaPublicKey};
1378    use ant_protocol::transport::{DHTNode, MlDsa65, ResponderView, WitnessedCloseGroup};
1379    use std::time::SystemTime;
1380    use xor_name::XorName;
1381
1382    /// A real ML-DSA-65 keypair plus its derived peer ID.
1383    struct Keypair {
1384        peer_id: PeerId,
1385        pub_key_bytes: Vec<u8>,
1386        secret_key_bytes: Vec<u8>,
1387    }
1388
1389    fn gen_keypair() -> Keypair {
1390        let ml_dsa = MlDsa65::new();
1391        let (pub_key, sk) = ml_dsa.generate_keypair().expect("ML-DSA-65 keygen");
1392        let pub_key_bytes = pub_key.as_bytes().to_vec();
1393        let peer_id = PeerId::from_bytes(compute_address(&pub_key_bytes));
1394        Keypair {
1395            peer_id,
1396            pub_key_bytes,
1397            secret_key_bytes: sk.as_bytes().to_vec(),
1398        }
1399    }
1400
1401    /// Build a PROPERLY-SIGNED baseline quote for `content`, signed by a real
1402    /// ML-DSA-65 key whose `BLAKE3(pub_key)` is the returned peer id. Passes the
1403    /// client's full classifier gate (binding + content + signature + price).
1404    fn signed_baseline_quote(content: [u8; 32]) -> (PeerId, PaymentQuote) {
1405        use ant_protocol::pqc::ops::MlDsaSecretKey;
1406        let kp = gen_keypair();
1407        let mut quote = PaymentQuote {
1408            content: XorName(content),
1409            timestamp: SystemTime::UNIX_EPOCH,
1410            price: calculate_price(0),
1411            rewards_address: RewardsAddress::new([0u8; 20]),
1412            pub_key: kp.pub_key_bytes.clone(),
1413            signature: Vec::new(),
1414            committed_key_count: 0,
1415            commitment_pin: None,
1416        };
1417        let ml_dsa = MlDsa65::new();
1418        let sk = MlDsaSecretKey::from_bytes(&kp.secret_key_bytes).expect("sk");
1419        let msg = quote.bytes_for_sig();
1420        quote.signature = ml_dsa.sign(&sk, &msg).expect("sign").as_bytes().to_vec();
1421        (kp.peer_id, quote)
1422    }
1423
1424    /// Build a quote tuple whose `pub_key` correctly hashes to its peer_id.
1425    /// Signature is left empty: this filter does not verify signatures.
1426    ///
1427    /// The quote is a valid ADR-0004 **baseline**: `(0, None)` priced at
1428    /// `calculate_price(0)`, so it passes the forced-price gate in
1429    /// `classify_quote_response`. The 5th tuple element is the (absent)
1430    /// commitment sidecar.
1431    fn good_quote_real() -> QuotedPeer {
1432        let kp = gen_keypair();
1433        let quote = PaymentQuote {
1434            content: XorName([0u8; 32]),
1435            timestamp: SystemTime::UNIX_EPOCH,
1436            price: calculate_price(0),
1437            rewards_address: RewardsAddress::new([0u8; 20]),
1438            pub_key: kp.pub_key_bytes,
1439            signature: Vec::new(),
1440            committed_key_count: 0,
1441            commitment_pin: None,
1442        };
1443        (kp.peer_id, Vec::new(), quote, calculate_price(0), None)
1444    }
1445
1446    /// Build a quote tuple where the quote carries a different keypair's
1447    /// `pub_key` than the peer_id derives from. Mirrors the production
1448    /// failure shape: peer A advertised on the transport, but the quote
1449    /// carries peer B's key.
1450    fn bad_quote_real() -> QuotedPeer {
1451        let claimed = gen_keypair();
1452        let signing = gen_keypair();
1453        assert_ne!(claimed.pub_key_bytes, signing.pub_key_bytes);
1454        assert_ne!(claimed.peer_id.as_bytes(), signing.peer_id.as_bytes());
1455        let quote = PaymentQuote {
1456            content: XorName([0u8; 32]),
1457            timestamp: SystemTime::UNIX_EPOCH,
1458            price: calculate_price(0),
1459            rewards_address: RewardsAddress::new([0u8; 20]),
1460            pub_key: signing.pub_key_bytes,
1461            signature: Vec::new(),
1462            committed_key_count: 0,
1463            commitment_pin: None,
1464        };
1465        (claimed.peer_id, Vec::new(), quote, calculate_price(0), None)
1466    }
1467
1468    fn witnessed_test_node(seed: u8) -> DHTNode {
1469        DHTNode {
1470            peer_id: PeerId::from_bytes([seed; 32]),
1471            addresses: Vec::new(),
1472            address_types: Vec::new(),
1473            distance: None,
1474            reliability: 1.0,
1475        }
1476    }
1477
1478    fn witnessed_test_nodes(seeds: &[u8]) -> Vec<DHTNode> {
1479        seeds.iter().copied().map(witnessed_test_node).collect()
1480    }
1481
1482    fn witnessed_test_view(responder: u8, closest: &[u8]) -> ResponderView {
1483        ResponderView {
1484            responder: PeerId::from_bytes([responder; 32]),
1485            closest: witnessed_test_nodes(closest),
1486        }
1487    }
1488
1489    fn synthetic_peer(seed: u8) -> PeerId {
1490        PeerId::from_bytes([seed; 32])
1491    }
1492
1493    fn synthetic_quote(
1494        seed: u8,
1495        price: u64,
1496    ) -> (
1497        PeerId,
1498        Vec<MultiAddr>,
1499        PaymentQuote,
1500        Amount,
1501        Option<Vec<u8>>,
1502    ) {
1503        let amount = Amount::from(price);
1504        let quote = PaymentQuote {
1505            content: XorName([0u8; 32]),
1506            timestamp: SystemTime::UNIX_EPOCH,
1507            price: amount,
1508            rewards_address: RewardsAddress::new([0u8; 20]),
1509            pub_key: Vec::new(),
1510            signature: Vec::new(),
1511            committed_key_count: 0,
1512            commitment_pin: None,
1513        };
1514        (synthetic_peer(seed), Vec::new(), quote, amount, None)
1515    }
1516
1517    fn synthetic_voters(seeds: &[u8]) -> HashSet<PeerId> {
1518        seeds.iter().copied().map(synthetic_peer).collect()
1519    }
1520
1521    fn quote_peer_seeds(quotes: &[StoreQuote]) -> Vec<u8> {
1522        quotes
1523            .iter()
1524            .map(|(peer_id, _, _, _, _)| peer_id.as_bytes()[0])
1525            .collect()
1526    }
1527
1528    fn put_peer_seeds(peers: &[(PeerId, Vec<MultiAddr>)]) -> Vec<u8> {
1529        peers
1530            .iter()
1531            .map(|(peer_id, _)| peer_id.as_bytes()[0])
1532            .collect()
1533    }
1534
1535    fn put_peers_from_seeds(seeds: &[u8]) -> Vec<(PeerId, Vec<MultiAddr>)> {
1536        seeds
1537            .iter()
1538            .copied()
1539            .map(|seed| (synthetic_peer(seed), Vec::new()))
1540            .collect()
1541    }
1542
1543    /// Independent re-implementation of the storer-side binding spec
1544    /// (`ant-node/src/payment/verifier.rs::validate_peer_bindings` +
1545    /// `peer_id_from_public_key_bytes`):
1546    /// (a) `pub_key` parses as ML-DSA-65 (length 1952), and
1547    /// (b) `BLAKE3(pub_key) == peer_id`.
1548    ///
1549    /// Re-derived from spec, NOT delegating to `quote_binding_is_valid`,
1550    /// so cross-checks are not "function == itself".
1551    fn storer_binding_would_accept(peer_id: &PeerId, quote: &PaymentQuote) -> bool {
1552        if MlDsaPublicKey::from_bytes(&quote.pub_key).is_err() {
1553            return false;
1554        }
1555        compute_address(&quote.pub_key) == *peer_id.as_bytes()
1556    }
1557
1558    // ============================================================
1559    // Tests for `quote_binding_is_valid` (the predicate)
1560    // ============================================================
1561
1562    #[test]
1563    fn binding_accepts_real_self_consistent_keypair() {
1564        let (peer_id, _, quote, _, _) = good_quote_real();
1565        // Property under test: the predicate accepts a quote whose pub_key
1566        // genuinely belongs to the claimed peer.
1567        assert!(quote_binding_is_valid(&peer_id, &quote));
1568        // Cross-check against the independent full storer-spec implementation.
1569        assert!(storer_binding_would_accept(&peer_id, &quote));
1570    }
1571
1572    #[test]
1573    fn binding_rejects_real_crossed_keypair() {
1574        let (peer_id, _, quote, _, _) = bad_quote_real();
1575        assert!(!quote_binding_is_valid(&peer_id, &quote));
1576        assert!(!storer_binding_would_accept(&peer_id, &quote));
1577    }
1578
1579    #[test]
1580    fn binding_rejects_oversize_pubkey() {
1581        // A pub_key longer than ML-DSA-65 (1952 bytes) must be rejected
1582        // even if BLAKE3 happens to agree, because the storer rejects on
1583        // length first via `peer_id_from_public_key_bytes`.
1584        let oversized = vec![0u8; ML_DSA_PUB_KEY_LEN + 1];
1585        let peer_id = PeerId::from_bytes(compute_address(&oversized));
1586        let quote = PaymentQuote {
1587            content: XorName([0u8; 32]),
1588            timestamp: SystemTime::UNIX_EPOCH,
1589            price: Amount::ZERO,
1590            rewards_address: RewardsAddress::new([0u8; 20]),
1591            pub_key: oversized,
1592            signature: Vec::new(),
1593            committed_key_count: 0,
1594            commitment_pin: None,
1595        };
1596        // BLAKE3(pub_key) DOES equal the peer_id we constructed, so the
1597        // bare hash check would pass — but the length guard must reject.
1598        assert_eq!(compute_address(&quote.pub_key), *peer_id.as_bytes());
1599        assert!(
1600            !quote_binding_is_valid(&peer_id, &quote),
1601            "predicate must reject oversize pub_key even when BLAKE3 happens to match"
1602        );
1603        assert!(!storer_binding_would_accept(&peer_id, &quote));
1604    }
1605
1606    #[test]
1607    fn binding_rejects_undersize_pubkey() {
1608        let undersized = vec![0u8; ML_DSA_PUB_KEY_LEN - 1];
1609        let peer_id = PeerId::from_bytes(compute_address(&undersized));
1610        let quote = PaymentQuote {
1611            content: XorName([0u8; 32]),
1612            timestamp: SystemTime::UNIX_EPOCH,
1613            price: Amount::ZERO,
1614            rewards_address: RewardsAddress::new([0u8; 20]),
1615            pub_key: undersized,
1616            signature: Vec::new(),
1617            committed_key_count: 0,
1618            commitment_pin: None,
1619        };
1620        assert!(!quote_binding_is_valid(&peer_id, &quote));
1621        assert!(!storer_binding_would_accept(&peer_id, &quote));
1622    }
1623
1624    // ============================================================
1625    // Tests for the filter (`drop_quotes_with_bad_bindings`)
1626    // ============================================================
1627
1628    #[test]
1629    fn quote_query_counts_keep_single_node_close_group_only() {
1630        assert_eq!(single_node_quote_query_count(), CLOSE_GROUP_SIZE);
1631        assert_eq!(SINGLE_NODE_MIN_QUOTE_COUNT, 1);
1632        assert_eq!(SINGLE_NODE_WITNESSED_VIEW_COUNT, 20);
1633        assert!(SINGLE_NODE_WITNESSED_VIEW_COUNT > single_node_quote_query_count());
1634        assert_eq!(witnessed_close_group_quorum(), 5);
1635        assert_eq!(witnessed_close_group_quorum_for_missing_views(0), 5);
1636        assert_eq!(witnessed_close_group_quorum_for_missing_views(1), 4);
1637        assert_eq!(witnessed_close_group_quorum_for_missing_views(2), 3);
1638        assert_eq!(
1639            fault_tolerant_quote_query_count(),
1640            CLOSE_GROUP_SIZE * FAULT_TOLERANT_QUOTE_QUERY_MULTIPLIER
1641        );
1642        assert!(fault_tolerant_quote_query_count() > single_node_quote_query_count());
1643    }
1644
1645    #[test]
1646    fn witnessed_quote_launch_budget_keeps_exact_quote_window() {
1647        assert_eq!(
1648            witnessed_quote_launch_budget(0, 0, CLOSE_GROUP_SIZE * 2),
1649            CLOSE_GROUP_SIZE,
1650            "initial SNP quote fetch should launch the closest seven peers"
1651        );
1652        assert_eq!(
1653            witnessed_quote_launch_budget(1, CLOSE_GROUP_SIZE - 1, CLOSE_GROUP_SIZE),
1654            0,
1655            "a successful quote should not launch an extra fallback"
1656        );
1657        assert_eq!(
1658            witnessed_quote_launch_budget(0, CLOSE_GROUP_SIZE - 1, CLOSE_GROUP_SIZE),
1659            1,
1660            "a failed in-flight quote should launch the next closest fallback"
1661        );
1662        assert_eq!(
1663            witnessed_quote_launch_budget(CLOSE_GROUP_SIZE - 1, 0, 3),
1664            1,
1665            "only one more peer is needed for the seventh quote"
1666        );
1667        assert_eq!(
1668            witnessed_quote_launch_budget(0, 0, CLOSE_GROUP_SIZE - 1),
1669            CLOSE_GROUP_SIZE - 1,
1670            "launch budget is capped by remaining candidates"
1671        );
1672    }
1673
1674    #[test]
1675    fn witnessed_candidates_sort_by_xor_distance_then_votes() {
1676        let address = [0u8; 32];
1677        let witnessed = WitnessedCloseGroup {
1678            target: address,
1679            k: CLOSE_GROUP_SIZE,
1680            initial_closest: witnessed_test_nodes(&[1, 2, 3, 4, 5, 6, 7]),
1681            responder_views: vec![
1682                witnessed_test_view(1, &[1, 9]),
1683                witnessed_test_view(2, &[1, 9]),
1684                witnessed_test_view(3, &[1, 9]),
1685                witnessed_test_view(4, &[1, 9]),
1686                witnessed_test_view(5, &[1, 9]),
1687                witnessed_test_view(6, &[9]),
1688                witnessed_test_view(7, &[9]),
1689            ],
1690        };
1691
1692        let candidates =
1693            witnessed_consensus_candidates(&witnessed, &address, witnessed_close_group_quorum());
1694
1695        assert_eq!(
1696            candidates
1697                .iter()
1698                .map(|candidate| candidate.node.peer_id.as_bytes()[0])
1699                .collect::<Vec<_>>(),
1700            vec![1, 9],
1701            "XOR closeness must be the primary sort before quote collection"
1702        );
1703    }
1704
1705    /// Ascending seeds `1..=count`, each a valid `u8` peer seed.
1706    fn ascending_seeds(count: usize) -> Vec<u8> {
1707        (1..=count)
1708            .map(|n| u8::try_from(n).expect("test seed fits in u8"))
1709            .collect()
1710    }
1711
1712    #[test]
1713    fn scope_witnessed_to_close_group_matches_native_close_group_query() {
1714        // How many of the closest-`CLOSE_GROUP_SIZE` responders returned a view.
1715        // The remainder are "missing", so the scoped transcript also exercises
1716        // the missing-views quorum adjustment.
1717        const RESPONDED_IN_SCOPE: usize = 5;
1718        // Responders past the close group whose views scoping must drop.
1719        const OUT_OF_SCOPE_RESPONDERS: usize = 2;
1720
1721        let address = [0u8; 32];
1722        let close_seeds = ascending_seeds(CLOSE_GROUP_SIZE);
1723        // Each view's closest list mixes in-group (1, 2) and far (8, 9) peers so
1724        // candidate selection is non-trivial and must survive scoping verbatim.
1725        let view_closest = [1, 2, 8, 9];
1726        let in_scope_views = || -> Vec<ResponderView> {
1727            ascending_seeds(RESPONDED_IN_SCOPE)
1728                .into_iter()
1729                .map(|responder| witnessed_test_view(responder, &view_closest))
1730                .collect()
1731        };
1732
1733        // A wide PUT_TARGET_WIDTH-peer transcript, ordered closest-first (seed n
1734        // == PeerId [n; 32], whose XOR distance to the zero address is n). Two
1735        // responders past the close group (out of scope) must be dropped.
1736        let mut wide_views = in_scope_views();
1737        for offset in 1..=OUT_OF_SCOPE_RESPONDERS {
1738            let responder =
1739                u8::try_from(CLOSE_GROUP_SIZE + offset).expect("out-of-scope seed fits in u8");
1740            wide_views.push(witnessed_test_view(responder, &[1, 2, 3]));
1741        }
1742        let wide = WitnessedCloseGroup {
1743            target: address,
1744            k: PUT_TARGET_WIDTH,
1745            initial_closest: witnessed_test_nodes(&ascending_seeds(PUT_TARGET_WIDTH)),
1746            responder_views: wide_views,
1747        };
1748
1749        // The hand-built equivalent: a native CLOSE_GROUP_SIZE-wide query with
1750        // the same in-scope responders.
1751        let native = WitnessedCloseGroup {
1752            target: address,
1753            k: CLOSE_GROUP_SIZE,
1754            initial_closest: witnessed_test_nodes(&close_seeds),
1755            responder_views: in_scope_views(),
1756        };
1757
1758        let scoped = scope_witnessed_to_close_group(&wide);
1759
1760        // Target preserved; k and the initial set collapse to the close group.
1761        assert_eq!(scoped.target, wide.target);
1762        assert_eq!(scoped.k, CLOSE_GROUP_SIZE);
1763        assert_eq!(
1764            scoped
1765                .initial_closest
1766                .iter()
1767                .map(|node| node.peer_id.as_bytes()[0])
1768                .collect::<Vec<_>>(),
1769            close_seeds,
1770            "initial set must be the closest CLOSE_GROUP_SIZE, in order"
1771        );
1772
1773        // Out-of-close-group responder views are dropped; the in-scope ones keep
1774        // their closest lists untouched (scoping filters by responder only).
1775        assert_eq!(
1776            scoped
1777                .responder_views
1778                .iter()
1779                .map(|view| view.responder.as_bytes()[0])
1780                .collect::<Vec<_>>(),
1781            ascending_seeds(RESPONDED_IN_SCOPE),
1782            "only responders inside the close group survive"
1783        );
1784        assert_eq!(
1785            scoped.responder_views[0]
1786                .closest
1787                .iter()
1788                .map(|node| node.peer_id.as_bytes()[0])
1789                .collect::<Vec<_>>(),
1790            view_closest.to_vec(),
1791            "a surviving view's closest set must be preserved verbatim"
1792        );
1793
1794        // The quorum math and candidate consensus run on the close group only
1795        // and are byte-for-byte identical to the native CLOSE_GROUP_SIZE query.
1796        assert_eq!(
1797            missing_witnessed_responder_views(&scoped),
1798            missing_witnessed_responder_views(&native),
1799        );
1800        let quorum = witnessed_close_group_quorum_for_transcript(&scoped);
1801        assert_eq!(quorum, witnessed_close_group_quorum_for_transcript(&native));
1802        let candidate_seeds = |group: &WitnessedCloseGroup| {
1803            witnessed_consensus_candidates(group, &address, quorum)
1804                .iter()
1805                .map(|candidate| candidate.node.peer_id.as_bytes()[0])
1806                .collect::<Vec<_>>()
1807        };
1808        assert_eq!(
1809            candidate_seeds(&scoped),
1810            candidate_seeds(&native),
1811            "scoped consensus must match a native close-group query"
1812        );
1813    }
1814
1815    #[test]
1816    fn witnessed_quote_peers_error_is_typed_and_pre_payment_when_consensus_is_short() {
1817        let address = [0u8; 32];
1818        let responder_views = (1..=7)
1819            .map(|responder| witnessed_test_view(responder, &[1, 2, 3, 4]))
1820            .collect();
1821        let witnessed = WitnessedCloseGroup {
1822            target: address,
1823            k: CLOSE_GROUP_SIZE,
1824            initial_closest: witnessed_test_nodes(&[1, 2, 3, 4, 5, 6, 7]),
1825            responder_views,
1826        };
1827
1828        let err = witnessed_quote_selection_or_error(
1829            &address,
1830            &witnessed,
1831            CLOSE_GROUP_SIZE,
1832            witnessed_close_group_quorum(),
1833        )
1834        .expect_err("short witnessed consensus must fail before payment");
1835
1836        match err {
1837            Error::InsufficientPeers(message) => {
1838                assert!(message.contains("before payment"));
1839                assert!(message.contains("vote_counts"));
1840                assert!(message.contains("quorum"));
1841            }
1842            other => panic!("expected typed InsufficientPeers error, got {other:?}"),
1843        }
1844    }
1845
1846    #[test]
1847    fn witnessed_quote_selection_accepts_one_quorum_recognised_candidate() {
1848        let address = [0u8; 32];
1849        let witnessed = WitnessedCloseGroup {
1850            target: address,
1851            k: CLOSE_GROUP_SIZE,
1852            initial_closest: witnessed_test_nodes(&[1, 2, 3, 4, 5, 6, 7]),
1853            responder_views: (1..=7)
1854                .map(|responder| witnessed_test_view(responder, &[1]))
1855                .collect(),
1856        };
1857
1858        let selection = witnessed_quote_selection_or_error(
1859            &address,
1860            &witnessed,
1861            SINGLE_NODE_MIN_QUOTE_COUNT,
1862            witnessed_close_group_quorum(),
1863        )
1864        .expect("one quorum-recognised candidate is enough before payment");
1865
1866        assert_eq!(
1867            selection
1868                .quote_peers
1869                .iter()
1870                .map(|peer| peer.peer_id.as_bytes()[0])
1871                .collect::<Vec<_>>(),
1872            vec![1]
1873        );
1874        assert_eq!(
1875            put_peer_seeds(&selection.initial_put_peers),
1876            vec![1, 2, 3, 4, 5, 6, 7]
1877        );
1878    }
1879
1880    #[test]
1881    fn witnessed_quote_peers_include_quorum_fallback_candidates() {
1882        const EXTRA_QUORUM_CANDIDATES: usize = 1;
1883
1884        let address = [0u8; 32];
1885        let witnessed = WitnessedCloseGroup {
1886            target: address,
1887            k: CLOSE_GROUP_SIZE,
1888            initial_closest: witnessed_test_nodes(&[1, 2, 3, 4, 5, 6, 7]),
1889            responder_views: vec![
1890                witnessed_test_view(1, &[1, 2, 3, 4, 5, 6, 7]),
1891                witnessed_test_view(2, &[1, 2, 3, 4, 5, 6, 8]),
1892                witnessed_test_view(3, &[1, 2, 3, 4, 5, 7, 8]),
1893                witnessed_test_view(4, &[1, 2, 3, 4, 6, 7, 8]),
1894                witnessed_test_view(5, &[1, 2, 3, 5, 6, 7, 8]),
1895                witnessed_test_view(6, &[1, 2, 4, 5, 6, 7, 8]),
1896                witnessed_test_view(7, &[1, 3, 4, 5, 6, 7, 8]),
1897            ],
1898        };
1899
1900        let selection = witnessed_quote_selection_or_error(
1901            &address,
1902            &witnessed,
1903            CLOSE_GROUP_SIZE,
1904            witnessed_close_group_quorum(),
1905        )
1906        .expect("fallback candidates should be retained for quote collection");
1907
1908        assert_eq!(
1909            selection.quote_peers.len(),
1910            CLOSE_GROUP_SIZE + EXTRA_QUORUM_CANDIDATES
1911        );
1912        assert_eq!(
1913            selection
1914                .quote_peers
1915                .iter()
1916                .map(|peer| peer.peer_id.as_bytes()[0])
1917                .collect::<Vec<_>>(),
1918            vec![1, 2, 3, 4, 5, 6, 7, 8]
1919        );
1920        assert_eq!(
1921            put_peer_seeds(&selection.initial_put_peers),
1922            vec![1, 2, 3, 4, 5, 6, 7]
1923        );
1924    }
1925
1926    #[test]
1927    fn witnessed_quote_peers_lower_quorum_for_missing_responder_views() {
1928        let address = [0u8; 32];
1929        let witnessed = WitnessedCloseGroup {
1930            target: address,
1931            k: CLOSE_GROUP_SIZE,
1932            initial_closest: witnessed_test_nodes(&[1, 2, 3, 4, 5, 6, 7]),
1933            responder_views: vec![
1934                witnessed_test_view(1, &[1, 2, 3, 4, 5, 6, 7]),
1935                witnessed_test_view(2, &[1, 2, 3, 4, 5, 6, 8]),
1936                witnessed_test_view(3, &[1, 2, 3, 4, 5, 7, 8]),
1937                witnessed_test_view(4, &[1, 2, 3, 4, 6, 7, 8]),
1938                witnessed_test_view(5, &[1, 2, 3, 5, 6, 7, 8]),
1939                witnessed_test_view(6, &[1, 2, 4, 5, 6, 7, 8]),
1940            ],
1941        };
1942        let quorum = witnessed_close_group_quorum_for_transcript(&witnessed);
1943
1944        assert_eq!(missing_witnessed_responder_views(&witnessed), 1);
1945        assert_eq!(quorum, 4);
1946
1947        let selection =
1948            witnessed_quote_selection_or_error(&address, &witnessed, CLOSE_GROUP_SIZE, quorum)
1949                .expect(
1950                    "one missing responder view should lower quorum and still select candidates",
1951                );
1952
1953        assert_eq!(
1954            selection
1955                .quote_peers
1956                .iter()
1957                .map(|peer| peer.peer_id.as_bytes()[0])
1958                .collect::<Vec<_>>(),
1959            vec![1, 2, 3, 4, 5, 6, 7, 8]
1960        );
1961        assert_eq!(selection.quorum, quorum);
1962    }
1963
1964    #[test]
1965    fn witnessed_quote_selection_keeps_closest_set_with_median_voter_quorum() {
1966        const MEDIAN_ISSUER_SEED: u8 = 7;
1967        const FAR_SUPPORTING_VOTER_SEED: u8 = 20;
1968        const UNSUCCESSFUL_SUPPORTING_VOTER_SEED: u8 = 21;
1969
1970        let address = [0u8; 32];
1971        let quotes = vec![
1972            synthetic_quote(1, 10),
1973            synthetic_quote(2, 20),
1974            synthetic_quote(3, 30),
1975            synthetic_quote(6, 50),
1976            synthetic_quote(MEDIAN_ISSUER_SEED, 40),
1977            synthetic_quote(8, 60),
1978            synthetic_quote(9, 70),
1979            synthetic_quote(FAR_SUPPORTING_VOTER_SEED, 80),
1980        ];
1981        let mut voters_by_peer = HashMap::new();
1982        voters_by_peer.insert(
1983            synthetic_peer(MEDIAN_ISSUER_SEED),
1984            synthetic_voters(&[
1985                1,
1986                2,
1987                3,
1988                MEDIAN_ISSUER_SEED,
1989                FAR_SUPPORTING_VOTER_SEED,
1990                UNSUCCESSFUL_SUPPORTING_VOTER_SEED,
1991            ]),
1992        );
1993
1994        let quorum = witnessed_close_group_quorum();
1995        let selected =
1996            select_witnessed_median_voter_quotes(quotes, &address, &voters_by_peer, quorum)
1997                .expect("a supported close-group quote set should be selected");
1998
1999        assert_eq!(quote_peer_seeds(&selected), vec![1, 2, 3, 6, 7, 8, 9]);
2000        let (median_peer_id, _) =
2001            median_paid_quote_issuer(&selected).expect("selected quotes have a median");
2002        assert_eq!(median_peer_id, synthetic_peer(MEDIAN_ISSUER_SEED));
2003        assert!(voters_by_peer[&median_peer_id].len() >= quorum);
2004    }
2005
2006    #[test]
2007    fn witnessed_quote_selection_uses_direct_median_witness_recognition() {
2008        const MEDIAN_ISSUER_SEED: u8 = 7;
2009
2010        let address = [0u8; 32];
2011        let quotes = vec![
2012            synthetic_quote(1, 10),
2013            synthetic_quote(2, 20),
2014            synthetic_quote(3, 30),
2015            synthetic_quote(4, 50),
2016            synthetic_quote(MEDIAN_ISSUER_SEED, 40),
2017            synthetic_quote(8, 60),
2018            synthetic_quote(9, 70),
2019        ];
2020        let mut voters_by_peer = HashMap::new();
2021        voters_by_peer.insert(
2022            synthetic_peer(MEDIAN_ISSUER_SEED),
2023            synthetic_voters(&[20, 21, 22, 23, 24]),
2024        );
2025
2026        let quorum = witnessed_close_group_quorum();
2027        let selected =
2028            select_witnessed_median_voter_quotes(quotes, &address, &voters_by_peer, quorum)
2029                .expect("direct witness recognition should support the paid median issuer");
2030
2031        let (median_peer_id, _) =
2032            median_paid_quote_issuer(&selected).expect("selected quotes have a median");
2033        let selected_peers = selected
2034            .iter()
2035            .map(|(peer_id, _, _, _, _)| *peer_id)
2036            .collect::<HashSet<_>>();
2037        assert_eq!(median_peer_id, synthetic_peer(MEDIAN_ISSUER_SEED));
2038        assert_eq!(
2039            voters_by_peer[&median_peer_id]
2040                .intersection(&selected_peers)
2041                .count(),
2042            0,
2043            "recognising witnesses need not also be selected quote issuers"
2044        );
2045        assert_eq!(voters_by_peer[&median_peer_id].len(), quorum);
2046    }
2047
2048    #[test]
2049    fn witnessed_quote_selection_allows_single_required_quote() {
2050        const QUOTE_ISSUER_SEED: u8 = 7;
2051
2052        let address = [0u8; 32];
2053        let quotes = vec![
2054            synthetic_quote(QUOTE_ISSUER_SEED, 10),
2055            synthetic_quote(1, 20),
2056            synthetic_quote(2, 30),
2057        ];
2058        let mut voters_by_peer = HashMap::new();
2059        voters_by_peer.insert(
2060            synthetic_peer(QUOTE_ISSUER_SEED),
2061            synthetic_voters(&[1, 2, 3, 4, 5]),
2062        );
2063
2064        let selected = select_witnessed_median_voter_quotes(
2065            quotes,
2066            &address,
2067            &voters_by_peer,
2068            witnessed_close_group_quorum(),
2069        )
2070        .expect("one quorum-supported quote is enough for SNP payment");
2071
2072        assert_eq!(quote_peer_seeds(&selected), vec![QUOTE_ISSUER_SEED]);
2073        let (median_peer_id, _) =
2074            median_paid_quote_issuer(&selected).expect("single quote is its own median");
2075        assert_eq!(median_peer_id, synthetic_peer(QUOTE_ISSUER_SEED));
2076    }
2077
2078    #[test]
2079    fn witnessed_quote_selection_rejects_median_without_witness_quorum() {
2080        const MEDIAN_ISSUER_SEED: u8 = 7;
2081
2082        let address = [0u8; 32];
2083        let quotes = vec![
2084            synthetic_quote(1, 10),
2085            synthetic_quote(2, 20),
2086            synthetic_quote(3, 30),
2087            synthetic_quote(6, 50),
2088            synthetic_quote(MEDIAN_ISSUER_SEED, 40),
2089            synthetic_quote(8, 60),
2090            synthetic_quote(9, 70),
2091            synthetic_quote(10, 80),
2092        ];
2093        let mut voters_by_peer = HashMap::new();
2094        voters_by_peer.insert(
2095            synthetic_peer(MEDIAN_ISSUER_SEED),
2096            synthetic_voters(&[1, 2, 3, 20]),
2097        );
2098
2099        let selected = select_witnessed_median_voter_quotes(
2100            quotes,
2101            &address,
2102            &voters_by_peer,
2103            witnessed_close_group_quorum(),
2104        );
2105
2106        assert!(
2107            selected.is_none(),
2108            "the selector must not return a paid quote set when fewer than the \
2109             witnessed median voter quorum recognised the paid median issuer"
2110        );
2111    }
2112
2113    #[test]
2114    fn put_peers_prioritise_median_voters_without_reordering_quotes() {
2115        const MEDIAN_ISSUER_SEED: u8 = 7;
2116
2117        let quotes = vec![
2118            synthetic_quote(1, 10),
2119            synthetic_quote(2, 20),
2120            synthetic_quote(3, 30),
2121            synthetic_quote(4, 50),
2122            synthetic_quote(5, 60),
2123            synthetic_quote(6, 70),
2124            synthetic_quote(MEDIAN_ISSUER_SEED, 40),
2125        ];
2126        let mut voters_by_peer = HashMap::new();
2127        voters_by_peer.insert(
2128            synthetic_peer(MEDIAN_ISSUER_SEED),
2129            synthetic_voters(&[3, 4, 5, 6, MEDIAN_ISSUER_SEED]),
2130        );
2131
2132        let put_candidates = put_peers_from_seeds(&[1, 2, 3, 4, 5, 6, 7]);
2133        let put_peers = put_peers_with_median_voters_first(
2134            &quotes,
2135            &put_candidates,
2136            &voters_by_peer,
2137            witnessed_close_group_quorum(),
2138        )
2139        .expect("median voters should produce an ordered PUT set");
2140
2141        assert_eq!(quote_peer_seeds(&quotes), vec![1, 2, 3, 4, 5, 6, 7]);
2142        let (median_peer_id, _) =
2143            median_paid_quote_issuer(&quotes).expect("selected quotes have a median");
2144        assert_eq!(median_peer_id, synthetic_peer(MEDIAN_ISSUER_SEED));
2145        assert_eq!(put_peer_seeds(&put_peers), vec![3, 4, 5, 6, 7, 1, 2]);
2146    }
2147
2148    #[test]
2149    fn filter_drops_only_bad_bindings_and_leaves_storer_acceptable_quotes() {
2150        let mut quotes = vec![
2151            good_quote_real(),
2152            bad_quote_real(),
2153            good_quote_real(),
2154            bad_quote_real(),
2155            good_quote_real(),
2156        ];
2157
2158        let dropped = drop_quotes_with_bad_bindings(&mut quotes);
2159
2160        assert_eq!(dropped, 2, "two crossed-key quotes must be dropped");
2161        assert_eq!(quotes.len(), 3, "three real-key quotes must remain");
2162
2163        // Cross-checked invariant: every retained quote would be accepted by
2164        // a storer running the full spec. The defensive filter only checks
2165        // the binding, so this asserts the binding-only filter is correct
2166        // for binding-only failures (other failure modes are filtered by
2167        // the per-peer classifier upstream).
2168        for (peer_id, _, quote, _, _) in &quotes {
2169            assert!(
2170                storer_binding_would_accept(peer_id, quote),
2171                "every retained quote must satisfy the full storer-side spec"
2172            );
2173        }
2174    }
2175
2176    #[test]
2177    fn filter_is_noop_when_all_quotes_are_storer_acceptable() {
2178        let mut quotes: Vec<_> = (0..5).map(|_| good_quote_real()).collect();
2179        let before = quotes.len();
2180        let dropped = drop_quotes_with_bad_bindings(&mut quotes);
2181        assert_eq!(dropped, 0);
2182        assert_eq!(quotes.len(), before);
2183        for (peer_id, _, quote, _, _) in &quotes {
2184            assert!(storer_binding_would_accept(peer_id, quote));
2185        }
2186    }
2187
2188    #[test]
2189    fn filter_drops_all_when_every_responder_is_bad() {
2190        // The "all hostile" case: every peer returned a bad binding. The
2191        // patch should leave us with zero quotes (not panic, not skip the
2192        // filter, not return malformed quotes). The caller then surfaces
2193        // InsufficientPeers.
2194        let mut quotes: Vec<_> = (0..fault_tolerant_quote_query_count())
2195            .map(|_| bad_quote_real())
2196            .collect();
2197        let dropped = drop_quotes_with_bad_bindings(&mut quotes);
2198        assert_eq!(dropped, fault_tolerant_quote_query_count());
2199        assert!(quotes.is_empty());
2200    }
2201
2202    #[test]
2203    fn filter_preserves_quote_payload_byte_for_byte() {
2204        // After filtering, the retained quotes must be untouched — pub_key,
2205        // signature, content, timestamp, price, rewards_address. The patch
2206        // is a filter, not a transformation; this test catches any future
2207        // regression that mutates a retained quote.
2208        let (peer_id, addrs, original_quote, amount, commitment) = good_quote_real();
2209        let mut quotes = vec![(
2210            peer_id,
2211            addrs.clone(),
2212            original_quote.clone(),
2213            amount,
2214            commitment,
2215        )];
2216        let _ = drop_quotes_with_bad_bindings(&mut quotes);
2217
2218        let (kept_peer, kept_addrs, kept_quote, kept_amount, _kept_commitment) =
2219            quotes.pop().expect("the good quote must survive filtering");
2220        assert_eq!(kept_peer.as_bytes(), peer_id.as_bytes());
2221        assert_eq!(kept_addrs.len(), addrs.len());
2222        assert_eq!(kept_amount, amount);
2223        assert_eq!(kept_quote.pub_key, original_quote.pub_key);
2224        assert_eq!(kept_quote.signature, original_quote.signature);
2225        assert_eq!(kept_quote.content.0, original_quote.content.0);
2226        assert_eq!(kept_quote.timestamp, original_quote.timestamp);
2227        assert_eq!(kept_quote.price, original_quote.price);
2228        assert_eq!(kept_quote.rewards_address, original_quote.rewards_address);
2229    }
2230
2231    // ============================================================
2232    // The Apr 30 production-failure repro
2233    // ============================================================
2234
2235    /// Repro of the production failure from 2026-04-30 testnet runs.
2236    ///
2237    /// An external operator on `75.48.86.24` ran two co-located ant-node
2238    /// identities (peer `0755ecb55b…` and peer `073db92f…`) that crossed
2239    /// their quote-signing keys. Every chunk whose XOR-closest set happened
2240    /// to include peer `0755ecb5` got a payment proof with one malformed
2241    /// quote, and the storer's `validate_peer_bindings` rejected the
2242    /// entire close-group proof — burning the chunk's payment.
2243    ///
2244    /// This test proves the fault-tolerant quote path still fixes that failure
2245    /// shape:
2246    ///
2247    /// 1. We assemble `2x CLOSE_GROUP_SIZE` real ML-DSA-65 quotes — the same
2248    ///    buffer merkle preflight and merkle-mode estimates retain for probes.
2249    /// 2. One of them is a *crossed-key* quote — the production failure shape.
2250    /// 3. We run an independent `storer_would_accept` check (re-derived from
2251    ///    the storer spec, not from `quote_binding_is_valid`) over the
2252    ///    pre-filter set; we confirm the bad peer is rejected, proving the
2253    ///    storer **would** burn the chunk's payment if we proceeded unfiltered.
2254    /// 4. We run `drop_quotes_with_bad_bindings`.
2255    /// 5. We re-run `storer_would_accept` over the post-filter set; we confirm
2256    ///    EVERY remaining quote would be accepted, proving the filtered set
2257    ///    will not trigger the `validate_peer_bindings` rejection that caused
2258    ///    the Apr 30 outage.
2259    /// 6. We confirm the post-filter set has at least `CLOSE_GROUP_SIZE`
2260    ///    quotes — the over-query buffer (2x) is sufficient.
2261    #[test]
2262    fn repro_apr_30_storer_would_have_rejected_pre_filter_and_accepts_post_filter() {
2263        let over_query_count = fault_tolerant_quote_query_count();
2264        let mut quotes: Vec<_> = (0..over_query_count - 1)
2265            .map(|_| good_quote_real())
2266            .collect();
2267        // Splice the crossed-key quote in the middle (mirrors the random
2268        // position the bad peer takes in the DHT-returned closest set).
2269        quotes.insert(over_query_count / 2, bad_quote_real());
2270        assert_eq!(quotes.len(), over_query_count);
2271
2272        // Step 1: prove the storer would reject the pre-filter set.
2273        let storer_would_reject_count = quotes
2274            .iter()
2275            .filter(|(p, _, q, _, _)| !storer_binding_would_accept(p, q))
2276            .count();
2277        assert_eq!(
2278            storer_would_reject_count, 1,
2279            "exactly one quote (the crossed-key one) must be rejected by the storer spec"
2280        );
2281
2282        // Step 2: run the patched filter.
2283        let dropped = drop_quotes_with_bad_bindings(&mut quotes);
2284        assert_eq!(dropped, 1, "exactly the crossed-key quote must be filtered");
2285
2286        // Step 3: prove the storer would accept every survivor under the FULL spec.
2287        for (peer_id, _, quote, _, _) in &quotes {
2288            assert!(
2289                storer_binding_would_accept(peer_id, quote),
2290                "every post-filter quote must be accepted by the storer spec — \
2291                 this is what the filter guarantees before any quote set is used"
2292            );
2293        }
2294
2295        // Step 4: prove the over-query buffer is sufficient to refill.
2296        assert!(
2297            quotes.len() >= CLOSE_GROUP_SIZE,
2298            "after filtering, at least CLOSE_GROUP_SIZE good quotes must remain \
2299             so a fault-tolerant probe can still return a full close group"
2300        );
2301    }
2302
2303    /// When more than the over-query buffer of peers misbehave, the filter
2304    /// must NOT silently produce a short proof. The downstream caller in
2305    /// `get_store_quotes` must see fewer than `CLOSE_GROUP_SIZE` survivors
2306    /// and return `InsufficientPeers`.
2307    #[test]
2308    fn filter_leaves_short_set_when_too_many_bad_peers() {
2309        let good_count = CLOSE_GROUP_SIZE - 1;
2310        let bad_count = fault_tolerant_quote_query_count() - good_count;
2311        let mut quotes: Vec<_> = std::iter::repeat_with(bad_quote_real)
2312            .take(bad_count)
2313            .chain(std::iter::repeat_with(good_quote_real).take(good_count))
2314            .collect();
2315
2316        let dropped = drop_quotes_with_bad_bindings(&mut quotes);
2317        assert_eq!(dropped, bad_count);
2318        assert!(
2319            quotes.len() < CLOSE_GROUP_SIZE,
2320            "this is the precondition for InsufficientPeers downstream"
2321        );
2322        // Sanity: every survivor is storer-acceptable under the full spec.
2323        for (peer_id, _, quote, _, _) in &quotes {
2324            assert!(storer_binding_would_accept(peer_id, quote));
2325        }
2326    }
2327
2328    // ============================================================
2329    // Tests for the per-peer response classifier (the PRIMARY defense).
2330    //
2331    // These tests exercise the production code path that runs inside
2332    // get_store_quotes' per-peer async closure. The defensive
2333    // `drop_quotes_with_bad_bindings` is a second line of defence —
2334    // these tests make sure the FIRST line is what actually catches
2335    // misbehaving peers in production. Without these, a regression
2336    // that removes the per-peer check could be masked by the post-
2337    // collect filter and pass the rest of the suite.
2338    // ============================================================
2339
2340    /// Helper: serialize a `PaymentQuote` to bytes the way the wire layer
2341    /// does (rmp_serde / msgpack), to feed into `classify_quote_response`.
2342    fn serialize_quote(quote: &PaymentQuote) -> Vec<u8> {
2343        rmp_serde::to_vec(quote).expect("serialize quote")
2344    }
2345
2346    #[test]
2347    fn classifier_accepts_real_self_consistent_quote() {
2348        // A properly-signed baseline quote for the requested content passes the
2349        // full client gate (binding + content + signature + price).
2350        let content = [7u8; 32];
2351        let (peer_id, quote) = signed_baseline_quote(content);
2352        let bytes = serialize_quote(&quote);
2353        let result = classify_quote_response(&peer_id, &content, &bytes, false, None);
2354        match result {
2355            Ok((q, price, commitment)) => {
2356                assert_eq!(q.pub_key, quote.pub_key);
2357                assert_eq!(price, quote.price);
2358                assert!(commitment.is_none(), "baseline quote ships no commitment");
2359            }
2360            Err(e) => panic!("expected Ok, got {e}"),
2361        }
2362    }
2363
2364    #[test]
2365    fn classifier_rejects_quote_with_invalid_signature() {
2366        // A quote whose pub_key binds correctly but whose signature is bogus is
2367        // dropped BEFORE payment (the storer would reject it and burn the pay).
2368        let content = [7u8; 32];
2369        let (peer_id, mut quote) = signed_baseline_quote(content);
2370        quote.signature = vec![0u8; quote.signature.len()]; // corrupt the signature
2371        let bytes = serialize_quote(&quote);
2372        let result = classify_quote_response(&peer_id, &content, &bytes, false, None);
2373        assert!(
2374            matches!(result, Err(Error::BadQuoteBinding { .. })),
2375            "a quote with an invalid signature must be rejected; got {result:?}"
2376        );
2377    }
2378
2379    #[test]
2380    fn classifier_rejects_quote_for_wrong_content() {
2381        // A validly-signed quote for a DIFFERENT address is dropped before pay.
2382        let (peer_id, quote) = signed_baseline_quote([7u8; 32]);
2383        let bytes = serialize_quote(&quote);
2384        let result = classify_quote_response(&peer_id, &[9u8; 32], &bytes, false, None);
2385        assert!(
2386            matches!(result, Err(Error::BadQuoteBinding { .. })),
2387            "a quote for the wrong content must be rejected; got {result:?}"
2388        );
2389    }
2390
2391    #[test]
2392    fn classifier_rejects_crossed_keypair_with_typed_error() {
2393        let (peer_id, _, quote, _, _) = bad_quote_real();
2394        let bytes = serialize_quote(&quote);
2395        let result = classify_quote_response(&peer_id, &[0u8; 32], &bytes, false, None);
2396        match result {
2397            Err(Error::BadQuoteBinding {
2398                peer_id: pid,
2399                detail,
2400            }) => {
2401                assert_eq!(pid, peer_id.to_string());
2402                assert!(
2403                    detail.contains("BLAKE3(pub_key)="),
2404                    "diagnostic detail must include the derived peer id: {detail}"
2405                );
2406            }
2407            other => panic!("expected BadQuoteBinding for crossed-key quote, got {other:?}"),
2408        }
2409    }
2410
2411    /// CRITICAL: a misbehaving peer that votes `already_stored=true` must
2412    /// NOT be allowed to influence the close-group "already stored"
2413    /// majority decision. The bind-check runs before the AlreadyStored
2414    /// short-circuit, so a crossed-key peer voting "already stored" is
2415    /// classified as `BadQuoteBinding`, not `AlreadyStored`.
2416    ///
2417    /// This locks in a specific reviewer concern from round 1:
2418    ///   "A peer with a crossed/garbage signing key could simply respond
2419    ///   already_stored=true and its vote enters already_stored_peers
2420    ///   unfiltered."
2421    #[test]
2422    fn classifier_rejects_already_stored_vote_from_bad_binding_peer() {
2423        let (peer_id, _, quote, _, _) = bad_quote_real();
2424        let bytes = serialize_quote(&quote);
2425        // The peer claims already_stored=true, but its quote has a crossed key.
2426        let result = classify_quote_response(&peer_id, &[0u8; 32], &bytes, true, None);
2427        assert!(
2428            matches!(result, Err(Error::BadQuoteBinding { .. })),
2429            "crossed-key peer must be classified BadQuoteBinding even when \
2430             voting already_stored=true; got {result:?}"
2431        );
2432    }
2433
2434    /// An honest peer's `already_stored=true` vote IS honoured (after
2435    /// passing the bind-check). This is the contrast to the test above.
2436    #[test]
2437    fn classifier_honours_already_stored_vote_from_good_binding_peer() {
2438        let content = [7u8; 32];
2439        let (peer_id, quote) = signed_baseline_quote(content);
2440        let bytes = serialize_quote(&quote);
2441        let result = classify_quote_response(&peer_id, &content, &bytes, true, None);
2442        assert!(
2443            matches!(result, Err(Error::AlreadyStored)),
2444            "honest peer's already_stored vote must be honoured; got {result:?}"
2445        );
2446    }
2447
2448    #[test]
2449    fn classifier_returns_serialization_error_on_bad_bytes() {
2450        let (peer_id, _, _, _, _) = good_quote_real();
2451        let garbage = b"this is not a valid msgpack PaymentQuote".to_vec();
2452        let result = classify_quote_response(&peer_id, &[0u8; 32], &garbage, false, None);
2453        assert!(
2454            matches!(result, Err(Error::Serialization(_))),
2455            "garbage bytes must produce a Serialization error; got {result:?}"
2456        );
2457    }
2458
2459    /// Cross-validate the classifier's binding verdict against the
2460    /// independent storer-spec re-derivation across mixed responders.
2461    #[test]
2462    fn classifier_verdict_matches_storer_binding_spec_for_mixed_responders() {
2463        let content = [7u8; 32];
2464        let mut responders: Vec<(PeerId, PaymentQuote)> =
2465            (0..12).map(|_| signed_baseline_quote(content)).collect();
2466        for _ in 0..4 {
2467            let (p, _, q, _, _) = bad_quote_real();
2468            responders.push((p, q));
2469        }
2470
2471        for (peer_id, quote) in &responders {
2472            let bytes = serialize_quote(quote);
2473            let storer_verdict = storer_binding_would_accept(peer_id, quote);
2474            let classifier_verdict =
2475                classify_quote_response(peer_id, &content, &bytes, false, None).is_ok();
2476            assert_eq!(
2477                classifier_verdict, storer_verdict,
2478                "classifier and storer-binding-spec must agree on every responder \
2479                 (peer_id={}, storer={storer_verdict}, classifier={classifier_verdict})",
2480                peer_id
2481            );
2482        }
2483    }
2484
2485    // ============================================================
2486    // ADR-0004: quote_commitment_binding_is_valid (forced-price gate)
2487    //
2488    // Mirrors the storer-side `binding_violation` in
2489    // `ant-node/src/payment/verifier.rs`. The client runs this before
2490    // paying so it never pays a quote the storer's arithmetic gate would
2491    // reject. The client now runs the FULL check (shape, cap, exact price,
2492    // and for bound quotes: parse + peer-binding + signature + hash==pin +
2493    // count==key_count) using the shared ant-protocol commitment type, so an
2494    // unresolvable/forged commitment is never paid. A live resolve against a
2495    // REAL signed commitment is proven in the e2e suite (e2e_adr0004.rs).
2496    // ============================================================
2497
2498    /// A throwaway peer id for tests that fail BEFORE commitment resolution
2499    /// (shape/cap/price checks don't depend on the peer).
2500    fn any_peer() -> PeerId {
2501        PeerId::from_bytes([0u8; 32])
2502    }
2503
2504    /// Build a quote carrying a specific `(count, pin, price)` binding.
2505    fn quote_with_binding(
2506        committed_key_count: u32,
2507        commitment_pin: Option<[u8; 32]>,
2508        price: Amount,
2509    ) -> PaymentQuote {
2510        PaymentQuote {
2511            content: XorName([0u8; 32]),
2512            timestamp: SystemTime::UNIX_EPOCH,
2513            price,
2514            rewards_address: RewardsAddress::new([0u8; 20]),
2515            pub_key: Vec::new(),
2516            signature: Vec::new(),
2517            committed_key_count,
2518            commitment_pin,
2519        }
2520    }
2521
2522    /// Build a VALIDLY-SIGNED `StorageCommitment` bound to `kp`'s peer id, so a
2523    /// test can pass peer-binding + signature and isolate the `hash == pin` and
2524    /// `count == key_count` sub-checks. Mirrors ant-node's commitment signing:
2525    /// the canonical payload (`root || key_count(LE) || peer_id || pk_len(LE) ||
2526    /// pub_key`) signed under `DOMAIN_COMMITMENT`.
2527    fn signed_commitment(kp: &Keypair, root: [u8; 32], key_count: u32) -> StorageCommitment {
2528        use ant_protocol::payment::commitment::DOMAIN_COMMITMENT;
2529        use ant_protocol::pqc::api::{ml_dsa_65, MlDsaSecretKey as ApiSecretKey, MlDsaVariant};
2530        let peer = compute_address(&kp.pub_key_bytes);
2531        let mut payload = Vec::with_capacity(32 + 4 + 32 + 4 + kp.pub_key_bytes.len());
2532        payload.extend_from_slice(&root);
2533        payload.extend_from_slice(&key_count.to_le_bytes());
2534        payload.extend_from_slice(&peer);
2535        payload.extend_from_slice(&(kp.pub_key_bytes.len() as u32).to_le_bytes());
2536        payload.extend_from_slice(&kp.pub_key_bytes);
2537        let sk = ApiSecretKey::from_bytes(MlDsaVariant::MlDsa65, &kp.secret_key_bytes)
2538            .expect("api secret key");
2539        let signature = ml_dsa_65()
2540            .sign_with_context(&sk, &payload, DOMAIN_COMMITMENT)
2541            .expect("sign commitment")
2542            .to_bytes();
2543        StorageCommitment {
2544            root,
2545            key_count,
2546            sender_peer_id: peer,
2547            sender_public_key: kp.pub_key_bytes.clone(),
2548            signature,
2549        }
2550    }
2551
2552    #[test]
2553    fn binding_baseline_ok_only_at_baseline_price() {
2554        // (0, None) priced at calculate_price(0) is the valid baseline.
2555        let q = quote_with_binding(0, None, calculate_price(0));
2556        assert!(quote_commitment_binding_is_valid(&any_peer(), &q, &None).is_ok());
2557
2558        // (0, None) priced above baseline is rejected — the forged-shape
2559        // bypass (strip the pin, charge more than the empty-node price).
2560        let q = quote_with_binding(0, None, calculate_price(500));
2561        assert!(quote_commitment_binding_is_valid(&any_peer(), &q, &None).is_err());
2562    }
2563
2564    #[test]
2565    fn binding_rejects_incoherent_shapes() {
2566        // count > 0 but no pin: unauditable.
2567        let q = quote_with_binding(500, None, calculate_price(500));
2568        assert!(quote_commitment_binding_is_valid(&any_peer(), &q, &None).is_err());
2569        // count 0 but a pin: incoherent baseline.
2570        let q = quote_with_binding(0, Some([9u8; 32]), calculate_price(0));
2571        assert!(quote_commitment_binding_is_valid(&any_peer(), &q, &None).is_err());
2572    }
2573
2574    #[test]
2575    fn binding_rejects_count_above_cap() {
2576        let over = MAX_COMMITMENT_KEY_COUNT + 1;
2577        let q = quote_with_binding(over, Some([9u8; 32]), calculate_price(over as usize));
2578        assert!(
2579            quote_commitment_binding_is_valid(&any_peer(), &q, &Some(vec![1u8; 16])).is_err(),
2580            "a count above MAX_COMMITMENT_KEY_COUNT must be rejected before payment"
2581        );
2582    }
2583
2584    #[test]
2585    fn binding_rejects_on_curve_wrong_count() {
2586        // Priced for 499 but claims count 500 — on a real price curve but the
2587        // wrong count. Rejected at the exact-price check, before resolution.
2588        let q = quote_with_binding(500, Some([9u8; 32]), calculate_price(499));
2589        assert!(quote_commitment_binding_is_valid(&any_peer(), &q, &Some(vec![1u8; 16])).is_err());
2590    }
2591
2592    #[test]
2593    fn binding_rejects_bound_quote_without_shipped_commitment() {
2594        // A bound quote whose commitment did not arrive is unresolvable, so it
2595        // is dropped before payment even though its price is on the curve.
2596        let q = quote_with_binding(500, Some([9u8; 32]), calculate_price(500));
2597        assert!(
2598            quote_commitment_binding_is_valid(&any_peer(), &q, &None).is_err(),
2599            "a bound quote missing its commitment must be rejected"
2600        );
2601    }
2602
2603    #[test]
2604    fn binding_rejects_unparseable_and_peer_unbound_commitment() {
2605        // A bound quote whose shipped commitment is garbage (doesn't even
2606        // deserialize) is rejected — the client never pays an unresolvable pin.
2607        let q = quote_with_binding(500, Some([9u8; 32]), calculate_price(500));
2608        assert!(
2609            quote_commitment_binding_is_valid(&any_peer(), &q, &Some(vec![0xFF; 8])).is_err(),
2610            "an unparseable commitment must be rejected before payment"
2611        );
2612
2613        // A well-formed StorageCommitment that is NOT bound to the quoting peer
2614        // (its sender_peer_id / pubkey don't derive the peer id) is rejected at
2615        // the peer-binding check. The signature / hash==pin / count==key_count
2616        // sub-checks are covered by the dedicated tests below, which pass
2617        // peer-binding first so each isolates exactly one sub-check.
2618        let bogus = StorageCommitment {
2619            root: [1u8; 32],
2620            key_count: 500,
2621            sender_peer_id: [2u8; 32], // not the quoting peer
2622            sender_public_key: vec![3u8; 1952],
2623            signature: vec![4u8; 3293],
2624        };
2625        let blob = rmp_serde::to_vec(&bogus).expect("serialize bogus commitment");
2626        assert!(
2627            quote_commitment_binding_is_valid(&any_peer(), &q, &Some(blob)).is_err(),
2628            "a commitment not bound to the quoting peer must be rejected before payment"
2629        );
2630    }
2631
2632    #[test]
2633    fn binding_rejects_commitment_with_invalid_signature() {
2634        // Correctly-bound commitment (passes peer-binding) but with a corrupted
2635        // signature: must be rejected at the signature check. Deleting that check
2636        // would let a peer attest any (root, key_count) without holding the key.
2637        let kp = gen_keypair();
2638        let mut commitment = signed_commitment(&kp, [6u8; 32], 500);
2639        commitment.signature[0] ^= 0xFF; // still 3293 bytes, no longer valid
2640                                         // Pin the (corrupted) commitment so the hash==pin check would pass; the
2641                                         // only thing wrong is the signature, isolating that sub-check.
2642        let pin = commitment_hash(&commitment).expect("hash");
2643        let blob = rmp_serde::to_vec(&commitment).expect("serialize commitment");
2644        let q = quote_with_binding(500, Some(pin), calculate_price(500));
2645        let res = quote_commitment_binding_is_valid(&kp.peer_id, &q, &Some(blob));
2646        let err = res.expect_err("commitment with an invalid signature must be rejected");
2647        assert!(
2648            err.contains("signature"),
2649            "should fail at the signature check: {err}"
2650        );
2651    }
2652
2653    #[test]
2654    fn binding_rejects_commitment_that_does_not_hash_to_pin() {
2655        // Validly-signed, correctly-bound commitment, but the quote pins a
2656        // DIFFERENT hash: must be rejected. Deleting the hash==pin check would
2657        // let a peer ship any commitment it holds for a pin it doesn't back.
2658        let kp = gen_keypair();
2659        let commitment = signed_commitment(&kp, [5u8; 32], 500);
2660        let wrong_pin = [0xAB; 32];
2661        assert_ne!(commitment_hash(&commitment), Some(wrong_pin));
2662        let blob = rmp_serde::to_vec(&commitment).expect("serialize commitment");
2663        let q = quote_with_binding(500, Some(wrong_pin), calculate_price(500));
2664        let res = quote_commitment_binding_is_valid(&kp.peer_id, &q, &Some(blob));
2665        let err = res.expect_err("commitment that does not hash to the pin must be rejected");
2666        assert!(
2667            err.contains("hash"),
2668            "should fail at the hash==pin check: {err}"
2669        );
2670    }
2671
2672    #[test]
2673    fn binding_rejects_count_disagreeing_with_commitment() {
2674        // Validly-signed, correctly-bound, correctly-pinned commitment attesting
2675        // key_count=400, but the quote claims 500 (priced on-curve for 500):
2676        // must be rejected. Deleting the count==key_count check would let a peer
2677        // price against an inflated count while committing to fewer keys.
2678        let kp = gen_keypair();
2679        let commitment = signed_commitment(&kp, [7u8; 32], 400);
2680        let pin = commitment_hash(&commitment).expect("hash");
2681        let blob = rmp_serde::to_vec(&commitment).expect("serialize commitment");
2682        let q = quote_with_binding(500, Some(pin), calculate_price(500));
2683        let res = quote_commitment_binding_is_valid(&kp.peer_id, &q, &Some(blob));
2684        let err = res.expect_err("a quote count disagreeing with the commitment must be rejected");
2685        assert!(
2686            err.contains("key_count") || err.contains("attests"),
2687            "should fail at the count==key_count check: {err}"
2688        );
2689    }
2690
2691    #[test]
2692    fn binding_rejects_oversized_commitment_before_parsing() {
2693        // A bound quote shipping a blob larger than the sidecar cap is rejected
2694        // before any deserialize attempt (DoS guard on the hot path).
2695        let q = quote_with_binding(500, Some([9u8; 32]), calculate_price(500));
2696        let huge = Some(vec![0u8; MAX_COMMITMENT_SIDECAR_BYTES + 1]);
2697        assert!(
2698            quote_commitment_binding_is_valid(&any_peer(), &q, &huge).is_err(),
2699            "an oversized commitment blob must be rejected before payment"
2700        );
2701    }
2702
2703    #[test]
2704    fn classifier_drops_off_curve_quote_with_typed_error() {
2705        // End-to-end through the classifier: a VALIDLY-SIGNED, correctly-bound
2706        // quote for the right content, but with an off-curve price, is dropped
2707        // as BadQuoteCommitment (the forced-price extraction guard fires after
2708        // the quote's own signature/content checks pass).
2709        use ant_protocol::pqc::ops::MlDsaSecretKey;
2710        let content = [7u8; 32];
2711        let kp = gen_keypair();
2712        let mut quote = PaymentQuote {
2713            content: XorName(content),
2714            timestamp: SystemTime::UNIX_EPOCH,
2715            // claims baseline shape but charges a non-baseline price
2716            price: calculate_price(500),
2717            rewards_address: RewardsAddress::new([0u8; 20]),
2718            pub_key: kp.pub_key_bytes.clone(),
2719            signature: Vec::new(),
2720            committed_key_count: 0,
2721            commitment_pin: None,
2722        };
2723        let ml_dsa = MlDsa65::new();
2724        let sk = MlDsaSecretKey::from_bytes(&kp.secret_key_bytes).expect("sk");
2725        quote.signature = ml_dsa
2726            .sign(&sk, &quote.bytes_for_sig())
2727            .expect("sign")
2728            .as_bytes()
2729            .to_vec();
2730        let bytes = serialize_quote(&quote);
2731        let result = classify_quote_response(&kp.peer_id, &content, &bytes, false, None);
2732        assert!(
2733            matches!(result, Err(Error::BadQuoteCommitment { .. })),
2734            "off-curve quote must be dropped as BadQuoteCommitment; got {result:?}"
2735        );
2736    }
2737}