Skip to main content

ant_node/payment/
quote.rs

1//! Payment quote generation for ant-node.
2//!
3//! Generates `PaymentQuote` values that clients use to pay for data storage.
4//! Compatible with the Autonomi payment system.
5//!
6//! NOTE: Quote generation requires integration with the node's signing
7//! capabilities from saorsa-core. This module provides the interface
8//! and will be fully integrated when the node is initialized.
9
10use crate::error::{Error, Result};
11use crate::logging::debug;
12use crate::payment::metrics::QuotingMetricsTracker;
13use crate::payment::pricing::calculate_price;
14use evmlib::merkle_payments::MerklePaymentCandidateNode;
15use evmlib::PaymentQuote;
16use evmlib::RewardsAddress;
17use saorsa_core::MlDsa65;
18use saorsa_pqc::pqc::types::MlDsaSecretKey;
19use saorsa_pqc::pqc::MlDsaOperations;
20use std::time::SystemTime;
21
22/// Content address type (32-byte `XorName`).
23pub type XorName = [u8; 32];
24
25/// Signing function type that takes bytes and returns a signature.
26pub type SignFn = Box<dyn Fn(&[u8]) -> Vec<u8> + Send + Sync>;
27
28/// Quote generator for creating payment quotes.
29///
30/// Uses the node's signing capabilities to sign quotes, which clients
31/// use to pay for storage on the Arbitrum network.
32pub struct QuoteGenerator {
33    /// The rewards address for receiving payments.
34    rewards_address: RewardsAddress,
35    /// Metrics tracker for quoting.
36    metrics_tracker: QuotingMetricsTracker,
37    /// Signing function provided by the node.
38    /// Takes bytes and returns a signature.
39    sign_fn: Option<SignFn>,
40    /// Public key bytes for the quote.
41    pub_key: Vec<u8>,
42}
43
44impl QuoteGenerator {
45    /// Create a new quote generator without signing capability.
46    ///
47    /// Call `set_signer` to enable quote signing.
48    ///
49    /// # Arguments
50    ///
51    /// * `rewards_address` - The EVM address for receiving payments
52    /// * `metrics_tracker` - Tracker for quoting metrics
53    #[must_use]
54    pub fn new(rewards_address: RewardsAddress, metrics_tracker: QuotingMetricsTracker) -> Self {
55        Self {
56            rewards_address,
57            metrics_tracker,
58            sign_fn: None,
59            pub_key: Vec::new(),
60        }
61    }
62
63    /// Set the signing function for quote generation.
64    ///
65    /// # Arguments
66    ///
67    /// * `pub_key` - The node's public key bytes
68    /// * `sign_fn` - Function that signs bytes and returns signature
69    pub fn set_signer<F>(&mut self, pub_key: Vec<u8>, sign_fn: F)
70    where
71        F: Fn(&[u8]) -> Vec<u8> + Send + Sync + 'static,
72    {
73        self.pub_key = pub_key;
74        self.sign_fn = Some(Box::new(sign_fn));
75    }
76
77    /// Check if the generator has signing capability.
78    #[must_use]
79    pub fn can_sign(&self) -> bool {
80        self.sign_fn.is_some()
81    }
82
83    /// Probe the signer with test data to verify it produces a non-empty signature.
84    ///
85    /// # Errors
86    ///
87    /// Returns an error if no signer is set or if signing produces an empty signature.
88    pub fn probe_signer(&self) -> Result<()> {
89        let sign_fn = self
90            .sign_fn
91            .as_ref()
92            .ok_or_else(|| Error::Payment("Signer not set".to_string()))?;
93        let test_msg = b"ant-signing-probe";
94        let test_sig = sign_fn(test_msg);
95        if test_sig.is_empty() {
96            return Err(Error::Payment(
97                "ML-DSA-65 signing probe failed: empty signature produced".to_string(),
98            ));
99        }
100        Ok(())
101    }
102
103    /// Generate a payment quote for storing data.
104    ///
105    /// # Arguments
106    ///
107    /// * `content` - The `XorName` of the content to store
108    /// * `data_size` - Size of the data in bytes
109    /// * `data_type` - Type index of the data (0 for chunks)
110    ///
111    /// # Returns
112    ///
113    /// A signed `PaymentQuote` that the client can use to pay on-chain.
114    ///
115    /// # Errors
116    ///
117    /// Returns an error if signing is not configured.
118    pub fn create_quote(
119        &self,
120        content: XorName,
121        data_size: usize,
122        data_type: u32,
123    ) -> Result<PaymentQuote> {
124        let sign_fn = self
125            .sign_fn
126            .as_ref()
127            .ok_or_else(|| Error::Payment("Quote signing not configured".to_string()))?;
128
129        let timestamp = SystemTime::now();
130
131        // Calculate price from current record count
132        let price = calculate_price(self.metrics_tracker.records_stored());
133
134        // Convert XorName to xor_name::XorName
135        let xor_name = xor_name::XorName(content);
136
137        // Create bytes for signing (following autonomi's pattern)
138        let bytes =
139            PaymentQuote::bytes_for_signing(xor_name, timestamp, &price, &self.rewards_address);
140
141        // Sign the bytes
142        let signature = sign_fn(&bytes);
143        if signature.is_empty() {
144            return Err(Error::Payment(
145                "Signing produced empty signature".to_string(),
146            ));
147        }
148
149        let quote = PaymentQuote {
150            content: xor_name,
151            timestamp,
152            price,
153            pub_key: self.pub_key.clone(),
154            rewards_address: self.rewards_address,
155            signature,
156        };
157
158        if crate::logging::enabled!(crate::logging::Level::DEBUG) {
159            let content_hex = hex::encode(content);
160            debug!("Generated quote for {content_hex} (size: {data_size}, type: {data_type})");
161        }
162
163        Ok(quote)
164    }
165
166    /// Get the rewards address.
167    #[must_use]
168    pub fn rewards_address(&self) -> &RewardsAddress {
169        &self.rewards_address
170    }
171
172    /// Get the current number of records stored.
173    #[must_use]
174    pub fn records_stored(&self) -> usize {
175        self.metrics_tracker.records_stored()
176    }
177
178    /// Record data stored (delegates to metrics tracker).
179    pub fn record_store(&self) {
180        self.metrics_tracker.record_store();
181    }
182
183    /// Create a merkle candidate quote for batch payment using ML-DSA-65.
184    ///
185    /// Returns a `MerklePaymentCandidateNode` constructed with the node's
186    /// ML-DSA-65 public key and signature. This uses the same post-quantum
187    /// signing stack as regular payment quotes, rather than the ed25519
188    /// signing that the upstream `ant-evm` library assumes.
189    ///
190    /// The `pub_key` field stores the raw ML-DSA-65 public key bytes,
191    /// and `signature` stores the ML-DSA-65 signature over `bytes_to_sign()`.
192    /// Clients verify these using `verify_merkle_candidate_signature()`.
193    ///
194    /// # Errors
195    ///
196    /// Returns an error if signing is not configured.
197    pub fn create_merkle_candidate_quote(
198        &self,
199        data_size: usize,
200        data_type: u32,
201        merkle_payment_timestamp: u64,
202    ) -> Result<MerklePaymentCandidateNode> {
203        let sign_fn = self
204            .sign_fn
205            .as_ref()
206            .ok_or_else(|| Error::Payment("Quote signing not configured".to_string()))?;
207
208        let price = calculate_price(self.metrics_tracker.records_stored());
209
210        // Compute the same bytes_to_sign used by the upstream library
211        let msg = MerklePaymentCandidateNode::bytes_to_sign(
212            &price,
213            &self.rewards_address,
214            merkle_payment_timestamp,
215        );
216
217        // Sign with ML-DSA-65
218        let signature = sign_fn(&msg);
219        if signature.is_empty() {
220            return Err(Error::Payment(
221                "ML-DSA-65 signing produced empty signature for merkle candidate".to_string(),
222            ));
223        }
224
225        let candidate = MerklePaymentCandidateNode {
226            pub_key: self.pub_key.clone(),
227            price,
228            reward_address: self.rewards_address,
229            merkle_payment_timestamp,
230            signature,
231        };
232
233        if crate::logging::enabled!(crate::logging::Level::DEBUG) {
234            debug!(
235                "Generated ML-DSA-65 merkle candidate quote (size: {data_size}, type: {data_type}, ts: {merkle_payment_timestamp})"
236            );
237        }
238
239        Ok(candidate)
240    }
241}
242
243// Wire-side signature verification (`verify_quote_content`,
244// `verify_quote_signature`, `verify_merkle_candidate_signature`) lives
245// in `ant_protocol::payment::verify`. Re-exported from
246// `crate::payment` for backwards compatibility.
247
248/// Wire ML-DSA-65 signing from a node identity into a `QuoteGenerator`.
249///
250/// This is the shared setup used by both production nodes and devnet nodes
251/// to configure quote signing from a `NodeIdentity`.
252///
253/// # Arguments
254///
255/// * `generator` - The quote generator to configure
256/// * `identity` - The node identity providing signing keys
257///
258/// # Errors
259///
260/// Returns an error if the secret key cannot be deserialized or if the
261/// signing probe (a test signature at startup) fails.
262pub fn wire_ml_dsa_signer(
263    generator: &mut QuoteGenerator,
264    identity: &saorsa_core::identity::NodeIdentity,
265) -> Result<()> {
266    let pub_key_bytes = identity.public_key().as_bytes().to_vec();
267    let sk_bytes = identity.secret_key_bytes().to_vec();
268    let sk = MlDsaSecretKey::from_bytes(&sk_bytes)
269        .map_err(|e| Error::Crypto(format!("Failed to deserialize ML-DSA-65 secret key: {e}")))?;
270    let ml_dsa = MlDsa65::new();
271    generator.set_signer(pub_key_bytes, move |msg| match ml_dsa.sign(&sk, msg) {
272        Ok(sig) => sig.as_bytes().to_vec(),
273        Err(e) => {
274            crate::logging::error!("ML-DSA-65 signing failed: {e}");
275            vec![]
276        }
277    });
278    generator.probe_signer()?;
279    Ok(())
280}
281
282#[cfg(test)]
283#[allow(clippy::expect_used)]
284mod tests {
285    use super::*;
286    use crate::payment::metrics::QuotingMetricsTracker;
287    // Verification helpers live in ant-protocol; import them here so the
288    // long-standing node-side negative tests (tampered keys, swapped
289    // pub keys, wrong timestamp, etc.) keep running against the canonical
290    // wire-side implementation.
291    use ant_protocol::payment::verify::{
292        verify_merkle_candidate_signature, verify_quote_content, verify_quote_signature,
293    };
294    use evmlib::common::Amount;
295    use saorsa_pqc::pqc::types::MlDsaSecretKey;
296
297    fn create_test_generator() -> QuoteGenerator {
298        let rewards_address = RewardsAddress::new([1u8; 20]);
299        let metrics_tracker = QuotingMetricsTracker::new(100);
300
301        let mut generator = QuoteGenerator::new(rewards_address, metrics_tracker);
302
303        // Set up a dummy signer for testing
304        generator.set_signer(vec![0u8; 64], |bytes| {
305            // Dummy signature - just return hash of bytes
306            let mut sig = vec![0u8; 64];
307            for (i, b) in bytes.iter().take(64).enumerate() {
308                sig[i] = *b;
309            }
310            sig
311        });
312
313        generator
314    }
315
316    #[test]
317    fn test_create_quote() {
318        let generator = create_test_generator();
319        let content = [42u8; 32];
320
321        let quote = generator.create_quote(content, 1024, 0);
322        assert!(quote.is_ok());
323
324        let quote = quote.expect("valid quote");
325        assert_eq!(quote.content.0, content);
326    }
327
328    #[test]
329    fn test_verify_quote_content() {
330        let generator = create_test_generator();
331        let content = [42u8; 32];
332
333        let quote = generator
334            .create_quote(content, 1024, 0)
335            .expect("valid quote");
336        assert!(verify_quote_content(&quote, &content));
337
338        // Wrong content should fail
339        let wrong_content = [99u8; 32];
340        assert!(!verify_quote_content(&quote, &wrong_content));
341    }
342
343    #[test]
344    fn test_generator_without_signer() {
345        let rewards_address = RewardsAddress::new([1u8; 20]);
346        let metrics_tracker = QuotingMetricsTracker::new(100);
347        let generator = QuoteGenerator::new(rewards_address, metrics_tracker);
348
349        assert!(!generator.can_sign());
350
351        let content = [42u8; 32];
352        let result = generator.create_quote(content, 1024, 0);
353        assert!(result.is_err());
354    }
355
356    #[test]
357    fn test_quote_signature_round_trip_real_keys() {
358        let ml_dsa = MlDsa65::new();
359        let (public_key, secret_key) = ml_dsa.generate_keypair().expect("keypair generation");
360
361        let rewards_address = RewardsAddress::new([2u8; 20]);
362        let metrics_tracker = QuotingMetricsTracker::new(100);
363        let mut generator = QuoteGenerator::new(rewards_address, metrics_tracker);
364
365        let pub_key_bytes = public_key.as_bytes().to_vec();
366        let sk_bytes = secret_key.as_bytes().to_vec();
367        generator.set_signer(pub_key_bytes, move |msg| {
368            let sk = MlDsaSecretKey::from_bytes(&sk_bytes).expect("secret key parse");
369            let ml_dsa = MlDsa65::new();
370            ml_dsa.sign(&sk, msg).expect("signing").as_bytes().to_vec()
371        });
372
373        let content = [7u8; 32];
374        let quote = generator
375            .create_quote(content, 2048, 0)
376            .expect("create quote");
377
378        // Valid signature should verify
379        assert!(verify_quote_signature(&quote));
380
381        // Tamper with the signature — flip a byte
382        let mut tampered_quote = quote;
383        if let Some(byte) = tampered_quote.signature.first_mut() {
384            *byte ^= 0xFF;
385        }
386        assert!(!verify_quote_signature(&tampered_quote));
387    }
388
389    #[test]
390    fn test_empty_signature_fails_verification() {
391        let generator = create_test_generator();
392        let content = [42u8; 32];
393
394        let quote = generator
395            .create_quote(content, 1024, 0)
396            .expect("create quote");
397
398        // The dummy signer produces a 64-byte fake signature, not a valid
399        // ML-DSA-65 signature (3309 bytes), so verification must fail.
400        assert!(!verify_quote_signature(&quote));
401    }
402
403    #[test]
404    fn test_rewards_address_getter() {
405        let addr = RewardsAddress::new([42u8; 20]);
406        let metrics_tracker = QuotingMetricsTracker::new(0);
407        let generator = QuoteGenerator::new(addr, metrics_tracker);
408
409        assert_eq!(*generator.rewards_address(), addr);
410    }
411
412    #[test]
413    fn test_records_stored() {
414        let rewards_address = RewardsAddress::new([1u8; 20]);
415        let metrics_tracker = QuotingMetricsTracker::new(50);
416        let generator = QuoteGenerator::new(rewards_address, metrics_tracker);
417
418        assert_eq!(generator.records_stored(), 50);
419    }
420
421    #[test]
422    fn test_record_store_delegation() {
423        let rewards_address = RewardsAddress::new([1u8; 20]);
424        let metrics_tracker = QuotingMetricsTracker::new(0);
425        let generator = QuoteGenerator::new(rewards_address, metrics_tracker);
426
427        generator.record_store();
428        generator.record_store();
429        generator.record_store();
430
431        assert_eq!(generator.records_stored(), 3);
432    }
433
434    #[test]
435    fn test_create_quote_different_data_types() {
436        let generator = create_test_generator();
437        let content = [10u8; 32];
438
439        // All data types produce the same price (price depends on records_stored, not data_type)
440        let q0 = generator.create_quote(content, 1024, 0).expect("type 0");
441        let q1 = generator.create_quote(content, 512, 1).expect("type 1");
442        let q2 = generator.create_quote(content, 256, 2).expect("type 2");
443
444        // All quotes should have a valid price (minimum floor of 1)
445        assert!(q0.price >= Amount::from(1u64));
446        assert!(q1.price >= Amount::from(1u64));
447        assert!(q2.price >= Amount::from(1u64));
448    }
449
450    #[test]
451    fn test_create_quote_zero_size() {
452        let generator = create_test_generator();
453        let content = [11u8; 32];
454
455        // Price depends on records_stored, not data size
456        let quote = generator.create_quote(content, 0, 0).expect("zero size");
457        assert!(quote.price >= Amount::from(1u64));
458    }
459
460    #[test]
461    fn test_create_quote_large_size() {
462        let generator = create_test_generator();
463        let content = [12u8; 32];
464
465        // Price depends on records_stored, not data size
466        let quote = generator
467            .create_quote(content, 10_000_000, 0)
468            .expect("large size");
469        assert!(quote.price >= Amount::from(1u64));
470    }
471
472    #[test]
473    fn test_verify_quote_signature_empty_pub_key() {
474        let quote = PaymentQuote {
475            content: xor_name::XorName([0u8; 32]),
476            timestamp: SystemTime::now(),
477            price: Amount::from(1u64),
478            rewards_address: RewardsAddress::new([0u8; 20]),
479            pub_key: vec![],
480            signature: vec![],
481        };
482
483        // Empty pub key should fail parsing
484        assert!(!verify_quote_signature(&quote));
485    }
486
487    #[test]
488    fn test_can_sign_after_set_signer() {
489        let rewards_address = RewardsAddress::new([1u8; 20]);
490        let metrics_tracker = QuotingMetricsTracker::new(0);
491        let mut generator = QuoteGenerator::new(rewards_address, metrics_tracker);
492
493        assert!(!generator.can_sign());
494
495        generator.set_signer(vec![0u8; 32], |_| vec![0u8; 32]);
496
497        assert!(generator.can_sign());
498    }
499
500    #[test]
501    fn test_wire_ml_dsa_signer_returns_ok_with_valid_identity() {
502        let identity = saorsa_core::identity::NodeIdentity::generate().expect("keypair generation");
503        let rewards_address = RewardsAddress::new([3u8; 20]);
504        let metrics_tracker = QuotingMetricsTracker::new(0);
505        let mut generator = QuoteGenerator::new(rewards_address, metrics_tracker);
506
507        let result = wire_ml_dsa_signer(&mut generator, &identity);
508        assert!(
509            result.is_ok(),
510            "wire_ml_dsa_signer should succeed: {result:?}"
511        );
512        assert!(generator.can_sign());
513    }
514
515    #[test]
516    fn test_probe_signer_fails_without_signer() {
517        let rewards_address = RewardsAddress::new([1u8; 20]);
518        let metrics_tracker = QuotingMetricsTracker::new(0);
519        let generator = QuoteGenerator::new(rewards_address, metrics_tracker);
520
521        let result = generator.probe_signer();
522        assert!(result.is_err());
523    }
524
525    #[test]
526    fn test_probe_signer_fails_with_empty_signature() {
527        let rewards_address = RewardsAddress::new([1u8; 20]);
528        let metrics_tracker = QuotingMetricsTracker::new(0);
529        let mut generator = QuoteGenerator::new(rewards_address, metrics_tracker);
530
531        generator.set_signer(vec![0u8; 32], |_| vec![]);
532
533        let result = generator.probe_signer();
534        assert!(result.is_err());
535    }
536
537    #[test]
538    fn test_create_merkle_candidate_quote_with_ml_dsa() {
539        let ml_dsa = MlDsa65::new();
540        let (public_key, secret_key) = ml_dsa.generate_keypair().expect("keypair generation");
541
542        let rewards_address = RewardsAddress::new([0x42u8; 20]);
543        let metrics_tracker = QuotingMetricsTracker::new(50);
544        let mut generator = QuoteGenerator::new(rewards_address, metrics_tracker);
545
546        // Wire ML-DSA-65 signing (same as production nodes)
547        let pub_key_bytes = public_key.as_bytes().to_vec();
548        let sk_bytes = secret_key.as_bytes().to_vec();
549        generator.set_signer(pub_key_bytes.clone(), move |msg| {
550            let sk = MlDsaSecretKey::from_bytes(&sk_bytes).expect("sk parse");
551            let ml_dsa = MlDsa65::new();
552            ml_dsa.sign(&sk, msg).expect("sign").as_bytes().to_vec()
553        });
554
555        let timestamp = std::time::SystemTime::now()
556            .duration_since(std::time::UNIX_EPOCH)
557            .expect("system time")
558            .as_secs();
559
560        let result = generator.create_merkle_candidate_quote(2048, 0, timestamp);
561
562        assert!(
563            result.is_ok(),
564            "create_merkle_candidate_quote should succeed: {result:?}"
565        );
566
567        let candidate = result.expect("valid candidate");
568
569        // Verify the returned node has the correct reward address
570        assert_eq!(candidate.reward_address, rewards_address);
571
572        // Verify the timestamp was set correctly
573        assert_eq!(candidate.merkle_payment_timestamp, timestamp);
574
575        // Verify price was calculated from records_stored using the pricing formula
576        assert_eq!(candidate.price, calculate_price(50));
577
578        // Verify the public key is the ML-DSA-65 public key (not ed25519)
579        assert_eq!(
580            candidate.pub_key, pub_key_bytes,
581            "Public key should be raw ML-DSA-65 bytes"
582        );
583
584        // Verify ML-DSA-65 signature is valid using our verifier
585        assert!(
586            verify_merkle_candidate_signature(&candidate),
587            "ML-DSA-65 merkle candidate signature must be valid"
588        );
589
590        // Verify tampered timestamp invalidates ML-DSA signature
591        let mut tampered = candidate;
592        tampered.merkle_payment_timestamp = timestamp + 1;
593        assert!(
594            !verify_merkle_candidate_signature(&tampered),
595            "Tampered timestamp should invalidate the ML-DSA-65 signature"
596        );
597    }
598
599    // =========================================================================
600    // verify_merkle_candidate_signature — direct tests
601    // =========================================================================
602
603    /// Helper: create a validly-signed `MerklePaymentCandidateNode`.
604    fn make_valid_merkle_candidate() -> MerklePaymentCandidateNode {
605        let ml_dsa = MlDsa65::new();
606        let (public_key, secret_key) = ml_dsa.generate_keypair().expect("keygen");
607
608        let rewards_address = RewardsAddress::new([0xABu8; 20]);
609        let timestamp = std::time::SystemTime::now()
610            .duration_since(std::time::UNIX_EPOCH)
611            .expect("system time")
612            .as_secs();
613        let price = Amount::from(42u64);
614
615        let msg = MerklePaymentCandidateNode::bytes_to_sign(&price, &rewards_address, timestamp);
616        let sk = MlDsaSecretKey::from_bytes(secret_key.as_bytes()).expect("sk");
617        let signature = ml_dsa.sign(&sk, &msg).expect("sign").as_bytes().to_vec();
618
619        MerklePaymentCandidateNode {
620            pub_key: public_key.as_bytes().to_vec(),
621            price,
622            reward_address: rewards_address,
623            merkle_payment_timestamp: timestamp,
624            signature,
625        }
626    }
627
628    #[test]
629    fn test_verify_merkle_candidate_valid_signature() {
630        let candidate = make_valid_merkle_candidate();
631        assert!(
632            verify_merkle_candidate_signature(&candidate),
633            "Freshly signed merkle candidate must verify"
634        );
635    }
636
637    #[test]
638    fn test_verify_merkle_candidate_tampered_pub_key() {
639        let mut candidate = make_valid_merkle_candidate();
640        // Flip a byte in the public key
641        if let Some(byte) = candidate.pub_key.first_mut() {
642            *byte ^= 0xFF;
643        }
644        assert!(
645            !verify_merkle_candidate_signature(&candidate),
646            "Tampered pub_key must invalidate the signature"
647        );
648    }
649
650    #[test]
651    fn test_verify_merkle_candidate_tampered_reward_address() {
652        let mut candidate = make_valid_merkle_candidate();
653        candidate.reward_address = RewardsAddress::new([0xFFu8; 20]);
654        assert!(
655            !verify_merkle_candidate_signature(&candidate),
656            "Tampered reward_address must invalidate the signature"
657        );
658    }
659
660    #[test]
661    fn test_verify_merkle_candidate_tampered_price() {
662        let mut candidate = make_valid_merkle_candidate();
663        candidate.price = Amount::from(999_999u64);
664        assert!(
665            !verify_merkle_candidate_signature(&candidate),
666            "Tampered price must invalidate the signature"
667        );
668    }
669
670    #[test]
671    fn test_verify_merkle_candidate_tampered_signature_byte() {
672        let mut candidate = make_valid_merkle_candidate();
673        if let Some(byte) = candidate.signature.first_mut() {
674            *byte ^= 0xFF;
675        }
676        assert!(
677            !verify_merkle_candidate_signature(&candidate),
678            "Tampered signature byte must fail verification"
679        );
680    }
681
682    #[test]
683    fn test_verify_merkle_candidate_empty_pub_key() {
684        let mut candidate = make_valid_merkle_candidate();
685        candidate.pub_key = vec![];
686        assert!(
687            !verify_merkle_candidate_signature(&candidate),
688            "Empty pub_key must fail verification"
689        );
690    }
691
692    #[test]
693    fn test_verify_merkle_candidate_empty_signature() {
694        let mut candidate = make_valid_merkle_candidate();
695        candidate.signature = vec![];
696        assert!(
697            !verify_merkle_candidate_signature(&candidate),
698            "Empty signature must fail verification"
699        );
700    }
701
702    #[test]
703    fn test_verify_merkle_candidate_wrong_length_signature() {
704        let mut candidate = make_valid_merkle_candidate();
705        // ML-DSA-65 signatures are 3309 bytes; use a truncated one
706        candidate.signature = vec![0xAA; 100];
707        assert!(
708            !verify_merkle_candidate_signature(&candidate),
709            "Wrong-length signature must fail verification"
710        );
711    }
712
713    #[test]
714    fn test_verify_merkle_candidate_wrong_length_pub_key() {
715        let mut candidate = make_valid_merkle_candidate();
716        // ML-DSA-65 pub keys are 1952 bytes; use a truncated one
717        candidate.pub_key = vec![0xBB; 100];
718        assert!(
719            !verify_merkle_candidate_signature(&candidate),
720            "Wrong-length pub_key must fail verification"
721        );
722    }
723
724    #[test]
725    fn test_verify_merkle_candidate_cross_key_rejection() {
726        // Sign with one key pair, then swap in a different valid public key
727        let candidate = make_valid_merkle_candidate();
728        let ml_dsa = MlDsa65::new();
729        let (other_pk, _) = ml_dsa.generate_keypair().expect("keygen");
730
731        let mut swapped = candidate;
732        swapped.pub_key = other_pk.as_bytes().to_vec();
733        assert!(
734            !verify_merkle_candidate_signature(&swapped),
735            "Signature from key A must not verify under key B"
736        );
737    }
738}