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 SINGLE_NODE_MIN_QUOTE_COUNT: usize = 1;
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.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, _) = "es[*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("es, 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 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 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 "es,
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 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 let close_group_query_count = single_node_quote_query_count();
1027 let required_quotes = SINGLE_NODE_MIN_QUOTE_COUNT;
1028 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 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 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 "e_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 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 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 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 }
1269 Ok(Err(e)) => return Err(e),
1270 Ok(Ok(())) => {}
1271 }
1272 }
1273
1274 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 if !already_stored_peers.is_empty() {
1292 let mut all_peers_by_distance: Vec<(bool, [u8; 32])> = Vec::new();
1293 for (peer_id, _, _, _, _) in "es {
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 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 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 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 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 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 fn storer_binding_would_accept(peer_id: &PeerId, quote: &PaymentQuote) -> bool {
1552 if MlDsaPublicKey::from_bytes("e.pub_key).is_err() {
1553 return false;
1554 }
1555 compute_address("e.pub_key) == *peer_id.as_bytes()
1556 }
1557
1558 #[test]
1563 fn binding_accepts_real_self_consistent_keypair() {
1564 let (peer_id, _, quote, _, _) = good_quote_real();
1565 assert!(quote_binding_is_valid(&peer_id, "e));
1568 assert!(storer_binding_would_accept(&peer_id, "e));
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, "e));
1576 assert!(!storer_binding_would_accept(&peer_id, "e));
1577 }
1578
1579 #[test]
1580 fn binding_rejects_oversize_pubkey() {
1581 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 assert_eq!(compute_address("e.pub_key), *peer_id.as_bytes());
1599 assert!(
1600 !quote_binding_is_valid(&peer_id, "e),
1601 "predicate must reject oversize pub_key even when BLAKE3 happens to match"
1602 );
1603 assert!(!storer_binding_would_accept(&peer_id, "e));
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, "e));
1621 assert!(!storer_binding_would_accept(&peer_id, "e));
1622 }
1623
1624 #[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 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 const RESPONDED_IN_SCOPE: usize = 5;
1718 const OUT_OF_SCOPE_RESPONDERS: usize = 2;
1720
1721 let address = [0u8; 32];
1722 let close_seeds = ascending_seeds(CLOSE_GROUP_SIZE);
1723 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 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 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 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 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 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 "es,
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("es), vec![1, 2, 3, 4, 5, 6, 7]);
2142 let (median_peer_id, _) =
2143 median_paid_quote_issuer("es).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 for (peer_id, _, quote, _, _) in "es {
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 "es {
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 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 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 #[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 quotes.insert(over_query_count / 2, bad_quote_real());
2270 assert_eq!(quotes.len(), over_query_count);
2271
2272 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 let dropped = drop_quotes_with_bad_bindings(&mut quotes);
2284 assert_eq!(dropped, 1, "exactly the crossed-key quote must be filtered");
2285
2286 for (peer_id, _, quote, _, _) in "es {
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 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 #[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 for (peer_id, _, quote, _, _) in "es {
2324 assert!(storer_binding_would_accept(peer_id, quote));
2325 }
2326 }
2327
2328 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 let content = [7u8; 32];
2351 let (peer_id, quote) = signed_baseline_quote(content);
2352 let bytes = serialize_quote("e);
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 let content = [7u8; 32];
2369 let (peer_id, mut quote) = signed_baseline_quote(content);
2370 quote.signature = vec![0u8; quote.signature.len()]; let bytes = serialize_quote("e);
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 let (peer_id, quote) = signed_baseline_quote([7u8; 32]);
2383 let bytes = serialize_quote("e);
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("e);
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 #[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("e);
2425 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 #[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("e);
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 #[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 fn any_peer() -> PeerId {
2501 PeerId::from_bytes([0u8; 32])
2502 }
2503
2504 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 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 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 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 let q = quote_with_binding(500, None, calculate_price(500));
2568 assert!(quote_commitment_binding_is_valid(&any_peer(), &q, &None).is_err());
2569 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 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 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 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 let bogus = StorageCommitment {
2619 root: [1u8; 32],
2620 key_count: 500,
2621 sender_peer_id: [2u8; 32], 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 let kp = gen_keypair();
2638 let mut commitment = signed_commitment(&kp, [6u8; 32], 500);
2639 commitment.signature[0] ^= 0xFF; 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 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 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 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 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 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, "e.bytes_for_sig())
2727 .expect("sign")
2728 .as_bytes()
2729 .to_vec();
2730 let bytes = serialize_quote("e);
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}