1use crate::data::client::peer_cache::record_peer_outcome;
7use crate::data::client::Client;
8use crate::data::error::{Error, Result};
9use ant_protocol::evm::{Amount, PaymentQuote};
10use ant_protocol::transport::{MultiAddr, PeerId};
11use ant_protocol::{
12 compute_address, send_and_await_chunk_response, ChunkMessage, ChunkMessageBody,
13 ChunkQuoteRequest, ChunkQuoteResponse, CLOSE_GROUP_MAJORITY, CLOSE_GROUP_SIZE,
14};
15use futures::stream::{FuturesUnordered, StreamExt};
16use std::time::{Duration, Instant};
17use tracing::{debug, info, warn};
18
19fn xor_distance(peer_id: &PeerId, target: &[u8; 32]) -> [u8; 32] {
24 let peer_bytes = peer_id.as_bytes();
25 let mut distance = [0u8; 32];
26 for (i, d) in distance.iter_mut().enumerate() {
27 let pb = peer_bytes.get(i).copied().unwrap_or(0);
28 *d = pb ^ target[i];
29 }
30 distance
31}
32
33const ML_DSA_PUB_KEY_LEN: usize = 1952;
42
43fn quote_binding_is_valid(peer_id: &PeerId, quote: &PaymentQuote) -> bool {
59 if quote.pub_key.len() != ML_DSA_PUB_KEY_LEN {
60 return false;
61 }
62 compute_address("e.pub_key) == *peer_id.as_bytes()
63}
64
65fn classify_quote_response(
93 peer_id: &PeerId,
94 quote_bytes: &[u8],
95 already_stored: bool,
96) -> std::result::Result<(PaymentQuote, Amount), Error> {
97 let payment_quote = rmp_serde::from_slice::<PaymentQuote>(quote_bytes).map_err(|e| {
98 Error::Serialization(format!("Failed to deserialize quote from {peer_id}: {e}"))
99 })?;
100
101 if !quote_binding_is_valid(peer_id, &payment_quote) {
106 let derived = compute_address(&payment_quote.pub_key);
107 warn!(
108 "Dropping response from {peer_id} — quote.pub_key BLAKE3 mismatch \
109 (peer is signing quotes with another peer's key); the storer \
110 would reject this proof"
111 );
112 return Err(Error::BadQuoteBinding {
113 peer_id: peer_id.to_string(),
114 detail: format!(
115 "BLAKE3(pub_key)={} pub_key_len={}",
116 hex::encode(derived),
117 payment_quote.pub_key.len(),
118 ),
119 });
120 }
121
122 if already_stored {
123 debug!("Peer {peer_id} already has chunk");
124 return Err(Error::AlreadyStored);
125 }
126 let price = payment_quote.price;
127 debug!("Received quote from {peer_id}: price = {price}");
128 Ok((payment_quote, price))
129}
130
131fn quote_outcome_is_success(result: &std::result::Result<(PaymentQuote, Amount), Error>) -> bool {
142 matches!(result, Ok(_) | Err(Error::AlreadyStored))
143}
144
145fn drop_quotes_with_bad_bindings(
148 quotes: &mut Vec<(PeerId, Vec<MultiAddr>, PaymentQuote, Amount)>,
149) -> usize {
150 let before = quotes.len();
151 quotes.retain(|(peer_id, _, quote, _)| {
152 if quote_binding_is_valid(peer_id, quote) {
153 true
154 } else {
155 warn!(
156 "Dropping quote from peer {peer_id} — quote.pub_key BLAKE3 mismatch \
157 (peer is signing quotes with another peer's key); the storer would \
158 reject this proof"
159 );
160 false
161 }
162 });
163 before - quotes.len()
164}
165
166impl Client {
167 #[allow(clippy::too_many_lines)]
180 pub async fn get_store_quotes(
181 &self,
182 address: &[u8; 32],
183 data_size: u64,
184 data_type: u32,
185 ) -> Result<Vec<(PeerId, Vec<MultiAddr>, PaymentQuote, Amount)>> {
186 let node = self.network().node();
187
188 let over_query_count = CLOSE_GROUP_SIZE * 2;
190 debug!(
191 "Requesting quotes from up to {over_query_count} peers for address {} (size: {data_size})",
192 hex::encode(address)
193 );
194
195 let remote_peers = self
196 .network()
197 .find_closest_peers(address, over_query_count)
198 .await?;
199
200 if remote_peers.len() < CLOSE_GROUP_SIZE {
201 return Err(Error::InsufficientPeers(format!(
202 "Found {} peers, need {CLOSE_GROUP_SIZE}",
203 remote_peers.len()
204 )));
205 }
206
207 let per_peer_timeout = Duration::from_secs(self.config().quote_timeout_secs);
208 let overall_timeout = Duration::from_secs(120);
212
213 let mut quote_futures = FuturesUnordered::new();
215
216 for (peer_id, peer_addrs) in &remote_peers {
217 let request_id = self.next_request_id();
218 let request = ChunkQuoteRequest {
219 address: *address,
220 data_size,
221 data_type,
222 };
223 let message = ChunkMessage {
224 request_id,
225 body: ChunkMessageBody::QuoteRequest(request),
226 };
227
228 let message_bytes = match message.encode() {
229 Ok(bytes) => bytes,
230 Err(e) => {
231 warn!("Failed to encode quote request for {peer_id}: {e}");
232 continue;
233 }
234 };
235
236 let peer_id_clone = *peer_id;
237 let addrs_clone = peer_addrs.clone();
238 let node_clone = node.clone();
239
240 let quote_future = async move {
241 let start = Instant::now();
242 let result = send_and_await_chunk_response(
243 &node_clone,
244 &peer_id_clone,
245 message_bytes,
246 request_id,
247 per_peer_timeout,
248 &addrs_clone,
249 |body| match body {
250 ChunkMessageBody::QuoteResponse(ChunkQuoteResponse::Success {
251 quote,
252 already_stored,
253 }) => Some(classify_quote_response(
254 &peer_id_clone,
255 "e,
256 already_stored,
257 )),
258 ChunkMessageBody::QuoteResponse(ChunkQuoteResponse::Error(e)) => Some(Err(
259 Error::Protocol(format!("Quote error from {peer_id_clone}: {e}")),
260 )),
261 _ => None,
262 },
263 |e| {
264 Error::Network(format!(
265 "Failed to send quote request to {peer_id_clone}: {e}"
266 ))
267 },
268 || Error::Timeout(format!("Timeout waiting for quote from {peer_id_clone}")),
269 )
270 .await;
271
272 let success = quote_outcome_is_success(&result);
275 let rtt_ms = success.then(|| start.elapsed().as_millis() as u64);
276 record_peer_outcome(&node_clone, peer_id_clone, &addrs_clone, success, rtt_ms)
277 .await;
278
279 (peer_id_clone, addrs_clone, result)
280 };
281
282 quote_futures.push(quote_future);
283 }
284
285 let mut quotes = Vec::with_capacity(over_query_count);
288 let mut already_stored_peers: Vec<(PeerId, [u8; 32])> = Vec::new();
289 let mut failures: Vec<String> = Vec::new();
290
291 let mut bad_quote_count = 0usize;
296
297 let collect_result: std::result::Result<std::result::Result<(), Error>, _> =
298 tokio::time::timeout(overall_timeout, async {
299 while let Some((peer_id, addrs, quote_result)) = quote_futures.next().await {
300 match quote_result {
301 Ok((quote, price)) => {
302 quotes.push((peer_id, addrs, quote, price));
303 }
304 Err(Error::AlreadyStored) => {
305 info!("Peer {peer_id} reports chunk already stored");
306 let dist = xor_distance(&peer_id, address);
307 already_stored_peers.push((peer_id, dist));
308 }
309 Err(e) => {
310 if matches!(&e, Error::BadQuoteBinding { .. }) {
314 bad_quote_count += 1;
315 }
316 warn!("Failed to get quote from {peer_id}: {e}");
317 failures.push(format!("{peer_id}: {e}"));
318 }
319 }
320 }
321 Ok(())
322 })
323 .await;
324
325 match collect_result {
326 Err(_elapsed) => {
327 warn!(
328 "Quote collection timed out after {overall_timeout:?} for address {}",
329 hex::encode(address)
330 );
331 }
335 Ok(Err(e)) => return Err(e),
336 Ok(Ok(())) => {}
337 }
338
339 let bad_dropped = drop_quotes_with_bad_bindings(&mut quotes);
345 if bad_dropped > 0 {
346 warn!(
347 "Defensive filter dropped {bad_dropped} quotes with mismatched peer bindings \
348 for address {} — the per-peer handler should have caught these earlier \
349 (this indicates an upstream regression)",
350 hex::encode(address),
351 );
352 bad_quote_count += bad_dropped;
353 }
354
355 if !already_stored_peers.is_empty() {
357 let mut all_peers_by_distance: Vec<(bool, [u8; 32])> = Vec::new();
358 for (peer_id, _, _, _) in "es {
359 all_peers_by_distance.push((false, xor_distance(peer_id, address)));
360 }
361 for (_, dist) in &already_stored_peers {
362 all_peers_by_distance.push((true, *dist));
363 }
364 all_peers_by_distance.sort_by_key(|a| a.1);
365
366 let close_group_stored = all_peers_by_distance
367 .iter()
368 .take(CLOSE_GROUP_SIZE)
369 .filter(|(is_stored, _)| *is_stored)
370 .count();
371
372 if close_group_stored >= CLOSE_GROUP_MAJORITY {
373 debug!(
374 "Chunk {} already stored ({close_group_stored}/{CLOSE_GROUP_SIZE} close-group peers confirm)",
375 hex::encode(address)
376 );
377 return Err(Error::AlreadyStored);
378 }
379 }
380
381 let already_stored_count = already_stored_peers.len();
382 let failure_count = failures.len();
383 let quote_count = quotes.len();
384 let total_responses = quote_count + failure_count + already_stored_count;
385
386 if quotes.len() >= CLOSE_GROUP_SIZE {
387 quotes.sort_by(|a, b| {
389 let dist_a = xor_distance(&a.0, address);
390 let dist_b = xor_distance(&b.0, address);
391 dist_a.cmp(&dist_b)
392 });
393 quotes.truncate(CLOSE_GROUP_SIZE);
394
395 info!(
396 "Collected {} quotes for address {} ({total_responses} responses: \
397 {quote_count} ok, {already_stored_count} already_stored, {failure_count} failed, \
398 {bad_quote_count} bad-binding)",
399 quotes.len(),
400 hex::encode(address),
401 );
402 return Ok(quotes);
403 }
404
405 Err(Error::InsufficientPeers(format!(
406 "Got {quote_count} quotes, need {CLOSE_GROUP_SIZE} ({total_responses} responses: \
407 {already_stored_count} already_stored, {failure_count} failed including \
408 {bad_quote_count} with mismatched peer bindings). Failures: [{}]",
409 failures.join("; ")
410 )))
411 }
412}
413
414#[cfg(test)]
415#[allow(clippy::unwrap_used, clippy::expect_used)]
416mod tests {
417 use super::*;
429 use ant_protocol::evm::RewardsAddress;
430 use ant_protocol::pqc::ops::{MlDsaOperations, MlDsaPublicKey};
431 use ant_protocol::transport::MlDsa65;
432 use std::time::SystemTime;
433 use xor_name::XorName;
434
435 struct Keypair {
437 peer_id: PeerId,
438 pub_key_bytes: Vec<u8>,
439 }
440
441 fn gen_keypair() -> Keypair {
442 let ml_dsa = MlDsa65::new();
443 let (pub_key, _sk) = ml_dsa.generate_keypair().expect("ML-DSA-65 keygen");
444 let pub_key_bytes = pub_key.as_bytes().to_vec();
445 let peer_id = PeerId::from_bytes(compute_address(&pub_key_bytes));
446 Keypair {
447 peer_id,
448 pub_key_bytes,
449 }
450 }
451
452 fn good_quote_real() -> (PeerId, Vec<MultiAddr>, PaymentQuote, Amount) {
455 let kp = gen_keypair();
456 let quote = PaymentQuote {
457 content: XorName([0u8; 32]),
458 timestamp: SystemTime::UNIX_EPOCH,
459 price: Amount::ZERO,
460 rewards_address: RewardsAddress::new([0u8; 20]),
461 pub_key: kp.pub_key_bytes,
462 signature: Vec::new(),
463 };
464 (kp.peer_id, Vec::new(), quote, Amount::ZERO)
465 }
466
467 fn bad_quote_real() -> (PeerId, Vec<MultiAddr>, PaymentQuote, Amount) {
472 let claimed = gen_keypair();
473 let signing = gen_keypair();
474 assert_ne!(claimed.pub_key_bytes, signing.pub_key_bytes);
475 assert_ne!(claimed.peer_id.as_bytes(), signing.peer_id.as_bytes());
476 let quote = PaymentQuote {
477 content: XorName([0u8; 32]),
478 timestamp: SystemTime::UNIX_EPOCH,
479 price: Amount::ZERO,
480 rewards_address: RewardsAddress::new([0u8; 20]),
481 pub_key: signing.pub_key_bytes,
482 signature: Vec::new(),
483 };
484 (claimed.peer_id, Vec::new(), quote, Amount::ZERO)
485 }
486
487 fn storer_binding_would_accept(peer_id: &PeerId, quote: &PaymentQuote) -> bool {
496 if MlDsaPublicKey::from_bytes("e.pub_key).is_err() {
497 return false;
498 }
499 compute_address("e.pub_key) == *peer_id.as_bytes()
500 }
501
502 #[test]
507 fn binding_accepts_real_self_consistent_keypair() {
508 let (peer_id, _, quote, _) = good_quote_real();
509 assert!(quote_binding_is_valid(&peer_id, "e));
512 assert!(storer_binding_would_accept(&peer_id, "e));
514 }
515
516 #[test]
517 fn binding_rejects_real_crossed_keypair() {
518 let (peer_id, _, quote, _) = bad_quote_real();
519 assert!(!quote_binding_is_valid(&peer_id, "e));
520 assert!(!storer_binding_would_accept(&peer_id, "e));
521 }
522
523 #[test]
524 fn binding_rejects_oversize_pubkey() {
525 let oversized = vec![0u8; ML_DSA_PUB_KEY_LEN + 1];
529 let peer_id = PeerId::from_bytes(compute_address(&oversized));
530 let quote = PaymentQuote {
531 content: XorName([0u8; 32]),
532 timestamp: SystemTime::UNIX_EPOCH,
533 price: Amount::ZERO,
534 rewards_address: RewardsAddress::new([0u8; 20]),
535 pub_key: oversized,
536 signature: Vec::new(),
537 };
538 assert_eq!(compute_address("e.pub_key), *peer_id.as_bytes());
541 assert!(
542 !quote_binding_is_valid(&peer_id, "e),
543 "predicate must reject oversize pub_key even when BLAKE3 happens to match"
544 );
545 assert!(!storer_binding_would_accept(&peer_id, "e));
546 }
547
548 #[test]
549 fn binding_rejects_undersize_pubkey() {
550 let undersized = vec![0u8; ML_DSA_PUB_KEY_LEN - 1];
551 let peer_id = PeerId::from_bytes(compute_address(&undersized));
552 let quote = PaymentQuote {
553 content: XorName([0u8; 32]),
554 timestamp: SystemTime::UNIX_EPOCH,
555 price: Amount::ZERO,
556 rewards_address: RewardsAddress::new([0u8; 20]),
557 pub_key: undersized,
558 signature: Vec::new(),
559 };
560 assert!(!quote_binding_is_valid(&peer_id, "e));
561 assert!(!storer_binding_would_accept(&peer_id, "e));
562 }
563
564 #[test]
569 fn filter_drops_only_bad_bindings_and_leaves_storer_acceptable_quotes() {
570 let mut quotes = vec![
571 good_quote_real(),
572 bad_quote_real(),
573 good_quote_real(),
574 bad_quote_real(),
575 good_quote_real(),
576 ];
577
578 let dropped = drop_quotes_with_bad_bindings(&mut quotes);
579
580 assert_eq!(dropped, 2, "two crossed-key quotes must be dropped");
581 assert_eq!(quotes.len(), 3, "three real-key quotes must remain");
582
583 for (peer_id, _, quote, _) in "es {
589 assert!(
590 storer_binding_would_accept(peer_id, quote),
591 "every retained quote must satisfy the full storer-side spec"
592 );
593 }
594 }
595
596 #[test]
597 fn filter_is_noop_when_all_quotes_are_storer_acceptable() {
598 let mut quotes: Vec<_> = (0..5).map(|_| good_quote_real()).collect();
599 let before = quotes.len();
600 let dropped = drop_quotes_with_bad_bindings(&mut quotes);
601 assert_eq!(dropped, 0);
602 assert_eq!(quotes.len(), before);
603 for (peer_id, _, quote, _) in "es {
604 assert!(storer_binding_would_accept(peer_id, quote));
605 }
606 }
607
608 #[test]
609 fn filter_drops_all_when_every_responder_is_bad() {
610 let mut quotes: Vec<_> = (0..CLOSE_GROUP_SIZE * 2)
615 .map(|_| bad_quote_real())
616 .collect();
617 let dropped = drop_quotes_with_bad_bindings(&mut quotes);
618 assert_eq!(dropped, CLOSE_GROUP_SIZE * 2);
619 assert!(quotes.is_empty());
620 }
621
622 #[test]
623 fn filter_preserves_quote_payload_byte_for_byte() {
624 let (peer_id, addrs, original_quote, amount) = good_quote_real();
629 let mut quotes = vec![(peer_id, addrs.clone(), original_quote.clone(), amount)];
630 let _ = drop_quotes_with_bad_bindings(&mut quotes);
631
632 let (kept_peer, kept_addrs, kept_quote, kept_amount) =
633 quotes.pop().expect("the good quote must survive filtering");
634 assert_eq!(kept_peer.as_bytes(), peer_id.as_bytes());
635 assert_eq!(kept_addrs.len(), addrs.len());
636 assert_eq!(kept_amount, amount);
637 assert_eq!(kept_quote.pub_key, original_quote.pub_key);
638 assert_eq!(kept_quote.signature, original_quote.signature);
639 assert_eq!(kept_quote.content.0, original_quote.content.0);
640 assert_eq!(kept_quote.timestamp, original_quote.timestamp);
641 assert_eq!(kept_quote.price, original_quote.price);
642 assert_eq!(kept_quote.rewards_address, original_quote.rewards_address);
643 }
644
645 #[test]
675 fn repro_apr_30_storer_would_have_rejected_pre_filter_and_accepts_post_filter() {
676 let over_query_count = CLOSE_GROUP_SIZE * 2;
677 let mut quotes: Vec<_> = (0..over_query_count - 1)
678 .map(|_| good_quote_real())
679 .collect();
680 quotes.insert(over_query_count / 2, bad_quote_real());
683 assert_eq!(quotes.len(), over_query_count);
684
685 let storer_would_reject_count = quotes
687 .iter()
688 .filter(|(p, _, q, _)| !storer_binding_would_accept(p, q))
689 .count();
690 assert_eq!(
691 storer_would_reject_count, 1,
692 "exactly one quote (the crossed-key one) must be rejected by the storer spec"
693 );
694
695 let dropped = drop_quotes_with_bad_bindings(&mut quotes);
697 assert_eq!(dropped, 1, "exactly the crossed-key quote must be filtered");
698
699 for (peer_id, _, quote, _) in "es {
701 assert!(
702 storer_binding_would_accept(peer_id, quote),
703 "every post-filter quote must be accepted by the storer spec — \
704 this is what the patch guarantees: no more burned payments"
705 );
706 }
707
708 assert!(
710 quotes.len() >= CLOSE_GROUP_SIZE,
711 "after filtering, at least CLOSE_GROUP_SIZE good quotes must remain \
712 so we can build a non-rejected ProofOfPayment"
713 );
714 }
715
716 #[test]
721 fn filter_leaves_short_set_when_too_many_bad_peers() {
722 let bad_count = CLOSE_GROUP_SIZE + 1;
724 let good_count = CLOSE_GROUP_SIZE - 1;
725 let mut quotes: Vec<_> = std::iter::repeat_with(bad_quote_real)
726 .take(bad_count)
727 .chain(std::iter::repeat_with(good_quote_real).take(good_count))
728 .collect();
729
730 let dropped = drop_quotes_with_bad_bindings(&mut quotes);
731 assert_eq!(dropped, bad_count);
732 assert!(
733 quotes.len() < CLOSE_GROUP_SIZE,
734 "this is the precondition for InsufficientPeers downstream"
735 );
736 for (peer_id, _, quote, _) in "es {
738 assert!(storer_binding_would_accept(peer_id, quote));
739 }
740 }
741
742 fn serialize_quote(quote: &PaymentQuote) -> Vec<u8> {
757 rmp_serde::to_vec(quote).expect("serialize quote")
758 }
759
760 #[test]
761 fn classifier_accepts_real_self_consistent_quote() {
762 let (peer_id, _, quote, _) = good_quote_real();
763 let bytes = serialize_quote("e);
764 let result = classify_quote_response(&peer_id, &bytes, false);
765 match result {
766 Ok((q, price)) => {
767 assert_eq!(q.pub_key, quote.pub_key);
768 assert_eq!(price, quote.price);
769 }
770 Err(e) => panic!("expected Ok, got {e}"),
771 }
772 }
773
774 #[test]
775 fn classifier_rejects_crossed_keypair_with_typed_error() {
776 let (peer_id, _, quote, _) = bad_quote_real();
777 let bytes = serialize_quote("e);
778 let result = classify_quote_response(&peer_id, &bytes, false);
779 match result {
780 Err(Error::BadQuoteBinding {
781 peer_id: pid,
782 detail,
783 }) => {
784 assert_eq!(pid, peer_id.to_string());
785 assert!(
786 detail.contains("BLAKE3(pub_key)="),
787 "diagnostic detail must include the derived peer id: {detail}"
788 );
789 }
790 other => panic!("expected BadQuoteBinding for crossed-key quote, got {other:?}"),
791 }
792 }
793
794 #[test]
805 fn classifier_rejects_already_stored_vote_from_bad_binding_peer() {
806 let (peer_id, _, quote, _) = bad_quote_real();
807 let bytes = serialize_quote("e);
808 let result = classify_quote_response(&peer_id, &bytes, true);
810 assert!(
811 matches!(result, Err(Error::BadQuoteBinding { .. })),
812 "crossed-key peer must be classified BadQuoteBinding even when \
813 voting already_stored=true; got {result:?}"
814 );
815 }
816
817 #[test]
820 fn classifier_honours_already_stored_vote_from_good_binding_peer() {
821 let (peer_id, _, quote, _) = good_quote_real();
822 let bytes = serialize_quote("e);
823 let result = classify_quote_response(&peer_id, &bytes, true);
824 assert!(
825 matches!(result, Err(Error::AlreadyStored)),
826 "honest peer's already_stored vote must be honoured; got {result:?}"
827 );
828 }
829
830 #[test]
831 fn classifier_returns_serialization_error_on_bad_bytes() {
832 let (peer_id, _, _, _) = good_quote_real();
833 let garbage = b"this is not a valid msgpack PaymentQuote".to_vec();
834 let result = classify_quote_response(&peer_id, &garbage, false);
835 assert!(
836 matches!(result, Err(Error::Serialization(_))),
837 "garbage bytes must produce a Serialization error; got {result:?}"
838 );
839 }
840
841 #[test]
848 fn aimd_success_for_ok_result() {
849 let (_, _, quote, _) = good_quote_real();
850 let result: std::result::Result<(PaymentQuote, Amount), Error> =
851 Ok((quote.clone(), quote.price));
852 assert!(quote_outcome_is_success(&result));
853 }
854
855 #[test]
856 fn aimd_success_for_already_stored() {
857 let result: std::result::Result<(PaymentQuote, Amount), Error> = Err(Error::AlreadyStored);
858 assert!(
859 quote_outcome_is_success(&result),
860 "an honest peer reporting already_stored is a benign outcome — \
861 the peer is reachable and well-behaved, so the AIMD cache must \
862 keep them at high reputation"
863 );
864 }
865
866 #[test]
867 fn aimd_failure_for_bad_quote_binding() {
868 let result: std::result::Result<(PaymentQuote, Amount), Error> =
869 Err(Error::BadQuoteBinding {
870 peer_id: "abc123".to_string(),
871 detail: "test".to_string(),
872 });
873 assert!(
874 !quote_outcome_is_success(&result),
875 "BadQuoteBinding peers must be marked as failures so the AIMD \
876 bootstrap cache learns to stop asking them on every upload"
877 );
878 }
879
880 #[test]
881 fn aimd_failure_for_network_and_timeout_and_protocol_and_serialization() {
882 for err in [
883 Error::Network("net".to_string()),
884 Error::Timeout("to".to_string()),
885 Error::Protocol("proto".to_string()),
886 Error::Serialization("ser".to_string()),
887 ] {
888 let result: std::result::Result<(PaymentQuote, Amount), Error> = Err(err);
889 assert!(
890 !quote_outcome_is_success(&result),
891 "network-class errors must be classified as failures: {result:?}"
892 );
893 }
894 }
895
896 #[test]
899 fn classifier_verdict_matches_storer_binding_spec_for_mixed_responders() {
900 let mut responders: Vec<(PeerId, PaymentQuote)> = (0..12)
901 .map(|_| {
902 let (p, _, q, _) = good_quote_real();
903 (p, q)
904 })
905 .collect();
906 for _ in 0..4 {
907 let (p, _, q, _) = bad_quote_real();
908 responders.push((p, q));
909 }
910
911 for (peer_id, quote) in &responders {
912 let bytes = serialize_quote(quote);
913 let storer_verdict = storer_binding_would_accept(peer_id, quote);
914 let classifier_verdict = classify_quote_response(peer_id, &bytes, false).is_ok();
915 assert_eq!(
916 classifier_verdict, storer_verdict,
917 "classifier and storer-binding-spec must agree on every responder \
918 (peer_id={}, storer={storer_verdict}, classifier={classifier_verdict})",
919 peer_id
920 );
921 }
922 }
923}