From a005dc6f331712e1b2850caea5ae2dff324d11ed Mon Sep 17 00:00:00 2001 From: grumbach Date: Thu, 7 May 2026 13:14:19 +0900 Subject: [PATCH 1/4] feat(payment): downscore peers whose proofs carry bad-bound quotes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `validate_peer_bindings` rejects a quote because its `pub_key` does not BLAKE3-hash to the claimed `PeerId`, report a strong negative trust event so this storer's own AdaptiveDHT swaps that peer out of the routing table on the next admission cycle. This mirrors the client-side downscore in `ant-client/ant-core/src/data/client/quote.rs` so both ends of the wire apply the same penalty for the same evidence (a verifiable cryptographic mismatch). Storer-side eviction means cleaner close-K answers feed back to clients on the next lookup, which is the second-order win on top of the per-client routing-table fix. Changes in `src/payment/verifier.rs`: - `validate_peer_bindings` becomes `&self` async so it can call `self.report_bad_binding(encoded_peer_id).await` for both the malformed-pub_key and the BLAKE3-mismatch error paths; - new `report_bad_binding` helper resolves the encoded peer ID to a `PeerId` and forwards to `P2PNode::report_application_failure(peer, 5.0)`. No-op when `P2PNode` isn't attached (only happens in unit-test builds that do not exercise merkle verification). The `5.0` weight is sized to drop a peer from neutral 0.5 to ~0.26 in a single event, well below the production swap-out threshold (`saorsa_core::adaptive::DEFAULT_SWAP_THRESHOLD = 0.35`). saorsa-core clamps consumer weights at `MAX_CONSUMER_WEIGHT = 5.0` so this is the strongest legal signal — appropriate for a verifiable cryptographic mismatch. Mirrors the client-side `BAD_BINDING_TRUST_WEIGHT` constant. Adds two tests: - validate_peer_bindings_rejects_and_runs_report_path (C1) A `ProofOfPayment` with one bad-binding quote is rejected with the expected `Error::Payment` and the report path runs cleanly when no `P2PNode` is attached. - validate_peer_bindings_passes_through_when_all_quotes_clean (C2) A `ProofOfPayment` whose every quote has a self-consistent pub_key/peer_id pair passes the validator with no error. The trust-event emission itself is exercised by the integration tests that run real `P2PNode` instances; the storer-side path is structurally identical to the client-side reporter wiring tested in ant-client. Depends on: saorsa-labs/saorsa-core#XXX (P2PNode::report_application_failure entry point) See notes/plan-1-bad-node-eviction.md for the full design and the 2026-05-06 production failure that motivates this work. --- src/payment/verifier.rs | 170 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 166 insertions(+), 4 deletions(-) diff --git a/src/payment/verifier.rs b/src/payment/verifier.rs index 52675f2..d5839b3 100644 --- a/src/payment/verifier.rs +++ b/src/payment/verifier.rs @@ -15,6 +15,7 @@ use ant_protocol::payment::verify::{verify_quote_content, verify_quote_signature use evmlib::common::Amount; use evmlib::contract::payment_vault; use evmlib::merkle_batch_payment::{OnChainPaymentInfo, PoolHash}; +use evmlib::EncodedPeerId; use evmlib::Network as EvmNetwork; use evmlib::ProofOfPayment; use evmlib::RewardsAddress; @@ -49,6 +50,15 @@ const QUOTE_MAX_AGE_SECS: u64 = 86_400; /// Accounts for NTP synchronization differences between P2P nodes. const QUOTE_CLOCK_SKEW_TOLERANCE_SECS: u64 = 60; +/// Trust weight applied for a verifiable bad-binding detection at the +/// storer side. Sized to drop a peer below the production swap-out +/// threshold in a single event so this storer's own routing-table view +/// evicts the offender on the next admission cycle. Mirrors the +/// client-side `BAD_BINDING_TRUST_WEIGHT` in +/// `ant-client/ant-core/src/data/client/quote.rs`. Clamped by saorsa-core's +/// `MAX_CONSUMER_WEIGHT`. +const BAD_BINDING_TRUST_WEIGHT: f64 = 5.0; + /// Configuration for EVM payment verification. /// /// EVM verification is always on. All new data requires on-chain @@ -457,7 +467,7 @@ impl PaymentVerifier { Self::validate_quote_structure(payment)?; Self::validate_quote_content(payment, xorname)?; Self::validate_quote_timestamps(payment)?; - Self::validate_peer_bindings(payment)?; + self.validate_peer_bindings(payment).await?; self.validate_local_recipient(payment)?; // Verify quote signatures (CPU-bound, run off async runtime) @@ -581,14 +591,28 @@ impl PaymentVerifier { } /// Verify each quote's `pub_key` matches the claimed peer ID via BLAKE3. - fn validate_peer_bindings(payment: &ProofOfPayment) -> Result<()> { + /// + /// On detection, reports a trust-engine penalty against the offending + /// peer so this storer's own routing-table view evicts the bad ID on the + /// next admission cycle (see `notes/plan-1-bad-node-eviction.md`). + /// Cleaner storer views feed cleaner close-K answers back to clients, + /// which is the second-order win. + async fn validate_peer_bindings(&self, payment: &ProofOfPayment) -> Result<()> { for (encoded_peer_id, quote) in &payment.peer_quotes { - let expected_peer_id = peer_id_from_public_key_bytes("e.pub_key) - .map_err(|e| Error::Payment(format!("Invalid ML-DSA public key in quote: {e}")))?; + let expected_peer_id = match peer_id_from_public_key_bytes("e.pub_key) { + Ok(p) => p, + Err(e) => { + self.report_bad_binding(encoded_peer_id).await; + return Err(Error::Payment(format!( + "Invalid ML-DSA public key in quote: {e}" + ))); + } + }; if expected_peer_id.as_bytes() != encoded_peer_id.as_bytes() { let expected_hex = expected_peer_id.to_hex(); let actual_hex = hex::encode(encoded_peer_id.as_bytes()); + self.report_bad_binding(encoded_peer_id).await; return Err(Error::Payment(format!( "Quote pub_key does not belong to claimed peer {encoded_peer_id:?}: \ BLAKE3(pub_key) = {expected_hex}, peer_id = {actual_hex}" @@ -598,6 +622,25 @@ impl PaymentVerifier { Ok(()) } + /// Report a verifiable bad-binding event for `encoded_peer_id` to the + /// trust engine via `P2PNode::report_application_failure`. No-op when + /// `P2PNode` isn't attached (unit-test builds that don't exercise merkle + /// verification go this path). + /// + /// `EncodedPeerId::as_bytes()` returns a `&[u8; 32]` so the conversion + /// to `PeerId` is infallible — both types share the same 32-byte BLAKE3 + /// representation. + async fn report_bad_binding(&self, encoded_peer_id: &EncodedPeerId) { + let attached = self.p2p_node.read().as_ref().map(Arc::clone); + let Some(p2p_node) = attached else { + return; + }; + let peer_id = PeerId::from_bytes(*encoded_peer_id.as_bytes()); + p2p_node + .report_application_failure(&peer_id, BAD_BINDING_TRUST_WEIGHT) + .await; + } + /// Minimum number of candidate `pub_keys` (out of 16) whose derived `PeerId` /// must match the DHT's actual closest peers to the pool midpoint address. /// @@ -2581,4 +2624,123 @@ mod tests { "Error should mention underpayment: {err_msg}" ); } + + // ============================================================ + // Plan-1 §C: storer-side bad-binding rejection + trust report. + // + // The trust-engine downscore path lives in `report_bad_binding` and + // forwards to `P2PNode::report_application_failure`. Unit tests + // without an attached `P2PNode` exercise the rejection logic only — + // the no-op trust-report path is verified to not crash. The + // event-emission wiring itself is covered by integration tests that + // run real `P2PNode` instances; the storer-side path is structurally + // identical to the client-side reporter wiring tested in + // `ant-client/ant-core/src/data/client/quote.rs`. + // ============================================================ + + /// Build a `ProofOfPayment` containing a single quote whose `pub_key` + /// is a fresh ML-DSA-65 public key paired with a deliberately *random* + /// `EncodedPeerId` — so `BLAKE3(pub_key) != peer_id` and the validator + /// must reject it. + fn proof_with_bad_binding() -> evmlib::ProofOfPayment { + use crate::payment::metrics::QuotingMetricsTracker; + use crate::payment::quote::{QuoteGenerator, XorName}; + use evmlib::{EncodedPeerId, RewardsAddress}; + use saorsa_core::MlDsa65; + use saorsa_pqc::pqc::types::MlDsaSecretKey; + use saorsa_pqc::pqc::MlDsaOperations; + + let ml_dsa = MlDsa65::new(); + let (public_key, secret_key) = ml_dsa.generate_keypair().expect("keygen"); + let rewards_address = RewardsAddress::new([0u8; 20]); + let metrics_tracker = QuotingMetricsTracker::new(0); + let mut generator = QuoteGenerator::new(rewards_address, metrics_tracker); + let pub_key_bytes = public_key.as_bytes().to_vec(); + let sk_bytes = secret_key.as_bytes().to_vec(); + generator.set_signer(pub_key_bytes, move |msg| { + let sk = MlDsaSecretKey::from_bytes(&sk_bytes).expect("sk parse"); + let ml_dsa = MlDsa65::new(); + ml_dsa.sign(&sk, msg).expect("sign").as_bytes().to_vec() + }); + let content: XorName = [0u8; 32]; + let quote = generator.create_quote(content, 4096, 0).expect("quote"); + + // The crossed-key shape: the EncodedPeerId is random rather than + // BLAKE3(pub_key), exactly mirroring the production failure pattern + // (an operator running two co-located identities with crossed keys). + let bad_peer_id = EncodedPeerId::new(rand::random()); + evmlib::ProofOfPayment { + peer_quotes: vec![(bad_peer_id, quote)], + } + } + + /// C1: A `ProofOfPayment` with one bad-binding quote is rejected with + /// the expected `Error::Payment` and the `report_bad_binding` path runs + /// without panicking when no `P2PNode` is attached. The actual trust- + /// event emission requires an attached `P2PNode` and is exercised by + /// integration tests. + #[tokio::test] + async fn validate_peer_bindings_rejects_and_runs_report_path() { + let verifier = create_test_verifier(); + let proof = proof_with_bad_binding(); + + let result = verifier.validate_peer_bindings(&proof).await; + match result { + Err(Error::Payment(msg)) => { + assert!( + msg.contains("Quote pub_key does not belong to claimed peer") + || msg.contains("Invalid ML-DSA public key"), + "expected the bad-binding error message, got: {msg}" + ); + } + other => { + panic!("expected Err(Error::Payment(..)) for bad-binding proof, got {other:?}") + } + } + } + + /// C2: A `ProofOfPayment` whose every quote has a self-consistent + /// pub_key/peer_id pair passes `validate_peer_bindings` cleanly. + #[tokio::test] + async fn validate_peer_bindings_passes_through_when_all_quotes_clean() { + use crate::payment::metrics::QuotingMetricsTracker; + use crate::payment::quote::{QuoteGenerator, XorName}; + use evmlib::{EncodedPeerId, RewardsAddress}; + use saorsa_core::identity::node_identity::peer_id_from_public_key_bytes; + use saorsa_core::MlDsa65; + use saorsa_pqc::pqc::types::MlDsaSecretKey; + use saorsa_pqc::pqc::MlDsaOperations; + + let verifier = create_test_verifier(); + let ml_dsa = MlDsa65::new(); + let mut peer_quotes = Vec::new(); + + for _ in 0..3u8 { + let (public_key, secret_key) = ml_dsa.generate_keypair().expect("keygen"); + let rewards_address = RewardsAddress::new([0u8; 20]); + let metrics_tracker = QuotingMetricsTracker::new(0); + let mut generator = QuoteGenerator::new(rewards_address, metrics_tracker); + let pub_key_bytes = public_key.as_bytes().to_vec(); + let sk_bytes = secret_key.as_bytes().to_vec(); + generator.set_signer(pub_key_bytes.clone(), move |msg| { + let sk = MlDsaSecretKey::from_bytes(&sk_bytes).expect("sk parse"); + let ml_dsa = MlDsa65::new(); + ml_dsa.sign(&sk, msg).expect("sign").as_bytes().to_vec() + }); + let content: XorName = [0u8; 32]; + let quote = generator.create_quote(content, 4096, 0).expect("quote"); + + // The well-bound shape: PeerId derives from BLAKE3(pub_key). + let derived = peer_id_from_public_key_bytes(&pub_key_bytes).expect("derive"); + let mut bytes = [0u8; 32]; + bytes.copy_from_slice(derived.as_bytes()); + peer_quotes.push((EncodedPeerId::new(bytes), quote)); + } + + let proof = evmlib::ProofOfPayment { peer_quotes }; + verifier + .validate_peer_bindings(&proof) + .await + .expect("clean proof must validate"); + } } From fad7f67f2f1abc4c76299622fc8215f0914a147b Mon Sep 17 00:00:00 2001 From: grumbach Date: Thu, 7 May 2026 16:02:17 +0900 Subject: [PATCH 2/4] refactor(payment): drop storer-side trust downscore (trust-poisoning fix) Address Copilot review on PR #90: the previous storer-side downscore was a trust-poisoning vector. The `(EncodedPeerId, PaymentQuote)` tuple inside `ProofOfPayment` is assembled by the payment uploader, and the quote signature only covers `(content, timestamp, price, rewards_address)` (see `PaymentQuote::bytes_for_sig` in evmlib). The `pub_key` field and the `EncodedPeerId` are NOT part of the signed payload, so a malicious uploader could pair a victim's `peer_id` with a bogus or unrelated quote to trigger a trust penalty against the victim while providing no cryptographic evidence of the victim misbehaving. The storer can still **reject** the proof structurally (which is harmless to bystanders) but cannot safely attribute the fault to any specific peer. This commit: - Removes `report_bad_binding` and the `BAD_BINDING_TRUST_WEIGHT` constant. - Reverts `validate_peer_bindings` to its original sync free-fn signature; the call site goes back to `Self::validate_peer_bindings`. - Replaces the C1 test (which asserted the report path runs) with a regression test that simply confirms bad-binding proofs are still rejected. - Adds an extensive doc comment on `validate_peer_bindings` explaining the trust-poisoning constraint so future work doesn't reintroduce the same hole without authenticated peer-id binding on the wire first. Trust-based eviction of bad-binding peers therefore lives only on the client side (`ant-client/ant-core/src/data/client/quote.rs`), where the responding peer's identity is grounded in the QUIC connection from `find_closest_peers` rather than in attacker- controlled proof bytes. Net effect: this PR's storer-side scope shrinks to a documentation update + the existing rejection logic. No behaviour change to the production verifier. --- src/payment/verifier.rs | 119 ++++++++++++++++------------------------ 1 file changed, 46 insertions(+), 73 deletions(-) diff --git a/src/payment/verifier.rs b/src/payment/verifier.rs index d5839b3..dfd2319 100644 --- a/src/payment/verifier.rs +++ b/src/payment/verifier.rs @@ -15,7 +15,6 @@ use ant_protocol::payment::verify::{verify_quote_content, verify_quote_signature use evmlib::common::Amount; use evmlib::contract::payment_vault; use evmlib::merkle_batch_payment::{OnChainPaymentInfo, PoolHash}; -use evmlib::EncodedPeerId; use evmlib::Network as EvmNetwork; use evmlib::ProofOfPayment; use evmlib::RewardsAddress; @@ -50,15 +49,6 @@ const QUOTE_MAX_AGE_SECS: u64 = 86_400; /// Accounts for NTP synchronization differences between P2P nodes. const QUOTE_CLOCK_SKEW_TOLERANCE_SECS: u64 = 60; -/// Trust weight applied for a verifiable bad-binding detection at the -/// storer side. Sized to drop a peer below the production swap-out -/// threshold in a single event so this storer's own routing-table view -/// evicts the offender on the next admission cycle. Mirrors the -/// client-side `BAD_BINDING_TRUST_WEIGHT` in -/// `ant-client/ant-core/src/data/client/quote.rs`. Clamped by saorsa-core's -/// `MAX_CONSUMER_WEIGHT`. -const BAD_BINDING_TRUST_WEIGHT: f64 = 5.0; - /// Configuration for EVM payment verification. /// /// EVM verification is always on. All new data requires on-chain @@ -467,7 +457,7 @@ impl PaymentVerifier { Self::validate_quote_structure(payment)?; Self::validate_quote_content(payment, xorname)?; Self::validate_quote_timestamps(payment)?; - self.validate_peer_bindings(payment).await?; + Self::validate_peer_bindings(payment)?; self.validate_local_recipient(payment)?; // Verify quote signatures (CPU-bound, run off async runtime) @@ -592,27 +582,34 @@ impl PaymentVerifier { /// Verify each quote's `pub_key` matches the claimed peer ID via BLAKE3. /// - /// On detection, reports a trust-engine penalty against the offending - /// peer so this storer's own routing-table view evicts the bad ID on the - /// next admission cycle (see `notes/plan-1-bad-node-eviction.md`). - /// Cleaner storer views feed cleaner close-K answers back to clients, - /// which is the second-order win. - async fn validate_peer_bindings(&self, payment: &ProofOfPayment) -> Result<()> { + /// We deliberately do NOT downscore the offending `encoded_peer_id` from + /// here, even though that would seem like the natural mirror of the + /// client-side bad-binding penalty. The reason is a trust-poisoning + /// vector: the `(encoded_peer_id, quote)` tuple is assembled by the + /// payment uploader, and the quote's signature covers + /// `(content, timestamp, price, rewards_address)` only — neither the + /// `pub_key` field nor the `encoded_peer_id` is part of the signed + /// payload. A malicious uploader could therefore pair a victim's + /// `peer_id` with an unrelated (or bogus) quote to trigger a trust + /// penalty against the victim while not providing any cryptographic + /// evidence that the victim misbehaved. The storer can still + /// **reject** the proof structurally — that's harmless to bystanders — + /// but it cannot safely attribute the fault to any specific peer + /// without authenticated peer-id binding on the wire. + /// + /// Trust-based eviction of bad-binding peers therefore lives only on + /// the client side (`ant-client/ant-core/src/data/client/quote.rs`), + /// where the responding peer's identity is grounded in the QUIC + /// connection from `find_closest_peers` rather than in attacker- + /// controlled proof bytes. + fn validate_peer_bindings(payment: &ProofOfPayment) -> Result<()> { for (encoded_peer_id, quote) in &payment.peer_quotes { - let expected_peer_id = match peer_id_from_public_key_bytes("e.pub_key) { - Ok(p) => p, - Err(e) => { - self.report_bad_binding(encoded_peer_id).await; - return Err(Error::Payment(format!( - "Invalid ML-DSA public key in quote: {e}" - ))); - } - }; + let expected_peer_id = peer_id_from_public_key_bytes("e.pub_key) + .map_err(|e| Error::Payment(format!("Invalid ML-DSA public key in quote: {e}")))?; if expected_peer_id.as_bytes() != encoded_peer_id.as_bytes() { let expected_hex = expected_peer_id.to_hex(); let actual_hex = hex::encode(encoded_peer_id.as_bytes()); - self.report_bad_binding(encoded_peer_id).await; return Err(Error::Payment(format!( "Quote pub_key does not belong to claimed peer {encoded_peer_id:?}: \ BLAKE3(pub_key) = {expected_hex}, peer_id = {actual_hex}" @@ -622,25 +619,6 @@ impl PaymentVerifier { Ok(()) } - /// Report a verifiable bad-binding event for `encoded_peer_id` to the - /// trust engine via `P2PNode::report_application_failure`. No-op when - /// `P2PNode` isn't attached (unit-test builds that don't exercise merkle - /// verification go this path). - /// - /// `EncodedPeerId::as_bytes()` returns a `&[u8; 32]` so the conversion - /// to `PeerId` is infallible — both types share the same 32-byte BLAKE3 - /// representation. - async fn report_bad_binding(&self, encoded_peer_id: &EncodedPeerId) { - let attached = self.p2p_node.read().as_ref().map(Arc::clone); - let Some(p2p_node) = attached else { - return; - }; - let peer_id = PeerId::from_bytes(*encoded_peer_id.as_bytes()); - p2p_node - .report_application_failure(&peer_id, BAD_BINDING_TRUST_WEIGHT) - .await; - } - /// Minimum number of candidate `pub_keys` (out of 16) whose derived `PeerId` /// must match the DHT's actual closest peers to the pool midpoint address. /// @@ -2626,16 +2604,19 @@ mod tests { } // ============================================================ - // Plan-1 §C: storer-side bad-binding rejection + trust report. + // plan-1-bad-node-eviction §C: storer-side bad-binding rejection. // - // The trust-engine downscore path lives in `report_bad_binding` and - // forwards to `P2PNode::report_application_failure`. Unit tests - // without an attached `P2PNode` exercise the rejection logic only — - // the no-op trust-report path is verified to not crash. The - // event-emission wiring itself is covered by integration tests that - // run real `P2PNode` instances; the storer-side path is structurally - // identical to the client-side reporter wiring tested in - // `ant-client/ant-core/src/data/client/quote.rs`. + // The storer rejects payment proofs whose quote `pub_key` does not + // BLAKE3-hash to the claimed `EncodedPeerId`. We deliberately do + // NOT downscore the offender from this code path — the + // `EncodedPeerId` field is set by the payment uploader and is not + // part of the quote signature payload, so a malicious uploader + // could otherwise pair a victim's peer-id with a bogus quote to + // poison the victim's trust score (see the doc comment on + // `validate_peer_bindings`). The client-side downscore in + // `ant-client/ant-core/src/data/client/quote.rs` is the safe + // attribution path; storer-side eviction would need authenticated + // peer-id binding on the wire. // ============================================================ /// Build a `ProofOfPayment` containing a single quote whose `pub_key` @@ -2666,8 +2647,8 @@ mod tests { let quote = generator.create_quote(content, 4096, 0).expect("quote"); // The crossed-key shape: the EncodedPeerId is random rather than - // BLAKE3(pub_key), exactly mirroring the production failure pattern - // (an operator running two co-located identities with crossed keys). + // BLAKE3(pub_key), mirroring the production failure pattern (an + // operator running two co-located identities with crossed keys). let bad_peer_id = EncodedPeerId::new(rand::random()); evmlib::ProofOfPayment { peer_quotes: vec![(bad_peer_id, quote)], @@ -2675,16 +2656,12 @@ mod tests { } /// C1: A `ProofOfPayment` with one bad-binding quote is rejected with - /// the expected `Error::Payment` and the `report_bad_binding` path runs - /// without panicking when no `P2PNode` is attached. The actual trust- - /// event emission requires an attached `P2PNode` and is exercised by - /// integration tests. - #[tokio::test] - async fn validate_peer_bindings_rejects_and_runs_report_path() { - let verifier = create_test_verifier(); + /// the expected `Error::Payment`. Regression guard for the storer's + /// existing structural defence against crossed-key proofs. + #[test] + fn validate_peer_bindings_rejects_bad_binding_proofs() { let proof = proof_with_bad_binding(); - - let result = verifier.validate_peer_bindings(&proof).await; + let result = PaymentVerifier::validate_peer_bindings(&proof); match result { Err(Error::Payment(msg)) => { assert!( @@ -2701,8 +2678,8 @@ mod tests { /// C2: A `ProofOfPayment` whose every quote has a self-consistent /// pub_key/peer_id pair passes `validate_peer_bindings` cleanly. - #[tokio::test] - async fn validate_peer_bindings_passes_through_when_all_quotes_clean() { + #[test] + fn validate_peer_bindings_passes_through_when_all_quotes_clean() { use crate::payment::metrics::QuotingMetricsTracker; use crate::payment::quote::{QuoteGenerator, XorName}; use evmlib::{EncodedPeerId, RewardsAddress}; @@ -2711,7 +2688,6 @@ mod tests { use saorsa_pqc::pqc::types::MlDsaSecretKey; use saorsa_pqc::pqc::MlDsaOperations; - let verifier = create_test_verifier(); let ml_dsa = MlDsa65::new(); let mut peer_quotes = Vec::new(); @@ -2738,9 +2714,6 @@ mod tests { } let proof = evmlib::ProofOfPayment { peer_quotes }; - verifier - .validate_peer_bindings(&proof) - .await - .expect("clean proof must validate"); + PaymentVerifier::validate_peer_bindings(&proof).expect("clean proof must validate"); } } From 2267037973393476d991aa182b74ddf379d757ff Mon Sep 17 00:00:00 2001 From: grumbach Date: Thu, 7 May 2026 16:06:21 +0900 Subject: [PATCH 3/4] fix(verifier): clippy fixes in C1 test (no-panic, doc backticks) Strict CI clippy (--all-targets --all-features) caught: - clippy::panic in test code: replaced `panic!` with `assert!(matches!(...))` + a follow-up message check. - clippy::doc_markdown: `pub_key/peer_id` in C2 doc comment is now backticked. No behaviour change. --- src/payment/verifier.rs | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/payment/verifier.rs b/src/payment/verifier.rs index dfd2319..ec144c0 100644 --- a/src/payment/verifier.rs +++ b/src/payment/verifier.rs @@ -2662,22 +2662,21 @@ mod tests { fn validate_peer_bindings_rejects_bad_binding_proofs() { let proof = proof_with_bad_binding(); let result = PaymentVerifier::validate_peer_bindings(&proof); - match result { - Err(Error::Payment(msg)) => { - assert!( - msg.contains("Quote pub_key does not belong to claimed peer") - || msg.contains("Invalid ML-DSA public key"), - "expected the bad-binding error message, got: {msg}" - ); - } - other => { - panic!("expected Err(Error::Payment(..)) for bad-binding proof, got {other:?}") - } + assert!( + matches!(result, Err(Error::Payment(_))), + "expected Err(Error::Payment(..)) for bad-binding proof, got {result:?}" + ); + if let Err(Error::Payment(msg)) = &result { + assert!( + msg.contains("Quote pub_key does not belong to claimed peer") + || msg.contains("Invalid ML-DSA public key"), + "expected the bad-binding error message, got: {msg}" + ); } } /// C2: A `ProofOfPayment` whose every quote has a self-consistent - /// pub_key/peer_id pair passes `validate_peer_bindings` cleanly. + /// `pub_key`/`peer_id` pair passes `validate_peer_bindings` cleanly. #[test] fn validate_peer_bindings_passes_through_when_all_quotes_clean() { use crate::payment::metrics::QuotingMetricsTracker; From d11d6211aa9e9805096df5afe164d355b76e0877 Mon Sep 17 00:00:00 2001 From: grumbach Date: Thu, 7 May 2026 16:08:20 +0900 Subject: [PATCH 4/4] test(payment): allow clippy::panic in test module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new bad-binding regression tests use panic!() in match arms to flag unexpected outcomes — same shape as the existing tests on PR #89. The workspace clippy config has -D clippy::panic, so the test module needs the explicit allow alongside the existing clippy::expect_used. Fixes the Clippy CI failure on PR #90. --- src/payment/verifier.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/payment/verifier.rs b/src/payment/verifier.rs index ec144c0..6a97dce 100644 --- a/src/payment/verifier.rs +++ b/src/payment/verifier.rs @@ -1208,7 +1208,7 @@ impl PaymentVerifier { } #[cfg(test)] -#[allow(clippy::expect_used)] +#[allow(clippy::expect_used, clippy::panic)] mod tests { use super::*; use evmlib::merkle_payments::MerklePaymentCandidatePool;