1use 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
30const FAULT_TOLERANT_QUOTE_QUERY_MULTIPLIER: usize = 2;
35
36const WITNESSED_QUORUM_NUMERATOR: usize = 2;
40const WITNESSED_QUORUM_DENOMINATOR: usize = 3;
41
42const SINGLE_NODE_WITNESSED_VIEW_COUNT: usize = 20;
44
45const MEDIAN_QUOTE_INDEX: usize = CLOSE_GROUP_SIZE / 2;
47
48const QUOTE_COLLECTION_TIMEOUT_SECS: u64 = 120;
52
53const ML_DSA_PUB_KEY_LEN: usize = 1952;
62
63type QuotedPeer = (
68 PeerId,
69 Vec<MultiAddr>,
70 PaymentQuote,
71 Amount,
72 Option<Vec<u8>>,
73);
74
75fn 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("e.pub_key) == *peer_id.as_bytes()
94}
95
96fn 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 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 let Some(pin) = pin else {
155 return Ok(());
156 };
157
158 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 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 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
200fn 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 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 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 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
304fn 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 "e,
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
458fn 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
492pub(crate) type StoreQuote = (
496 PeerId,
497 Vec<MultiAddr>,
498 PaymentQuote,
499 Amount,
500 Option<Vec<u8>>,
501);
502type StoreQuoteRequestResult = (
503 PeerId,
504 Vec<MultiAddr>,
505 Result<(PaymentQuote, Amount, Option<Vec<u8>>)>,
506);
507type VotersByPeer = HashMap<PeerId, HashSet<PeerId>>;
508type WitnessedVoteData = (HashMap<PeerId, DHTNode>, VotersByPeer, Vec<(PeerId, usize)>);
509
510pub(crate) struct StoreQuotePlan {
511 pub(crate) quotes: Vec<StoreQuote>,
512 pub(crate) put_peers: Vec<(PeerId, Vec<MultiAddr>)>,
513}
514
515#[derive(Debug, Clone)]
516struct WitnessedQuoteCandidate {
517 node: DHTNode,
518 votes: usize,
519 voters: HashSet<PeerId>,
520}
521
522#[derive(Debug, Clone)]
523struct WitnessedQuotePeer {
524 peer_id: PeerId,
525 addrs: Vec<MultiAddr>,
526 voters: HashSet<PeerId>,
527}
528
529#[derive(Debug, Clone)]
530struct WitnessedQuoteSelection {
531 quote_peers: Vec<WitnessedQuotePeer>,
532 initial_put_peers: Vec<(PeerId, Vec<MultiAddr>)>,
533 quorum: usize,
534}
535
536enum QuoteSelectionPolicy {
537 ClosestByDistance,
538 WitnessedMedianVoters {
539 voters_by_peer: VotersByPeer,
540 quorum: usize,
541 },
542}
543
544fn witnessed_initial_peers(witnessed: &WitnessedCloseGroup) -> Vec<String> {
545 witnessed
546 .initial_closest
547 .iter()
548 .map(|node| node.peer_id.to_string())
549 .collect()
550}
551
552fn witnessed_responder_views(witnessed: &WitnessedCloseGroup) -> Vec<String> {
553 witnessed
554 .responder_views
555 .iter()
556 .map(|view| {
557 let peers = view
558 .closest
559 .iter()
560 .map(|node| node.peer_id)
561 .collect::<Vec<_>>();
562 format!("{}=>{:?}", view.responder, peer_list(&peers))
563 })
564 .collect()
565}
566
567fn merge_witnessed_node(nodes: &mut HashMap<PeerId, DHTNode>, node: DHTNode) {
568 match nodes.entry(node.peer_id) {
569 std::collections::hash_map::Entry::Occupied(mut entry) => {
570 entry.get_mut().merge_from(node);
571 }
572 std::collections::hash_map::Entry::Vacant(entry) => {
573 entry.insert(node);
574 }
575 }
576}
577
578fn sort_vote_counts_by_distance(vote_counts: &mut [(PeerId, usize)], address: &[u8; 32]) {
579 vote_counts.sort_by(|left, right| {
580 peer_xor_distance(&left.0, address)
581 .cmp(&peer_xor_distance(&right.0, address))
582 .then_with(|| left.0.as_bytes().cmp(right.0.as_bytes()))
583 });
584}
585
586fn witnessed_vote_counts_and_nodes(
587 witnessed: &WitnessedCloseGroup,
588 address: &[u8; 32],
589) -> WitnessedVoteData {
590 let mut known_nodes = HashMap::new();
591 for node in &witnessed.initial_closest {
592 merge_witnessed_node(&mut known_nodes, node.clone());
593 }
594
595 let mut voters_by_peer: HashMap<PeerId, HashSet<PeerId>> = HashMap::new();
596 for view in &witnessed.responder_views {
597 let mut voted = HashSet::new();
598 for node in &view.closest {
599 merge_witnessed_node(&mut known_nodes, node.clone());
600 if voted.insert(node.peer_id) {
601 voters_by_peer
602 .entry(node.peer_id)
603 .or_default()
604 .insert(view.responder);
605 }
606 }
607 }
608
609 let mut vote_counts: Vec<(PeerId, usize)> = voters_by_peer
610 .iter()
611 .map(|(peer_id, voters)| (*peer_id, voters.len()))
612 .collect();
613 sort_vote_counts_by_distance(&mut vote_counts, address);
614 (known_nodes, voters_by_peer, vote_counts)
615}
616
617fn witnessed_consensus_candidates(
618 witnessed: &WitnessedCloseGroup,
619 address: &[u8; 32],
620 quorum: usize,
621) -> Vec<WitnessedQuoteCandidate> {
622 let (known_nodes, voters_by_peer, vote_counts) =
623 witnessed_vote_counts_and_nodes(witnessed, address);
624 let mut candidates = vote_counts
625 .iter()
626 .filter_map(|(peer_id, votes)| {
627 if *votes < quorum {
628 return None;
629 }
630 known_nodes.get(peer_id).cloned().and_then(|node| {
631 voters_by_peer
632 .get(peer_id)
633 .cloned()
634 .map(|voters| WitnessedQuoteCandidate {
635 node,
636 votes: *votes,
637 voters,
638 })
639 })
640 })
641 .collect::<Vec<_>>();
642
643 candidates.sort_by(|left, right| {
644 peer_xor_distance(&left.node.peer_id, address)
645 .cmp(&peer_xor_distance(&right.node.peer_id, address))
646 .then_with(|| right.votes.cmp(&left.votes))
647 .then_with(|| {
648 left.node
649 .peer_id
650 .as_bytes()
651 .cmp(right.node.peer_id.as_bytes())
652 })
653 });
654 candidates
655}
656
657fn witnessed_vote_counts(witnessed: &WitnessedCloseGroup, address: &[u8; 32]) -> Vec<String> {
658 let (_, _, vote_counts) = witnessed_vote_counts_and_nodes(witnessed, address);
659 vote_counts
660 .iter()
661 .map(|(peer_id, votes)| format!("{peer_id}:{votes}"))
662 .collect()
663}
664
665fn witnessed_consensus(
666 witnessed: &WitnessedCloseGroup,
667 address: &[u8; 32],
668 quorum: usize,
669) -> Vec<String> {
670 witnessed_consensus_candidates(witnessed, address, quorum)
671 .iter()
672 .map(|candidate| format!("{}:{}", candidate.node.peer_id, candidate.votes))
673 .collect()
674}
675
676fn witnessed_close_group_diagnostics(
677 address: &[u8; 32],
678 witnessed: &WitnessedCloseGroup,
679 quorum: usize,
680) -> String {
681 format!(
682 "target={}, initial={:?}, responder_views={:?}, vote_counts={:?}, quorum={}, final={:?}",
683 hex::encode(address),
684 witnessed_initial_peers(witnessed),
685 witnessed_responder_views(witnessed),
686 witnessed_vote_counts(witnessed, address),
687 quorum,
688 witnessed_consensus(witnessed, address, quorum)
689 )
690}
691
692fn witnessed_quote_selection_or_error(
693 address: &[u8; 32],
694 witnessed: &WitnessedCloseGroup,
695 required: usize,
696 quorum: usize,
697) -> Result<WitnessedQuoteSelection> {
698 let candidates = witnessed_consensus_candidates(witnessed, address, quorum);
699 if candidates.len() < required {
700 return Err(Error::InsufficientPeers(format!(
701 "Witnessed close group inconclusive before payment: got {}/{} quorum-recognised peers. {}",
702 candidates.len(),
703 required,
704 witnessed_close_group_diagnostics(address, witnessed, quorum)
705 )));
706 }
707
708 let initial_put_peers = witnessed
709 .initial_closest
710 .iter()
711 .take(CLOSE_GROUP_SIZE)
712 .map(|node| (node.peer_id, node.addresses_by_priority()))
713 .collect::<Vec<_>>();
714
715 if initial_put_peers.len() < CLOSE_GROUP_SIZE {
716 return Err(Error::InsufficientPeers(format!(
717 "Witnessed close group returned only {}/{} initial PUT peers before payment. {}",
718 initial_put_peers.len(),
719 CLOSE_GROUP_SIZE,
720 witnessed_close_group_diagnostics(address, witnessed, quorum)
721 )));
722 }
723
724 let quote_peers = candidates
725 .into_iter()
726 .map(|candidate| WitnessedQuotePeer {
727 peer_id: candidate.node.peer_id,
728 addrs: candidate.node.addresses_by_priority(),
729 voters: candidate.voters,
730 })
731 .collect();
732
733 Ok(WitnessedQuoteSelection {
734 quote_peers,
735 initial_put_peers,
736 quorum,
737 })
738}
739
740pub(crate) fn median_paid_quote_issuer(quotes: &[StoreQuote]) -> Option<(PeerId, Amount)> {
741 if quotes.len() <= MEDIAN_QUOTE_INDEX {
742 return None;
743 }
744
745 let mut by_price: Vec<(usize, PeerId, Amount)> = quotes
746 .iter()
747 .enumerate()
748 .map(|(index, (peer_id, _, _, price, _))| (index, *peer_id, *price))
749 .collect();
750 by_price.sort_by_key(|(index, _, price)| (*price, *index));
751 by_price
752 .get(MEDIAN_QUOTE_INDEX)
753 .map(|(_, peer_id, price)| (*peer_id, *price))
754}
755
756fn sort_quotes_by_distance(quotes: &mut [StoreQuote], address: &[u8; 32]) {
757 quotes.sort_by(|left, right| {
758 peer_xor_distance(&left.0, address)
759 .cmp(&peer_xor_distance(&right.0, address))
760 .then_with(|| left.0.as_bytes().cmp(right.0.as_bytes()))
761 });
762}
763
764fn median_paid_quote_issuer_for_indices(
765 quotes: &[StoreQuote],
766 indices: &[usize],
767) -> Option<(PeerId, Amount)> {
768 if indices.len() <= MEDIAN_QUOTE_INDEX {
769 return None;
770 }
771
772 let mut by_price: Vec<(usize, PeerId, Amount)> = indices
773 .iter()
774 .enumerate()
775 .map(|(selected_index, quote_index)| {
776 let (peer_id, _, _, price, _) = "es[*quote_index];
777 (selected_index, *peer_id, *price)
778 })
779 .collect();
780 by_price.sort_by_key(|(selected_index, _, price)| (*price, *selected_index));
781 by_price
782 .get(MEDIAN_QUOTE_INDEX)
783 .map(|(_, peer_id, price)| (*peer_id, *price))
784}
785
786fn median_issuer_voter_support(
787 quotes: &[StoreQuote],
788 indices: &[usize],
789 voters_by_peer: &VotersByPeer,
790) -> Option<(PeerId, usize)> {
791 let (median_peer_id, _) = median_paid_quote_issuer_for_indices(quotes, indices)?;
792 let voters = voters_by_peer.get(&median_peer_id)?;
793 Some((median_peer_id, voters.len()))
794}
795
796fn visit_quote_subsets<F>(
797 quote_count: usize,
798 subset_size: usize,
799 start_index: usize,
800 current: &mut Vec<usize>,
801 visit: &mut F,
802) where
803 F: FnMut(&[usize]),
804{
805 if current.len() == subset_size {
806 visit(current);
807 return;
808 }
809
810 let remaining = subset_size - current.len();
811 let last_start = quote_count - remaining;
812 for index in start_index..=last_start {
813 current.push(index);
814 visit_quote_subsets(quote_count, subset_size, index + 1, current, visit);
815 current.pop();
816 }
817}
818
819fn select_closest_quotes(mut quotes: Vec<StoreQuote>, address: &[u8; 32]) -> Vec<StoreQuote> {
820 sort_quotes_by_distance(&mut quotes, address);
821 quotes.truncate(CLOSE_GROUP_SIZE);
822 quotes
823}
824
825fn select_witnessed_median_voter_quotes(
826 mut quotes: Vec<StoreQuote>,
827 address: &[u8; 32],
828 voters_by_peer: &VotersByPeer,
829 required_support: usize,
830) -> Option<Vec<StoreQuote>> {
831 if quotes.len() < CLOSE_GROUP_SIZE {
832 return None;
833 }
834
835 sort_quotes_by_distance(&mut quotes, address);
836
837 let mut best_indices: Option<(usize, Vec<usize>)> = None;
838 let mut current_indices = Vec::with_capacity(CLOSE_GROUP_SIZE);
839 visit_quote_subsets(
840 quotes.len(),
841 CLOSE_GROUP_SIZE,
842 0,
843 &mut current_indices,
844 &mut |indices| {
845 let Some((_, support)) = median_issuer_voter_support("es, indices, voters_by_peer)
846 else {
847 return;
848 };
849 if support < required_support {
850 return;
851 }
852 match &best_indices {
853 Some((best_support, best)) if *best_support > support => {}
854 Some((best_support, best))
855 if *best_support == support && best.as_slice() <= indices => {}
856 _ => best_indices = Some((support, indices.to_vec())),
857 }
858 },
859 );
860
861 best_indices.map(|(_, indices)| {
862 indices
863 .into_iter()
864 .map(|index| quotes[index].clone())
865 .collect()
866 })
867}
868
869fn put_peers_with_median_voters_first(
870 quotes: &[StoreQuote],
871 put_peers: &[(PeerId, Vec<MultiAddr>)],
872 voters_by_peer: &VotersByPeer,
873 required_support: usize,
874) -> Option<Vec<(PeerId, Vec<MultiAddr>)>> {
875 let (median_peer_id, _) = median_paid_quote_issuer(quotes)?;
876 let voters = voters_by_peer.get(&median_peer_id)?;
877
878 let mut supporting_peers = Vec::new();
879 let mut fallback_peers = Vec::new();
880 for (peer_id, addrs) in put_peers {
881 let peer = (*peer_id, addrs.clone());
882 if voters.contains(peer_id) {
883 supporting_peers.push(peer);
884 } else {
885 fallback_peers.push(peer);
886 }
887 }
888
889 if supporting_peers.len() < required_support {
890 return None;
891 }
892
893 supporting_peers.extend(fallback_peers);
894 Some(supporting_peers)
895}
896
897impl Client {
898 pub async fn get_store_quotes(
914 &self,
915 address: &[u8; 32],
916 data_size: u64,
917 data_type: u32,
918 ) -> Result<Vec<StoreQuote>> {
919 Ok(self
920 .get_store_quote_plan(address, data_size, data_type)
921 .await?
922 .quotes)
923 }
924
925 pub(crate) async fn get_store_quote_plan(
932 &self,
933 address: &[u8; 32],
934 data_size: u64,
935 data_type: u32,
936 ) -> Result<StoreQuotePlan> {
937 let witnessed_selection = self.select_witnessed_quote_selection(address).await?;
938 let voters_by_peer: VotersByPeer = witnessed_selection
939 .quote_peers
940 .iter()
941 .map(|peer| (peer.peer_id, peer.voters.clone()))
942 .collect();
943 let remote_peers = witnessed_selection
944 .quote_peers
945 .into_iter()
946 .map(|peer| (peer.peer_id, peer.addrs))
947 .collect();
948 let initial_put_peers = witnessed_selection.initial_put_peers;
949 let quorum = witnessed_selection.quorum;
950 let quotes = self
951 .collect_store_quotes_from_remote_peers(
952 address,
953 data_size,
954 data_type,
955 remote_peers,
956 QuoteSelectionPolicy::WitnessedMedianVoters {
957 voters_by_peer: voters_by_peer.clone(),
958 quorum,
959 },
960 )
961 .await?;
962 let put_peers = put_peers_with_median_voters_first(
963 "es,
964 &initial_put_peers,
965 &voters_by_peer,
966 quorum,
967 )
968 .ok_or_else(|| {
969 Error::InsufficientPeers(format!(
970 "Collected {} witnessed quotes, but fewer than {} initial witness PUT peers \
971 voted for the paid median issuer for {}",
972 quotes.len(),
973 quorum,
974 hex::encode(address)
975 ))
976 })?;
977
978 Ok(StoreQuotePlan { quotes, put_peers })
979 }
980
981 pub(crate) async fn get_store_quotes_with_fault_tolerance(
988 &self,
989 address: &[u8; 32],
990 data_size: u64,
991 data_type: u32,
992 ) -> Result<Vec<StoreQuote>> {
993 let peer_query_count = fault_tolerant_quote_query_count();
994 let remote_peers = self
995 .network()
996 .find_closest_peers(address, peer_query_count)
997 .await?;
998
999 self.collect_store_quotes_from_remote_peers(
1000 address,
1001 data_size,
1002 data_type,
1003 remote_peers,
1004 QuoteSelectionPolicy::ClosestByDistance,
1005 )
1006 .await
1007 }
1008
1009 async fn select_witnessed_quote_selection(
1010 &self,
1011 address: &[u8; 32],
1012 ) -> Result<WitnessedQuoteSelection> {
1013 let required = single_node_quote_query_count();
1015 let witnessed = match self
1021 .network()
1022 .find_witnessed_close_group_with_view_count(
1023 address,
1024 PUT_TARGET_WIDTH,
1025 SINGLE_NODE_WITNESSED_VIEW_COUNT,
1026 )
1027 .await
1028 {
1029 Ok(witnessed) => witnessed,
1030 Err(wide_err) => {
1031 debug!(
1032 target = %hex::encode(address),
1033 "Wide witnessed lookup ({PUT_TARGET_WIDTH}) failed ({wide_err}); \
1034 retrying at close-group width ({required})"
1035 );
1036 self.network()
1037 .find_witnessed_close_group_with_view_count(
1038 address,
1039 required,
1040 SINGLE_NODE_WITNESSED_VIEW_COUNT,
1041 )
1042 .await
1043 .map_err(|e| {
1044 Error::InsufficientPeers(format!(
1045 "Witnessed close group lookup failed before payment for target {}: {e}",
1046 hex::encode(address)
1047 ))
1048 })?
1049 }
1050 };
1051 let witnessed_quote = scope_witnessed_to_close_group(&witnessed);
1054 let base_quorum = witnessed_close_group_quorum();
1055 let missing_views = missing_witnessed_responder_views(&witnessed_quote);
1056 let quorum = witnessed_close_group_quorum_for_transcript(&witnessed_quote);
1057
1058 if missing_views > 0 {
1059 warn!(
1060 target = %hex::encode(address),
1061 initial = witnessed_quote.initial_closest.len(),
1062 responder_views = witnessed_quote.responder_views.len(),
1063 missing_views = missing_views,
1064 base_quorum = base_quorum,
1065 adjusted_quorum = quorum,
1066 "Witnessed close group transcript is missing responder views; lowering SNP witness quorum"
1067 );
1068 }
1069
1070 debug!(
1071 target = %hex::encode(address),
1072 quorum = quorum,
1073 view_count = SINGLE_NODE_WITNESSED_VIEW_COUNT,
1074 initial = ?witnessed_initial_peers(&witnessed_quote),
1075 responder_views = ?witnessed_responder_views(&witnessed_quote),
1076 vote_counts = ?witnessed_vote_counts(&witnessed_quote, address),
1077 final_witnessed_set = ?witnessed_consensus(&witnessed_quote, address, quorum),
1078 "Witnessed close group selected for SNP quote collection"
1079 );
1080
1081 let mut selection =
1082 witnessed_quote_selection_or_error(address, &witnessed_quote, required, quorum)?;
1083 selection.initial_put_peers = witnessed
1087 .initial_closest
1088 .iter()
1089 .take(PUT_TARGET_WIDTH)
1090 .map(|node| (node.peer_id, node.addresses_by_priority()))
1091 .collect();
1092 Ok(selection)
1093 }
1094
1095 #[allow(clippy::too_many_lines)]
1096 async fn collect_store_quotes_from_remote_peers(
1097 &self,
1098 address: &[u8; 32],
1099 data_size: u64,
1100 data_type: u32,
1101 remote_peers: Vec<(PeerId, Vec<MultiAddr>)>,
1102 quote_selection_policy: QuoteSelectionPolicy,
1103 ) -> Result<Vec<StoreQuote>> {
1104 let peer_query_count = remote_peers.len();
1105
1106 let node = self.network().node();
1107
1108 debug!(
1109 "Requesting quotes from up to {peer_query_count} peers for address {} (size: {data_size})",
1110 hex::encode(address)
1111 );
1112
1113 if remote_peers.len() < CLOSE_GROUP_SIZE {
1114 return Err(Error::InsufficientPeers(format!(
1115 "Found {} peers, need {CLOSE_GROUP_SIZE}",
1116 remote_peers.len()
1117 )));
1118 }
1119 debug_assert!(peer_query_count >= CLOSE_GROUP_SIZE);
1120
1121 let per_peer_timeout = Duration::from_secs(self.config().quote_timeout_secs);
1122 let overall_timeout = Duration::from_secs(QUOTE_COLLECTION_TIMEOUT_SECS);
1123
1124 let mut quotes = Vec::with_capacity(peer_query_count);
1128 let mut already_stored_peers: Vec<(PeerId, [u8; 32])> = Vec::new();
1129 let mut failures: Vec<String> = Vec::new();
1130
1131 let mut bad_quote_count = 0usize;
1136
1137 let staged_witnessed_collection = matches!(
1138 "e_selection_policy,
1139 QuoteSelectionPolicy::WitnessedMedianVoters { .. }
1140 );
1141
1142 if staged_witnessed_collection {
1143 let mut quote_futures = FuturesUnordered::new();
1144 let mut next_peer_index = 0usize;
1145 let collect_result: std::result::Result<std::result::Result<(), Error>, _> =
1146 tokio::time::timeout(overall_timeout, async {
1147 loop {
1148 let launch_count = witnessed_quote_launch_budget(
1149 quotes.len(),
1150 quote_futures.len(),
1151 remote_peers.len().saturating_sub(next_peer_index),
1152 );
1153 for _ in 0..launch_count {
1154 let (peer_id, peer_addrs) = &remote_peers[next_peer_index];
1155 next_peer_index += 1;
1156 quote_futures.push(request_store_quote_from_peer(
1157 node.clone(),
1158 *peer_id,
1159 peer_addrs.clone(),
1160 self.next_request_id(),
1161 *address,
1162 data_size,
1163 data_type,
1164 per_peer_timeout,
1165 ));
1166 }
1167
1168 if quotes.len() >= CLOSE_GROUP_SIZE || quote_futures.is_empty() {
1169 break;
1170 }
1171
1172 let Some((peer_id, addrs, quote_result)) = quote_futures.next().await
1173 else {
1174 break;
1175 };
1176 record_store_quote_result(
1177 peer_id,
1178 addrs,
1179 quote_result,
1180 address,
1181 &mut quotes,
1182 &mut already_stored_peers,
1183 &mut failures,
1184 &mut bad_quote_count,
1185 );
1186 }
1187 Ok(())
1188 })
1189 .await;
1190
1191 match collect_result {
1192 Err(_elapsed) => {
1193 warn!(
1194 "Quote collection timed out after {overall_timeout:?} for address {}",
1195 hex::encode(address)
1196 );
1197 }
1198 Ok(Err(e)) => return Err(e),
1199 Ok(Ok(())) => {}
1200 }
1201 } else {
1202 let mut quote_futures = FuturesUnordered::new();
1206
1207 for (peer_id, peer_addrs) in &remote_peers {
1208 quote_futures.push(request_store_quote_from_peer(
1209 node.clone(),
1210 *peer_id,
1211 peer_addrs.clone(),
1212 self.next_request_id(),
1213 *address,
1214 data_size,
1215 data_type,
1216 per_peer_timeout,
1217 ));
1218 }
1219
1220 let collect_result: std::result::Result<std::result::Result<(), Error>, _> =
1221 tokio::time::timeout(overall_timeout, async {
1222 while let Some((peer_id, addrs, quote_result)) = quote_futures.next().await {
1223 record_store_quote_result(
1224 peer_id,
1225 addrs,
1226 quote_result,
1227 address,
1228 &mut quotes,
1229 &mut already_stored_peers,
1230 &mut failures,
1231 &mut bad_quote_count,
1232 );
1233 }
1234 Ok(())
1235 })
1236 .await;
1237
1238 match collect_result {
1239 Err(_elapsed) => {
1240 warn!(
1241 "Quote collection timed out after {overall_timeout:?} for address {}",
1242 hex::encode(address)
1243 );
1244 }
1248 Ok(Err(e)) => return Err(e),
1249 Ok(Ok(())) => {}
1250 }
1251 }
1252
1253 let bad_dropped = drop_quotes_with_bad_bindings(&mut quotes);
1259 if bad_dropped > 0 {
1260 warn!(
1261 "Defensive filter dropped {bad_dropped} quotes with mismatched peer bindings \
1262 for address {} — the per-peer handler should have caught these earlier \
1263 (this indicates an upstream regression)",
1264 hex::encode(address),
1265 );
1266 bad_quote_count += bad_dropped;
1267 }
1268
1269 if !already_stored_peers.is_empty() {
1271 let mut all_peers_by_distance: Vec<(bool, [u8; 32])> = Vec::new();
1272 for (peer_id, _, _, _, _) in "es {
1273 all_peers_by_distance.push((false, peer_xor_distance(peer_id, address)));
1274 }
1275 for (_, dist) in &already_stored_peers {
1276 all_peers_by_distance.push((true, *dist));
1277 }
1278 all_peers_by_distance.sort_by_key(|a| a.1);
1279
1280 let close_group_stored = all_peers_by_distance
1281 .iter()
1282 .take(CLOSE_GROUP_SIZE)
1283 .filter(|(is_stored, _)| *is_stored)
1284 .count();
1285
1286 if close_group_stored >= CLOSE_GROUP_MAJORITY {
1287 debug!(
1288 "Chunk {} already stored ({close_group_stored}/{CLOSE_GROUP_SIZE} close-group peers confirm)",
1289 hex::encode(address)
1290 );
1291 return Err(Error::AlreadyStored);
1292 }
1293 }
1294
1295 let already_stored_count = already_stored_peers.len();
1296 let failure_count = failures.len();
1297 let quote_count = quotes.len();
1298 let total_responses = quote_count + failure_count + already_stored_count;
1299
1300 if quotes.len() >= CLOSE_GROUP_SIZE {
1301 let selected_quotes = match quote_selection_policy {
1302 QuoteSelectionPolicy::ClosestByDistance => select_closest_quotes(quotes, address),
1303 QuoteSelectionPolicy::WitnessedMedianVoters {
1304 voters_by_peer,
1305 quorum,
1306 } => select_witnessed_median_voter_quotes(quotes, address, &voters_by_peer, quorum)
1307 .ok_or_else(|| {
1308 Error::InsufficientPeers(format!(
1309 "Got {quote_count} quotes, need {CLOSE_GROUP_SIZE} whose paid \
1310 median issuer is recognised by at least {} \
1311 selected witness peers ({total_responses} responses: \
1312 {already_stored_count} already_stored, {failure_count} failed \
1313 including {bad_quote_count} with mismatched peer bindings). \
1314 Failures: [{}]",
1315 quorum,
1316 failures.join("; ")
1317 ))
1318 })?,
1319 };
1320
1321 info!(
1322 "Collected {} quotes for address {} ({total_responses} responses: \
1323 {quote_count} ok, {already_stored_count} already_stored, {failure_count} failed, \
1324 {bad_quote_count} bad-binding)",
1325 selected_quotes.len(),
1326 hex::encode(address),
1327 );
1328 return Ok(selected_quotes);
1329 }
1330
1331 Err(Error::InsufficientPeers(format!(
1332 "Got {quote_count} quotes, need {CLOSE_GROUP_SIZE} ({total_responses} responses: \
1333 {already_stored_count} already_stored, {failure_count} failed including \
1334 {bad_quote_count} with mismatched peer bindings). Failures: [{}]",
1335 failures.join("; ")
1336 )))
1337 }
1338}
1339
1340#[cfg(test)]
1341#[allow(clippy::unwrap_used, clippy::expect_used)]
1342mod tests {
1343 use super::*;
1355 use ant_protocol::evm::RewardsAddress;
1356 use ant_protocol::pqc::ops::{MlDsaOperations, MlDsaPublicKey};
1357 use ant_protocol::transport::{DHTNode, MlDsa65, ResponderView, WitnessedCloseGroup};
1358 use std::time::SystemTime;
1359 use xor_name::XorName;
1360
1361 struct Keypair {
1363 peer_id: PeerId,
1364 pub_key_bytes: Vec<u8>,
1365 secret_key_bytes: Vec<u8>,
1366 }
1367
1368 fn gen_keypair() -> Keypair {
1369 let ml_dsa = MlDsa65::new();
1370 let (pub_key, sk) = ml_dsa.generate_keypair().expect("ML-DSA-65 keygen");
1371 let pub_key_bytes = pub_key.as_bytes().to_vec();
1372 let peer_id = PeerId::from_bytes(compute_address(&pub_key_bytes));
1373 Keypair {
1374 peer_id,
1375 pub_key_bytes,
1376 secret_key_bytes: sk.as_bytes().to_vec(),
1377 }
1378 }
1379
1380 fn signed_baseline_quote(content: [u8; 32]) -> (PeerId, PaymentQuote) {
1384 use ant_protocol::pqc::ops::MlDsaSecretKey;
1385 let kp = gen_keypair();
1386 let mut quote = PaymentQuote {
1387 content: XorName(content),
1388 timestamp: SystemTime::UNIX_EPOCH,
1389 price: calculate_price(0),
1390 rewards_address: RewardsAddress::new([0u8; 20]),
1391 pub_key: kp.pub_key_bytes.clone(),
1392 signature: Vec::new(),
1393 committed_key_count: 0,
1394 commitment_pin: None,
1395 };
1396 let ml_dsa = MlDsa65::new();
1397 let sk = MlDsaSecretKey::from_bytes(&kp.secret_key_bytes).expect("sk");
1398 let msg = quote.bytes_for_sig();
1399 quote.signature = ml_dsa.sign(&sk, &msg).expect("sign").as_bytes().to_vec();
1400 (kp.peer_id, quote)
1401 }
1402
1403 fn good_quote_real() -> QuotedPeer {
1411 let kp = gen_keypair();
1412 let quote = PaymentQuote {
1413 content: XorName([0u8; 32]),
1414 timestamp: SystemTime::UNIX_EPOCH,
1415 price: calculate_price(0),
1416 rewards_address: RewardsAddress::new([0u8; 20]),
1417 pub_key: kp.pub_key_bytes,
1418 signature: Vec::new(),
1419 committed_key_count: 0,
1420 commitment_pin: None,
1421 };
1422 (kp.peer_id, Vec::new(), quote, calculate_price(0), None)
1423 }
1424
1425 fn bad_quote_real() -> QuotedPeer {
1430 let claimed = gen_keypair();
1431 let signing = gen_keypair();
1432 assert_ne!(claimed.pub_key_bytes, signing.pub_key_bytes);
1433 assert_ne!(claimed.peer_id.as_bytes(), signing.peer_id.as_bytes());
1434 let quote = PaymentQuote {
1435 content: XorName([0u8; 32]),
1436 timestamp: SystemTime::UNIX_EPOCH,
1437 price: calculate_price(0),
1438 rewards_address: RewardsAddress::new([0u8; 20]),
1439 pub_key: signing.pub_key_bytes,
1440 signature: Vec::new(),
1441 committed_key_count: 0,
1442 commitment_pin: None,
1443 };
1444 (claimed.peer_id, Vec::new(), quote, calculate_price(0), None)
1445 }
1446
1447 fn witnessed_test_node(seed: u8) -> DHTNode {
1448 DHTNode {
1449 peer_id: PeerId::from_bytes([seed; 32]),
1450 addresses: Vec::new(),
1451 address_types: Vec::new(),
1452 distance: None,
1453 reliability: 1.0,
1454 }
1455 }
1456
1457 fn witnessed_test_nodes(seeds: &[u8]) -> Vec<DHTNode> {
1458 seeds.iter().copied().map(witnessed_test_node).collect()
1459 }
1460
1461 fn witnessed_test_view(responder: u8, closest: &[u8]) -> ResponderView {
1462 ResponderView {
1463 responder: PeerId::from_bytes([responder; 32]),
1464 closest: witnessed_test_nodes(closest),
1465 }
1466 }
1467
1468 fn synthetic_peer(seed: u8) -> PeerId {
1469 PeerId::from_bytes([seed; 32])
1470 }
1471
1472 fn synthetic_quote(
1473 seed: u8,
1474 price: u64,
1475 ) -> (
1476 PeerId,
1477 Vec<MultiAddr>,
1478 PaymentQuote,
1479 Amount,
1480 Option<Vec<u8>>,
1481 ) {
1482 let amount = Amount::from(price);
1483 let quote = PaymentQuote {
1484 content: XorName([0u8; 32]),
1485 timestamp: SystemTime::UNIX_EPOCH,
1486 price: amount,
1487 rewards_address: RewardsAddress::new([0u8; 20]),
1488 pub_key: Vec::new(),
1489 signature: Vec::new(),
1490 committed_key_count: 0,
1491 commitment_pin: None,
1492 };
1493 (synthetic_peer(seed), Vec::new(), quote, amount, None)
1494 }
1495
1496 fn synthetic_voters(seeds: &[u8]) -> HashSet<PeerId> {
1497 seeds.iter().copied().map(synthetic_peer).collect()
1498 }
1499
1500 fn quote_peer_seeds(quotes: &[StoreQuote]) -> Vec<u8> {
1501 quotes
1502 .iter()
1503 .map(|(peer_id, _, _, _, _)| peer_id.as_bytes()[0])
1504 .collect()
1505 }
1506
1507 fn put_peer_seeds(peers: &[(PeerId, Vec<MultiAddr>)]) -> Vec<u8> {
1508 peers
1509 .iter()
1510 .map(|(peer_id, _)| peer_id.as_bytes()[0])
1511 .collect()
1512 }
1513
1514 fn put_peers_from_seeds(seeds: &[u8]) -> Vec<(PeerId, Vec<MultiAddr>)> {
1515 seeds
1516 .iter()
1517 .copied()
1518 .map(|seed| (synthetic_peer(seed), Vec::new()))
1519 .collect()
1520 }
1521
1522 fn storer_binding_would_accept(peer_id: &PeerId, quote: &PaymentQuote) -> bool {
1531 if MlDsaPublicKey::from_bytes("e.pub_key).is_err() {
1532 return false;
1533 }
1534 compute_address("e.pub_key) == *peer_id.as_bytes()
1535 }
1536
1537 #[test]
1542 fn binding_accepts_real_self_consistent_keypair() {
1543 let (peer_id, _, quote, _, _) = good_quote_real();
1544 assert!(quote_binding_is_valid(&peer_id, "e));
1547 assert!(storer_binding_would_accept(&peer_id, "e));
1549 }
1550
1551 #[test]
1552 fn binding_rejects_real_crossed_keypair() {
1553 let (peer_id, _, quote, _, _) = bad_quote_real();
1554 assert!(!quote_binding_is_valid(&peer_id, "e));
1555 assert!(!storer_binding_would_accept(&peer_id, "e));
1556 }
1557
1558 #[test]
1559 fn binding_rejects_oversize_pubkey() {
1560 let oversized = vec![0u8; ML_DSA_PUB_KEY_LEN + 1];
1564 let peer_id = PeerId::from_bytes(compute_address(&oversized));
1565 let quote = PaymentQuote {
1566 content: XorName([0u8; 32]),
1567 timestamp: SystemTime::UNIX_EPOCH,
1568 price: Amount::ZERO,
1569 rewards_address: RewardsAddress::new([0u8; 20]),
1570 pub_key: oversized,
1571 signature: Vec::new(),
1572 committed_key_count: 0,
1573 commitment_pin: None,
1574 };
1575 assert_eq!(compute_address("e.pub_key), *peer_id.as_bytes());
1578 assert!(
1579 !quote_binding_is_valid(&peer_id, "e),
1580 "predicate must reject oversize pub_key even when BLAKE3 happens to match"
1581 );
1582 assert!(!storer_binding_would_accept(&peer_id, "e));
1583 }
1584
1585 #[test]
1586 fn binding_rejects_undersize_pubkey() {
1587 let undersized = vec![0u8; ML_DSA_PUB_KEY_LEN - 1];
1588 let peer_id = PeerId::from_bytes(compute_address(&undersized));
1589 let quote = PaymentQuote {
1590 content: XorName([0u8; 32]),
1591 timestamp: SystemTime::UNIX_EPOCH,
1592 price: Amount::ZERO,
1593 rewards_address: RewardsAddress::new([0u8; 20]),
1594 pub_key: undersized,
1595 signature: Vec::new(),
1596 committed_key_count: 0,
1597 commitment_pin: None,
1598 };
1599 assert!(!quote_binding_is_valid(&peer_id, "e));
1600 assert!(!storer_binding_would_accept(&peer_id, "e));
1601 }
1602
1603 #[test]
1608 fn quote_query_counts_keep_single_node_close_group_only() {
1609 assert_eq!(single_node_quote_query_count(), CLOSE_GROUP_SIZE);
1610 assert_eq!(SINGLE_NODE_WITNESSED_VIEW_COUNT, 20);
1611 assert!(SINGLE_NODE_WITNESSED_VIEW_COUNT > single_node_quote_query_count());
1612 assert_eq!(witnessed_close_group_quorum(), 5);
1613 assert_eq!(witnessed_close_group_quorum_for_missing_views(0), 5);
1614 assert_eq!(witnessed_close_group_quorum_for_missing_views(1), 4);
1615 assert_eq!(witnessed_close_group_quorum_for_missing_views(2), 3);
1616 assert_eq!(
1617 fault_tolerant_quote_query_count(),
1618 CLOSE_GROUP_SIZE * FAULT_TOLERANT_QUOTE_QUERY_MULTIPLIER
1619 );
1620 assert!(fault_tolerant_quote_query_count() > single_node_quote_query_count());
1621 }
1622
1623 #[test]
1624 fn witnessed_quote_launch_budget_keeps_exact_quote_window() {
1625 assert_eq!(
1626 witnessed_quote_launch_budget(0, 0, CLOSE_GROUP_SIZE * 2),
1627 CLOSE_GROUP_SIZE,
1628 "initial SNP quote fetch should launch the closest seven peers"
1629 );
1630 assert_eq!(
1631 witnessed_quote_launch_budget(1, CLOSE_GROUP_SIZE - 1, CLOSE_GROUP_SIZE),
1632 0,
1633 "a successful quote should not launch an extra fallback"
1634 );
1635 assert_eq!(
1636 witnessed_quote_launch_budget(0, CLOSE_GROUP_SIZE - 1, CLOSE_GROUP_SIZE),
1637 1,
1638 "a failed in-flight quote should launch the next closest fallback"
1639 );
1640 assert_eq!(
1641 witnessed_quote_launch_budget(CLOSE_GROUP_SIZE - 1, 0, 3),
1642 1,
1643 "only one more peer is needed for the seventh quote"
1644 );
1645 assert_eq!(
1646 witnessed_quote_launch_budget(0, 0, CLOSE_GROUP_SIZE - 1),
1647 CLOSE_GROUP_SIZE - 1,
1648 "launch budget is capped by remaining candidates"
1649 );
1650 }
1651
1652 #[test]
1653 fn witnessed_candidates_sort_by_xor_distance_then_votes() {
1654 let address = [0u8; 32];
1655 let witnessed = WitnessedCloseGroup {
1656 target: address,
1657 k: CLOSE_GROUP_SIZE,
1658 initial_closest: witnessed_test_nodes(&[1, 2, 3, 4, 5, 6, 7]),
1659 responder_views: vec![
1660 witnessed_test_view(1, &[1, 9]),
1661 witnessed_test_view(2, &[1, 9]),
1662 witnessed_test_view(3, &[1, 9]),
1663 witnessed_test_view(4, &[1, 9]),
1664 witnessed_test_view(5, &[1, 9]),
1665 witnessed_test_view(6, &[9]),
1666 witnessed_test_view(7, &[9]),
1667 ],
1668 };
1669
1670 let candidates =
1671 witnessed_consensus_candidates(&witnessed, &address, witnessed_close_group_quorum());
1672
1673 assert_eq!(
1674 candidates
1675 .iter()
1676 .map(|candidate| candidate.node.peer_id.as_bytes()[0])
1677 .collect::<Vec<_>>(),
1678 vec![1, 9],
1679 "XOR closeness must be the primary sort before quote collection"
1680 );
1681 }
1682
1683 fn ascending_seeds(count: usize) -> Vec<u8> {
1685 (1..=count)
1686 .map(|n| u8::try_from(n).expect("test seed fits in u8"))
1687 .collect()
1688 }
1689
1690 #[test]
1691 fn scope_witnessed_to_close_group_matches_native_close_group_query() {
1692 const RESPONDED_IN_SCOPE: usize = 5;
1696 const OUT_OF_SCOPE_RESPONDERS: usize = 2;
1698
1699 let address = [0u8; 32];
1700 let close_seeds = ascending_seeds(CLOSE_GROUP_SIZE);
1701 let view_closest = [1, 2, 8, 9];
1704 let in_scope_views = || -> Vec<ResponderView> {
1705 ascending_seeds(RESPONDED_IN_SCOPE)
1706 .into_iter()
1707 .map(|responder| witnessed_test_view(responder, &view_closest))
1708 .collect()
1709 };
1710
1711 let mut wide_views = in_scope_views();
1715 for offset in 1..=OUT_OF_SCOPE_RESPONDERS {
1716 let responder =
1717 u8::try_from(CLOSE_GROUP_SIZE + offset).expect("out-of-scope seed fits in u8");
1718 wide_views.push(witnessed_test_view(responder, &[1, 2, 3]));
1719 }
1720 let wide = WitnessedCloseGroup {
1721 target: address,
1722 k: PUT_TARGET_WIDTH,
1723 initial_closest: witnessed_test_nodes(&ascending_seeds(PUT_TARGET_WIDTH)),
1724 responder_views: wide_views,
1725 };
1726
1727 let native = WitnessedCloseGroup {
1730 target: address,
1731 k: CLOSE_GROUP_SIZE,
1732 initial_closest: witnessed_test_nodes(&close_seeds),
1733 responder_views: in_scope_views(),
1734 };
1735
1736 let scoped = scope_witnessed_to_close_group(&wide);
1737
1738 assert_eq!(scoped.target, wide.target);
1740 assert_eq!(scoped.k, CLOSE_GROUP_SIZE);
1741 assert_eq!(
1742 scoped
1743 .initial_closest
1744 .iter()
1745 .map(|node| node.peer_id.as_bytes()[0])
1746 .collect::<Vec<_>>(),
1747 close_seeds,
1748 "initial set must be the closest CLOSE_GROUP_SIZE, in order"
1749 );
1750
1751 assert_eq!(
1754 scoped
1755 .responder_views
1756 .iter()
1757 .map(|view| view.responder.as_bytes()[0])
1758 .collect::<Vec<_>>(),
1759 ascending_seeds(RESPONDED_IN_SCOPE),
1760 "only responders inside the close group survive"
1761 );
1762 assert_eq!(
1763 scoped.responder_views[0]
1764 .closest
1765 .iter()
1766 .map(|node| node.peer_id.as_bytes()[0])
1767 .collect::<Vec<_>>(),
1768 view_closest.to_vec(),
1769 "a surviving view's closest set must be preserved verbatim"
1770 );
1771
1772 assert_eq!(
1775 missing_witnessed_responder_views(&scoped),
1776 missing_witnessed_responder_views(&native),
1777 );
1778 let quorum = witnessed_close_group_quorum_for_transcript(&scoped);
1779 assert_eq!(quorum, witnessed_close_group_quorum_for_transcript(&native));
1780 let candidate_seeds = |group: &WitnessedCloseGroup| {
1781 witnessed_consensus_candidates(group, &address, quorum)
1782 .iter()
1783 .map(|candidate| candidate.node.peer_id.as_bytes()[0])
1784 .collect::<Vec<_>>()
1785 };
1786 assert_eq!(
1787 candidate_seeds(&scoped),
1788 candidate_seeds(&native),
1789 "scoped consensus must match a native close-group query"
1790 );
1791 }
1792
1793 #[test]
1794 fn witnessed_quote_peers_error_is_typed_and_pre_payment_when_consensus_is_short() {
1795 let address = [0u8; 32];
1796 let responder_views = (1..=7)
1797 .map(|responder| witnessed_test_view(responder, &[1, 2, 3, 4]))
1798 .collect();
1799 let witnessed = WitnessedCloseGroup {
1800 target: address,
1801 k: CLOSE_GROUP_SIZE,
1802 initial_closest: witnessed_test_nodes(&[1, 2, 3, 4, 5, 6, 7]),
1803 responder_views,
1804 };
1805
1806 let err = witnessed_quote_selection_or_error(
1807 &address,
1808 &witnessed,
1809 CLOSE_GROUP_SIZE,
1810 witnessed_close_group_quorum(),
1811 )
1812 .expect_err("short witnessed consensus must fail before payment");
1813
1814 match err {
1815 Error::InsufficientPeers(message) => {
1816 assert!(message.contains("before payment"));
1817 assert!(message.contains("vote_counts"));
1818 assert!(message.contains("quorum"));
1819 }
1820 other => panic!("expected typed InsufficientPeers error, got {other:?}"),
1821 }
1822 }
1823
1824 #[test]
1825 fn witnessed_quote_peers_include_quorum_fallback_candidates() {
1826 const EXTRA_QUORUM_CANDIDATES: usize = 1;
1827
1828 let address = [0u8; 32];
1829 let witnessed = WitnessedCloseGroup {
1830 target: address,
1831 k: CLOSE_GROUP_SIZE,
1832 initial_closest: witnessed_test_nodes(&[1, 2, 3, 4, 5, 6, 7]),
1833 responder_views: vec![
1834 witnessed_test_view(1, &[1, 2, 3, 4, 5, 6, 7]),
1835 witnessed_test_view(2, &[1, 2, 3, 4, 5, 6, 8]),
1836 witnessed_test_view(3, &[1, 2, 3, 4, 5, 7, 8]),
1837 witnessed_test_view(4, &[1, 2, 3, 4, 6, 7, 8]),
1838 witnessed_test_view(5, &[1, 2, 3, 5, 6, 7, 8]),
1839 witnessed_test_view(6, &[1, 2, 4, 5, 6, 7, 8]),
1840 witnessed_test_view(7, &[1, 3, 4, 5, 6, 7, 8]),
1841 ],
1842 };
1843
1844 let selection = witnessed_quote_selection_or_error(
1845 &address,
1846 &witnessed,
1847 CLOSE_GROUP_SIZE,
1848 witnessed_close_group_quorum(),
1849 )
1850 .expect("fallback candidates should be retained for quote collection");
1851
1852 assert_eq!(
1853 selection.quote_peers.len(),
1854 CLOSE_GROUP_SIZE + EXTRA_QUORUM_CANDIDATES
1855 );
1856 assert_eq!(
1857 selection
1858 .quote_peers
1859 .iter()
1860 .map(|peer| peer.peer_id.as_bytes()[0])
1861 .collect::<Vec<_>>(),
1862 vec![1, 2, 3, 4, 5, 6, 7, 8]
1863 );
1864 assert_eq!(
1865 put_peer_seeds(&selection.initial_put_peers),
1866 vec![1, 2, 3, 4, 5, 6, 7]
1867 );
1868 }
1869
1870 #[test]
1871 fn witnessed_quote_peers_lower_quorum_for_missing_responder_views() {
1872 let address = [0u8; 32];
1873 let witnessed = WitnessedCloseGroup {
1874 target: address,
1875 k: CLOSE_GROUP_SIZE,
1876 initial_closest: witnessed_test_nodes(&[1, 2, 3, 4, 5, 6, 7]),
1877 responder_views: vec![
1878 witnessed_test_view(1, &[1, 2, 3, 4, 5, 6, 7]),
1879 witnessed_test_view(2, &[1, 2, 3, 4, 5, 6, 8]),
1880 witnessed_test_view(3, &[1, 2, 3, 4, 5, 7, 8]),
1881 witnessed_test_view(4, &[1, 2, 3, 4, 6, 7, 8]),
1882 witnessed_test_view(5, &[1, 2, 3, 5, 6, 7, 8]),
1883 witnessed_test_view(6, &[1, 2, 4, 5, 6, 7, 8]),
1884 ],
1885 };
1886 let quorum = witnessed_close_group_quorum_for_transcript(&witnessed);
1887
1888 assert_eq!(missing_witnessed_responder_views(&witnessed), 1);
1889 assert_eq!(quorum, 4);
1890
1891 let selection =
1892 witnessed_quote_selection_or_error(&address, &witnessed, CLOSE_GROUP_SIZE, quorum)
1893 .expect(
1894 "one missing responder view should lower quorum and still select candidates",
1895 );
1896
1897 assert_eq!(
1898 selection
1899 .quote_peers
1900 .iter()
1901 .map(|peer| peer.peer_id.as_bytes()[0])
1902 .collect::<Vec<_>>(),
1903 vec![1, 2, 3, 4, 5, 6, 7, 8]
1904 );
1905 assert_eq!(selection.quorum, quorum);
1906 }
1907
1908 #[test]
1909 fn witnessed_quote_selection_keeps_closest_set_with_median_voter_quorum() {
1910 const MEDIAN_ISSUER_SEED: u8 = 7;
1911 const FAR_SUPPORTING_VOTER_SEED: u8 = 20;
1912 const UNSUCCESSFUL_SUPPORTING_VOTER_SEED: u8 = 21;
1913
1914 let address = [0u8; 32];
1915 let quotes = vec![
1916 synthetic_quote(1, 10),
1917 synthetic_quote(2, 20),
1918 synthetic_quote(3, 30),
1919 synthetic_quote(6, 50),
1920 synthetic_quote(MEDIAN_ISSUER_SEED, 40),
1921 synthetic_quote(8, 60),
1922 synthetic_quote(9, 70),
1923 synthetic_quote(FAR_SUPPORTING_VOTER_SEED, 80),
1924 ];
1925 let mut voters_by_peer = HashMap::new();
1926 voters_by_peer.insert(
1927 synthetic_peer(MEDIAN_ISSUER_SEED),
1928 synthetic_voters(&[
1929 1,
1930 2,
1931 3,
1932 MEDIAN_ISSUER_SEED,
1933 FAR_SUPPORTING_VOTER_SEED,
1934 UNSUCCESSFUL_SUPPORTING_VOTER_SEED,
1935 ]),
1936 );
1937
1938 let quorum = witnessed_close_group_quorum();
1939 let selected =
1940 select_witnessed_median_voter_quotes(quotes, &address, &voters_by_peer, quorum)
1941 .expect("a supported close-group quote set should be selected");
1942
1943 assert_eq!(quote_peer_seeds(&selected), vec![1, 2, 3, 6, 7, 8, 9]);
1944 let (median_peer_id, _) =
1945 median_paid_quote_issuer(&selected).expect("selected quotes have a median");
1946 assert_eq!(median_peer_id, synthetic_peer(MEDIAN_ISSUER_SEED));
1947 assert!(voters_by_peer[&median_peer_id].len() >= quorum);
1948 }
1949
1950 #[test]
1951 fn witnessed_quote_selection_uses_direct_median_witness_recognition() {
1952 const MEDIAN_ISSUER_SEED: u8 = 7;
1953
1954 let address = [0u8; 32];
1955 let quotes = vec![
1956 synthetic_quote(1, 10),
1957 synthetic_quote(2, 20),
1958 synthetic_quote(3, 30),
1959 synthetic_quote(4, 50),
1960 synthetic_quote(MEDIAN_ISSUER_SEED, 40),
1961 synthetic_quote(8, 60),
1962 synthetic_quote(9, 70),
1963 ];
1964 let mut voters_by_peer = HashMap::new();
1965 voters_by_peer.insert(
1966 synthetic_peer(MEDIAN_ISSUER_SEED),
1967 synthetic_voters(&[20, 21, 22, 23, 24]),
1968 );
1969
1970 let quorum = witnessed_close_group_quorum();
1971 let selected =
1972 select_witnessed_median_voter_quotes(quotes, &address, &voters_by_peer, quorum)
1973 .expect("direct witness recognition should support the paid median issuer");
1974
1975 let (median_peer_id, _) =
1976 median_paid_quote_issuer(&selected).expect("selected quotes have a median");
1977 let selected_peers = selected
1978 .iter()
1979 .map(|(peer_id, _, _, _, _)| *peer_id)
1980 .collect::<HashSet<_>>();
1981 assert_eq!(median_peer_id, synthetic_peer(MEDIAN_ISSUER_SEED));
1982 assert_eq!(
1983 voters_by_peer[&median_peer_id]
1984 .intersection(&selected_peers)
1985 .count(),
1986 0,
1987 "recognising witnesses need not also be selected quote issuers"
1988 );
1989 assert_eq!(voters_by_peer[&median_peer_id].len(), quorum);
1990 }
1991
1992 #[test]
1993 fn witnessed_quote_selection_rejects_median_without_witness_quorum() {
1994 const MEDIAN_ISSUER_SEED: u8 = 7;
1995
1996 let address = [0u8; 32];
1997 let quotes = vec![
1998 synthetic_quote(1, 10),
1999 synthetic_quote(2, 20),
2000 synthetic_quote(3, 30),
2001 synthetic_quote(6, 50),
2002 synthetic_quote(MEDIAN_ISSUER_SEED, 40),
2003 synthetic_quote(8, 60),
2004 synthetic_quote(9, 70),
2005 synthetic_quote(10, 80),
2006 ];
2007 let mut voters_by_peer = HashMap::new();
2008 voters_by_peer.insert(
2009 synthetic_peer(MEDIAN_ISSUER_SEED),
2010 synthetic_voters(&[1, 2, 3, 20]),
2011 );
2012
2013 let selected = select_witnessed_median_voter_quotes(
2014 quotes,
2015 &address,
2016 &voters_by_peer,
2017 witnessed_close_group_quorum(),
2018 );
2019
2020 assert!(
2021 selected.is_none(),
2022 "the selector must not return a paid quote set when fewer than the \
2023 witnessed median voter quorum recognised the paid median issuer"
2024 );
2025 }
2026
2027 #[test]
2028 fn put_peers_prioritise_median_voters_without_reordering_quotes() {
2029 const MEDIAN_ISSUER_SEED: u8 = 7;
2030
2031 let quotes = vec![
2032 synthetic_quote(1, 10),
2033 synthetic_quote(2, 20),
2034 synthetic_quote(3, 30),
2035 synthetic_quote(4, 50),
2036 synthetic_quote(5, 60),
2037 synthetic_quote(6, 70),
2038 synthetic_quote(MEDIAN_ISSUER_SEED, 40),
2039 ];
2040 let mut voters_by_peer = HashMap::new();
2041 voters_by_peer.insert(
2042 synthetic_peer(MEDIAN_ISSUER_SEED),
2043 synthetic_voters(&[3, 4, 5, 6, MEDIAN_ISSUER_SEED]),
2044 );
2045
2046 let put_candidates = put_peers_from_seeds(&[1, 2, 3, 4, 5, 6, 7]);
2047 let put_peers = put_peers_with_median_voters_first(
2048 "es,
2049 &put_candidates,
2050 &voters_by_peer,
2051 witnessed_close_group_quorum(),
2052 )
2053 .expect("median voters should produce an ordered PUT set");
2054
2055 assert_eq!(quote_peer_seeds("es), vec![1, 2, 3, 4, 5, 6, 7]);
2056 let (median_peer_id, _) =
2057 median_paid_quote_issuer("es).expect("selected quotes have a median");
2058 assert_eq!(median_peer_id, synthetic_peer(MEDIAN_ISSUER_SEED));
2059 assert_eq!(put_peer_seeds(&put_peers), vec![3, 4, 5, 6, 7, 1, 2]);
2060 }
2061
2062 #[test]
2063 fn filter_drops_only_bad_bindings_and_leaves_storer_acceptable_quotes() {
2064 let mut quotes = vec![
2065 good_quote_real(),
2066 bad_quote_real(),
2067 good_quote_real(),
2068 bad_quote_real(),
2069 good_quote_real(),
2070 ];
2071
2072 let dropped = drop_quotes_with_bad_bindings(&mut quotes);
2073
2074 assert_eq!(dropped, 2, "two crossed-key quotes must be dropped");
2075 assert_eq!(quotes.len(), 3, "three real-key quotes must remain");
2076
2077 for (peer_id, _, quote, _, _) in "es {
2083 assert!(
2084 storer_binding_would_accept(peer_id, quote),
2085 "every retained quote must satisfy the full storer-side spec"
2086 );
2087 }
2088 }
2089
2090 #[test]
2091 fn filter_is_noop_when_all_quotes_are_storer_acceptable() {
2092 let mut quotes: Vec<_> = (0..5).map(|_| good_quote_real()).collect();
2093 let before = quotes.len();
2094 let dropped = drop_quotes_with_bad_bindings(&mut quotes);
2095 assert_eq!(dropped, 0);
2096 assert_eq!(quotes.len(), before);
2097 for (peer_id, _, quote, _, _) in "es {
2098 assert!(storer_binding_would_accept(peer_id, quote));
2099 }
2100 }
2101
2102 #[test]
2103 fn filter_drops_all_when_every_responder_is_bad() {
2104 let mut quotes: Vec<_> = (0..fault_tolerant_quote_query_count())
2109 .map(|_| bad_quote_real())
2110 .collect();
2111 let dropped = drop_quotes_with_bad_bindings(&mut quotes);
2112 assert_eq!(dropped, fault_tolerant_quote_query_count());
2113 assert!(quotes.is_empty());
2114 }
2115
2116 #[test]
2117 fn filter_preserves_quote_payload_byte_for_byte() {
2118 let (peer_id, addrs, original_quote, amount, commitment) = good_quote_real();
2123 let mut quotes = vec![(
2124 peer_id,
2125 addrs.clone(),
2126 original_quote.clone(),
2127 amount,
2128 commitment,
2129 )];
2130 let _ = drop_quotes_with_bad_bindings(&mut quotes);
2131
2132 let (kept_peer, kept_addrs, kept_quote, kept_amount, _kept_commitment) =
2133 quotes.pop().expect("the good quote must survive filtering");
2134 assert_eq!(kept_peer.as_bytes(), peer_id.as_bytes());
2135 assert_eq!(kept_addrs.len(), addrs.len());
2136 assert_eq!(kept_amount, amount);
2137 assert_eq!(kept_quote.pub_key, original_quote.pub_key);
2138 assert_eq!(kept_quote.signature, original_quote.signature);
2139 assert_eq!(kept_quote.content.0, original_quote.content.0);
2140 assert_eq!(kept_quote.timestamp, original_quote.timestamp);
2141 assert_eq!(kept_quote.price, original_quote.price);
2142 assert_eq!(kept_quote.rewards_address, original_quote.rewards_address);
2143 }
2144
2145 #[test]
2176 fn repro_apr_30_storer_would_have_rejected_pre_filter_and_accepts_post_filter() {
2177 let over_query_count = fault_tolerant_quote_query_count();
2178 let mut quotes: Vec<_> = (0..over_query_count - 1)
2179 .map(|_| good_quote_real())
2180 .collect();
2181 quotes.insert(over_query_count / 2, bad_quote_real());
2184 assert_eq!(quotes.len(), over_query_count);
2185
2186 let storer_would_reject_count = quotes
2188 .iter()
2189 .filter(|(p, _, q, _, _)| !storer_binding_would_accept(p, q))
2190 .count();
2191 assert_eq!(
2192 storer_would_reject_count, 1,
2193 "exactly one quote (the crossed-key one) must be rejected by the storer spec"
2194 );
2195
2196 let dropped = drop_quotes_with_bad_bindings(&mut quotes);
2198 assert_eq!(dropped, 1, "exactly the crossed-key quote must be filtered");
2199
2200 for (peer_id, _, quote, _, _) in "es {
2202 assert!(
2203 storer_binding_would_accept(peer_id, quote),
2204 "every post-filter quote must be accepted by the storer spec — \
2205 this is what the filter guarantees before any quote set is used"
2206 );
2207 }
2208
2209 assert!(
2211 quotes.len() >= CLOSE_GROUP_SIZE,
2212 "after filtering, at least CLOSE_GROUP_SIZE good quotes must remain \
2213 so a fault-tolerant probe can still return a full close group"
2214 );
2215 }
2216
2217 #[test]
2222 fn filter_leaves_short_set_when_too_many_bad_peers() {
2223 let good_count = CLOSE_GROUP_SIZE - 1;
2224 let bad_count = fault_tolerant_quote_query_count() - good_count;
2225 let mut quotes: Vec<_> = std::iter::repeat_with(bad_quote_real)
2226 .take(bad_count)
2227 .chain(std::iter::repeat_with(good_quote_real).take(good_count))
2228 .collect();
2229
2230 let dropped = drop_quotes_with_bad_bindings(&mut quotes);
2231 assert_eq!(dropped, bad_count);
2232 assert!(
2233 quotes.len() < CLOSE_GROUP_SIZE,
2234 "this is the precondition for InsufficientPeers downstream"
2235 );
2236 for (peer_id, _, quote, _, _) in "es {
2238 assert!(storer_binding_would_accept(peer_id, quote));
2239 }
2240 }
2241
2242 fn serialize_quote(quote: &PaymentQuote) -> Vec<u8> {
2257 rmp_serde::to_vec(quote).expect("serialize quote")
2258 }
2259
2260 #[test]
2261 fn classifier_accepts_real_self_consistent_quote() {
2262 let content = [7u8; 32];
2265 let (peer_id, quote) = signed_baseline_quote(content);
2266 let bytes = serialize_quote("e);
2267 let result = classify_quote_response(&peer_id, &content, &bytes, false, None);
2268 match result {
2269 Ok((q, price, commitment)) => {
2270 assert_eq!(q.pub_key, quote.pub_key);
2271 assert_eq!(price, quote.price);
2272 assert!(commitment.is_none(), "baseline quote ships no commitment");
2273 }
2274 Err(e) => panic!("expected Ok, got {e}"),
2275 }
2276 }
2277
2278 #[test]
2279 fn classifier_rejects_quote_with_invalid_signature() {
2280 let content = [7u8; 32];
2283 let (peer_id, mut quote) = signed_baseline_quote(content);
2284 quote.signature = vec![0u8; quote.signature.len()]; let bytes = serialize_quote("e);
2286 let result = classify_quote_response(&peer_id, &content, &bytes, false, None);
2287 assert!(
2288 matches!(result, Err(Error::BadQuoteBinding { .. })),
2289 "a quote with an invalid signature must be rejected; got {result:?}"
2290 );
2291 }
2292
2293 #[test]
2294 fn classifier_rejects_quote_for_wrong_content() {
2295 let (peer_id, quote) = signed_baseline_quote([7u8; 32]);
2297 let bytes = serialize_quote("e);
2298 let result = classify_quote_response(&peer_id, &[9u8; 32], &bytes, false, None);
2299 assert!(
2300 matches!(result, Err(Error::BadQuoteBinding { .. })),
2301 "a quote for the wrong content must be rejected; got {result:?}"
2302 );
2303 }
2304
2305 #[test]
2306 fn classifier_rejects_crossed_keypair_with_typed_error() {
2307 let (peer_id, _, quote, _, _) = bad_quote_real();
2308 let bytes = serialize_quote("e);
2309 let result = classify_quote_response(&peer_id, &[0u8; 32], &bytes, false, None);
2310 match result {
2311 Err(Error::BadQuoteBinding {
2312 peer_id: pid,
2313 detail,
2314 }) => {
2315 assert_eq!(pid, peer_id.to_string());
2316 assert!(
2317 detail.contains("BLAKE3(pub_key)="),
2318 "diagnostic detail must include the derived peer id: {detail}"
2319 );
2320 }
2321 other => panic!("expected BadQuoteBinding for crossed-key quote, got {other:?}"),
2322 }
2323 }
2324
2325 #[test]
2336 fn classifier_rejects_already_stored_vote_from_bad_binding_peer() {
2337 let (peer_id, _, quote, _, _) = bad_quote_real();
2338 let bytes = serialize_quote("e);
2339 let result = classify_quote_response(&peer_id, &[0u8; 32], &bytes, true, None);
2341 assert!(
2342 matches!(result, Err(Error::BadQuoteBinding { .. })),
2343 "crossed-key peer must be classified BadQuoteBinding even when \
2344 voting already_stored=true; got {result:?}"
2345 );
2346 }
2347
2348 #[test]
2351 fn classifier_honours_already_stored_vote_from_good_binding_peer() {
2352 let content = [7u8; 32];
2353 let (peer_id, quote) = signed_baseline_quote(content);
2354 let bytes = serialize_quote("e);
2355 let result = classify_quote_response(&peer_id, &content, &bytes, true, None);
2356 assert!(
2357 matches!(result, Err(Error::AlreadyStored)),
2358 "honest peer's already_stored vote must be honoured; got {result:?}"
2359 );
2360 }
2361
2362 #[test]
2363 fn classifier_returns_serialization_error_on_bad_bytes() {
2364 let (peer_id, _, _, _, _) = good_quote_real();
2365 let garbage = b"this is not a valid msgpack PaymentQuote".to_vec();
2366 let result = classify_quote_response(&peer_id, &[0u8; 32], &garbage, false, None);
2367 assert!(
2368 matches!(result, Err(Error::Serialization(_))),
2369 "garbage bytes must produce a Serialization error; got {result:?}"
2370 );
2371 }
2372
2373 #[test]
2376 fn classifier_verdict_matches_storer_binding_spec_for_mixed_responders() {
2377 let content = [7u8; 32];
2378 let mut responders: Vec<(PeerId, PaymentQuote)> =
2379 (0..12).map(|_| signed_baseline_quote(content)).collect();
2380 for _ in 0..4 {
2381 let (p, _, q, _, _) = bad_quote_real();
2382 responders.push((p, q));
2383 }
2384
2385 for (peer_id, quote) in &responders {
2386 let bytes = serialize_quote(quote);
2387 let storer_verdict = storer_binding_would_accept(peer_id, quote);
2388 let classifier_verdict =
2389 classify_quote_response(peer_id, &content, &bytes, false, None).is_ok();
2390 assert_eq!(
2391 classifier_verdict, storer_verdict,
2392 "classifier and storer-binding-spec must agree on every responder \
2393 (peer_id={}, storer={storer_verdict}, classifier={classifier_verdict})",
2394 peer_id
2395 );
2396 }
2397 }
2398
2399 fn any_peer() -> PeerId {
2415 PeerId::from_bytes([0u8; 32])
2416 }
2417
2418 fn quote_with_binding(
2420 committed_key_count: u32,
2421 commitment_pin: Option<[u8; 32]>,
2422 price: Amount,
2423 ) -> PaymentQuote {
2424 PaymentQuote {
2425 content: XorName([0u8; 32]),
2426 timestamp: SystemTime::UNIX_EPOCH,
2427 price,
2428 rewards_address: RewardsAddress::new([0u8; 20]),
2429 pub_key: Vec::new(),
2430 signature: Vec::new(),
2431 committed_key_count,
2432 commitment_pin,
2433 }
2434 }
2435
2436 fn signed_commitment(kp: &Keypair, root: [u8; 32], key_count: u32) -> StorageCommitment {
2442 use ant_protocol::payment::commitment::DOMAIN_COMMITMENT;
2443 use ant_protocol::pqc::api::{ml_dsa_65, MlDsaSecretKey as ApiSecretKey, MlDsaVariant};
2444 let peer = compute_address(&kp.pub_key_bytes);
2445 let mut payload = Vec::with_capacity(32 + 4 + 32 + 4 + kp.pub_key_bytes.len());
2446 payload.extend_from_slice(&root);
2447 payload.extend_from_slice(&key_count.to_le_bytes());
2448 payload.extend_from_slice(&peer);
2449 payload.extend_from_slice(&(kp.pub_key_bytes.len() as u32).to_le_bytes());
2450 payload.extend_from_slice(&kp.pub_key_bytes);
2451 let sk = ApiSecretKey::from_bytes(MlDsaVariant::MlDsa65, &kp.secret_key_bytes)
2452 .expect("api secret key");
2453 let signature = ml_dsa_65()
2454 .sign_with_context(&sk, &payload, DOMAIN_COMMITMENT)
2455 .expect("sign commitment")
2456 .to_bytes();
2457 StorageCommitment {
2458 root,
2459 key_count,
2460 sender_peer_id: peer,
2461 sender_public_key: kp.pub_key_bytes.clone(),
2462 signature,
2463 }
2464 }
2465
2466 #[test]
2467 fn binding_baseline_ok_only_at_baseline_price() {
2468 let q = quote_with_binding(0, None, calculate_price(0));
2470 assert!(quote_commitment_binding_is_valid(&any_peer(), &q, &None).is_ok());
2471
2472 let q = quote_with_binding(0, None, calculate_price(500));
2475 assert!(quote_commitment_binding_is_valid(&any_peer(), &q, &None).is_err());
2476 }
2477
2478 #[test]
2479 fn binding_rejects_incoherent_shapes() {
2480 let q = quote_with_binding(500, None, calculate_price(500));
2482 assert!(quote_commitment_binding_is_valid(&any_peer(), &q, &None).is_err());
2483 let q = quote_with_binding(0, Some([9u8; 32]), calculate_price(0));
2485 assert!(quote_commitment_binding_is_valid(&any_peer(), &q, &None).is_err());
2486 }
2487
2488 #[test]
2489 fn binding_rejects_count_above_cap() {
2490 let over = MAX_COMMITMENT_KEY_COUNT + 1;
2491 let q = quote_with_binding(over, Some([9u8; 32]), calculate_price(over as usize));
2492 assert!(
2493 quote_commitment_binding_is_valid(&any_peer(), &q, &Some(vec![1u8; 16])).is_err(),
2494 "a count above MAX_COMMITMENT_KEY_COUNT must be rejected before payment"
2495 );
2496 }
2497
2498 #[test]
2499 fn binding_rejects_on_curve_wrong_count() {
2500 let q = quote_with_binding(500, Some([9u8; 32]), calculate_price(499));
2503 assert!(quote_commitment_binding_is_valid(&any_peer(), &q, &Some(vec![1u8; 16])).is_err());
2504 }
2505
2506 #[test]
2507 fn binding_rejects_bound_quote_without_shipped_commitment() {
2508 let q = quote_with_binding(500, Some([9u8; 32]), calculate_price(500));
2511 assert!(
2512 quote_commitment_binding_is_valid(&any_peer(), &q, &None).is_err(),
2513 "a bound quote missing its commitment must be rejected"
2514 );
2515 }
2516
2517 #[test]
2518 fn binding_rejects_unparseable_and_peer_unbound_commitment() {
2519 let q = quote_with_binding(500, Some([9u8; 32]), calculate_price(500));
2522 assert!(
2523 quote_commitment_binding_is_valid(&any_peer(), &q, &Some(vec![0xFF; 8])).is_err(),
2524 "an unparseable commitment must be rejected before payment"
2525 );
2526
2527 let bogus = StorageCommitment {
2533 root: [1u8; 32],
2534 key_count: 500,
2535 sender_peer_id: [2u8; 32], sender_public_key: vec![3u8; 1952],
2537 signature: vec![4u8; 3293],
2538 };
2539 let blob = rmp_serde::to_vec(&bogus).expect("serialize bogus commitment");
2540 assert!(
2541 quote_commitment_binding_is_valid(&any_peer(), &q, &Some(blob)).is_err(),
2542 "a commitment not bound to the quoting peer must be rejected before payment"
2543 );
2544 }
2545
2546 #[test]
2547 fn binding_rejects_commitment_with_invalid_signature() {
2548 let kp = gen_keypair();
2552 let mut commitment = signed_commitment(&kp, [6u8; 32], 500);
2553 commitment.signature[0] ^= 0xFF; let pin = commitment_hash(&commitment).expect("hash");
2557 let blob = rmp_serde::to_vec(&commitment).expect("serialize commitment");
2558 let q = quote_with_binding(500, Some(pin), calculate_price(500));
2559 let res = quote_commitment_binding_is_valid(&kp.peer_id, &q, &Some(blob));
2560 let err = res.expect_err("commitment with an invalid signature must be rejected");
2561 assert!(
2562 err.contains("signature"),
2563 "should fail at the signature check: {err}"
2564 );
2565 }
2566
2567 #[test]
2568 fn binding_rejects_commitment_that_does_not_hash_to_pin() {
2569 let kp = gen_keypair();
2573 let commitment = signed_commitment(&kp, [5u8; 32], 500);
2574 let wrong_pin = [0xAB; 32];
2575 assert_ne!(commitment_hash(&commitment), Some(wrong_pin));
2576 let blob = rmp_serde::to_vec(&commitment).expect("serialize commitment");
2577 let q = quote_with_binding(500, Some(wrong_pin), calculate_price(500));
2578 let res = quote_commitment_binding_is_valid(&kp.peer_id, &q, &Some(blob));
2579 let err = res.expect_err("commitment that does not hash to the pin must be rejected");
2580 assert!(
2581 err.contains("hash"),
2582 "should fail at the hash==pin check: {err}"
2583 );
2584 }
2585
2586 #[test]
2587 fn binding_rejects_count_disagreeing_with_commitment() {
2588 let kp = gen_keypair();
2593 let commitment = signed_commitment(&kp, [7u8; 32], 400);
2594 let pin = commitment_hash(&commitment).expect("hash");
2595 let blob = rmp_serde::to_vec(&commitment).expect("serialize commitment");
2596 let q = quote_with_binding(500, Some(pin), calculate_price(500));
2597 let res = quote_commitment_binding_is_valid(&kp.peer_id, &q, &Some(blob));
2598 let err = res.expect_err("a quote count disagreeing with the commitment must be rejected");
2599 assert!(
2600 err.contains("key_count") || err.contains("attests"),
2601 "should fail at the count==key_count check: {err}"
2602 );
2603 }
2604
2605 #[test]
2606 fn binding_rejects_oversized_commitment_before_parsing() {
2607 let q = quote_with_binding(500, Some([9u8; 32]), calculate_price(500));
2610 let huge = Some(vec![0u8; MAX_COMMITMENT_SIDECAR_BYTES + 1]);
2611 assert!(
2612 quote_commitment_binding_is_valid(&any_peer(), &q, &huge).is_err(),
2613 "an oversized commitment blob must be rejected before payment"
2614 );
2615 }
2616
2617 #[test]
2618 fn classifier_drops_off_curve_quote_with_typed_error() {
2619 use ant_protocol::pqc::ops::MlDsaSecretKey;
2624 let content = [7u8; 32];
2625 let kp = gen_keypair();
2626 let mut quote = PaymentQuote {
2627 content: XorName(content),
2628 timestamp: SystemTime::UNIX_EPOCH,
2629 price: calculate_price(500),
2631 rewards_address: RewardsAddress::new([0u8; 20]),
2632 pub_key: kp.pub_key_bytes.clone(),
2633 signature: Vec::new(),
2634 committed_key_count: 0,
2635 commitment_pin: None,
2636 };
2637 let ml_dsa = MlDsa65::new();
2638 let sk = MlDsaSecretKey::from_bytes(&kp.secret_key_bytes).expect("sk");
2639 quote.signature = ml_dsa
2640 .sign(&sk, "e.bytes_for_sig())
2641 .expect("sign")
2642 .as_bytes()
2643 .to_vec();
2644 let bytes = serialize_quote("e);
2645 let result = classify_quote_response(&kp.peer_id, &content, &bytes, false, None);
2646 assert!(
2647 matches!(result, Err(Error::BadQuoteCommitment { .. })),
2648 "off-curve quote must be dropped as BadQuoteCommitment; got {result:?}"
2649 );
2650 }
2651}