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::{MlDsaPublicKey, MlDsaSecretKey, MlDsaSignature};
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 a payment received (delegates to metrics tracker).
179    pub fn record_payment(&self) {
180        self.metrics_tracker.record_payment();
181    }
182
183    /// Record data stored (delegates to metrics tracker).
184    pub fn record_store(&self, data_type: u32) {
185        self.metrics_tracker.record_store(data_type);
186    }
187
188    /// Create a merkle candidate quote for batch payment using ML-DSA-65.
189    ///
190    /// Returns a `MerklePaymentCandidateNode` constructed with the node's
191    /// ML-DSA-65 public key and signature. This uses the same post-quantum
192    /// signing stack as regular payment quotes, rather than the ed25519
193    /// signing that the upstream `ant-evm` library assumes.
194    ///
195    /// The `pub_key` field stores the raw ML-DSA-65 public key bytes,
196    /// and `signature` stores the ML-DSA-65 signature over `bytes_to_sign()`.
197    /// Clients verify these using `verify_merkle_candidate_signature()`.
198    ///
199    /// # Errors
200    ///
201    /// Returns an error if signing is not configured.
202    pub fn create_merkle_candidate_quote(
203        &self,
204        data_size: usize,
205        data_type: u32,
206        merkle_payment_timestamp: u64,
207    ) -> Result<MerklePaymentCandidateNode> {
208        let sign_fn = self
209            .sign_fn
210            .as_ref()
211            .ok_or_else(|| Error::Payment("Quote signing not configured".to_string()))?;
212
213        let price = calculate_price(self.metrics_tracker.records_stored());
214
215        // Compute the same bytes_to_sign used by the upstream library
216        let msg = MerklePaymentCandidateNode::bytes_to_sign(
217            &price,
218            &self.rewards_address,
219            merkle_payment_timestamp,
220        );
221
222        // Sign with ML-DSA-65
223        let signature = sign_fn(&msg);
224        if signature.is_empty() {
225            return Err(Error::Payment(
226                "ML-DSA-65 signing produced empty signature for merkle candidate".to_string(),
227            ));
228        }
229
230        let candidate = MerklePaymentCandidateNode {
231            pub_key: self.pub_key.clone(),
232            price,
233            reward_address: self.rewards_address,
234            merkle_payment_timestamp,
235            signature,
236        };
237
238        if crate::logging::enabled!(crate::logging::Level::DEBUG) {
239            debug!(
240                "Generated ML-DSA-65 merkle candidate quote (size: {data_size}, type: {data_type}, ts: {merkle_payment_timestamp})"
241            );
242        }
243
244        Ok(candidate)
245    }
246}
247
248/// Verify a payment quote's content address and ML-DSA-65 signature.
249///
250/// # Arguments
251///
252/// * `quote` - The quote to verify
253/// * `expected_content` - The expected content `XorName`
254///
255/// # Returns
256///
257/// `true` if the content matches and the ML-DSA-65 signature is valid.
258#[must_use]
259pub fn verify_quote_content(quote: &PaymentQuote, expected_content: &XorName) -> bool {
260    // Check content matches
261    if quote.content.0 != *expected_content {
262        if crate::logging::enabled!(crate::logging::Level::DEBUG) {
263            debug!(
264                "Quote content mismatch: expected {}, got {}",
265                hex::encode(expected_content),
266                hex::encode(quote.content.0)
267            );
268        }
269        return false;
270    }
271    true
272}
273
274/// Verify that a payment quote has a valid ML-DSA-65 signature.
275///
276/// This replaces ant-evm's `check_is_signed_by_claimed_peer()` which only
277/// handles Ed25519/libp2p signatures. Autonomi uses ML-DSA-65 post-quantum
278/// signatures for quote signing.
279///
280/// # Arguments
281///
282/// * `quote` - The quote to verify
283///
284/// # Returns
285///
286/// `true` if the ML-DSA-65 signature is valid for the quote's content.
287#[must_use]
288pub fn verify_quote_signature(quote: &PaymentQuote) -> bool {
289    // Parse public key from quote
290    let pub_key = match MlDsaPublicKey::from_bytes(&quote.pub_key) {
291        Ok(pk) => pk,
292        Err(e) => {
293            debug!("Failed to parse ML-DSA-65 public key from quote: {e}");
294            return false;
295        }
296    };
297
298    // Parse signature from quote
299    let signature = match MlDsaSignature::from_bytes(&quote.signature) {
300        Ok(sig) => sig,
301        Err(e) => {
302            debug!("Failed to parse ML-DSA-65 signature from quote: {e}");
303            return false;
304        }
305    };
306
307    // Get the bytes that were signed
308    let bytes = quote.bytes_for_sig();
309
310    // Verify using ML-DSA-65 implementation
311    let ml_dsa = MlDsa65::new();
312    match ml_dsa.verify(&pub_key, &bytes, &signature) {
313        Ok(valid) => {
314            if !valid {
315                debug!("ML-DSA-65 quote signature verification failed");
316            }
317            valid
318        }
319        Err(e) => {
320            debug!("ML-DSA-65 verification error: {e}");
321            false
322        }
323    }
324}
325
326/// Verify a `MerklePaymentCandidateNode` signature using ML-DSA-65.
327///
328/// Autonomi uses ML-DSA-65 post-quantum signatures for merkle candidate signing,
329/// rather than the ed25519 signatures used by the upstream `ant-evm` library.
330/// The `pub_key` field contains the raw ML-DSA-65 public key bytes, and
331/// `signature` contains the ML-DSA-65 signature over `bytes_to_sign()`.
332///
333/// This replaces `MerklePaymentCandidateNode::verify_signature()` which
334/// expects libp2p ed25519 keys.
335#[must_use]
336pub fn verify_merkle_candidate_signature(candidate: &MerklePaymentCandidateNode) -> bool {
337    let pub_key = match MlDsaPublicKey::from_bytes(&candidate.pub_key) {
338        Ok(pk) => pk,
339        Err(e) => {
340            debug!("Failed to parse ML-DSA-65 public key from merkle candidate: {e}");
341            return false;
342        }
343    };
344
345    let signature = match MlDsaSignature::from_bytes(&candidate.signature) {
346        Ok(sig) => sig,
347        Err(e) => {
348            debug!("Failed to parse ML-DSA-65 signature from merkle candidate: {e}");
349            return false;
350        }
351    };
352
353    let msg = MerklePaymentCandidateNode::bytes_to_sign(
354        &candidate.price,
355        &candidate.reward_address,
356        candidate.merkle_payment_timestamp,
357    );
358
359    let ml_dsa = MlDsa65::new();
360    match ml_dsa.verify(&pub_key, &msg, &signature) {
361        Ok(valid) => {
362            if !valid {
363                debug!("ML-DSA-65 merkle candidate signature verification failed");
364            }
365            valid
366        }
367        Err(e) => {
368            debug!("ML-DSA-65 merkle candidate verification error: {e}");
369            false
370        }
371    }
372}
373
374/// Wire ML-DSA-65 signing from a node identity into a `QuoteGenerator`.
375///
376/// This is the shared setup used by both production nodes and devnet nodes
377/// to configure quote signing from a `NodeIdentity`.
378///
379/// # Arguments
380///
381/// * `generator` - The quote generator to configure
382/// * `identity` - The node identity providing signing keys
383///
384/// # Errors
385///
386/// Returns an error if the secret key cannot be deserialized or if the
387/// signing probe (a test signature at startup) fails.
388pub fn wire_ml_dsa_signer(
389    generator: &mut QuoteGenerator,
390    identity: &saorsa_core::identity::NodeIdentity,
391) -> Result<()> {
392    let pub_key_bytes = identity.public_key().as_bytes().to_vec();
393    let sk_bytes = identity.secret_key_bytes().to_vec();
394    let sk = MlDsaSecretKey::from_bytes(&sk_bytes)
395        .map_err(|e| Error::Crypto(format!("Failed to deserialize ML-DSA-65 secret key: {e}")))?;
396    let ml_dsa = MlDsa65::new();
397    generator.set_signer(pub_key_bytes, move |msg| match ml_dsa.sign(&sk, msg) {
398        Ok(sig) => sig.as_bytes().to_vec(),
399        Err(e) => {
400            crate::logging::error!("ML-DSA-65 signing failed: {e}");
401            vec![]
402        }
403    });
404    generator.probe_signer()?;
405    Ok(())
406}
407
408#[cfg(test)]
409#[allow(clippy::expect_used)]
410mod tests {
411    use super::*;
412    use crate::payment::metrics::QuotingMetricsTracker;
413    use evmlib::common::Amount;
414    use saorsa_pqc::pqc::types::MlDsaSecretKey;
415
416    fn create_test_generator() -> QuoteGenerator {
417        let rewards_address = RewardsAddress::new([1u8; 20]);
418        let metrics_tracker = QuotingMetricsTracker::new(100);
419
420        let mut generator = QuoteGenerator::new(rewards_address, metrics_tracker);
421
422        // Set up a dummy signer for testing
423        generator.set_signer(vec![0u8; 64], |bytes| {
424            // Dummy signature - just return hash of bytes
425            let mut sig = vec![0u8; 64];
426            for (i, b) in bytes.iter().take(64).enumerate() {
427                sig[i] = *b;
428            }
429            sig
430        });
431
432        generator
433    }
434
435    #[test]
436    fn test_create_quote() {
437        let generator = create_test_generator();
438        let content = [42u8; 32];
439
440        let quote = generator.create_quote(content, 1024, 0);
441        assert!(quote.is_ok());
442
443        let quote = quote.expect("valid quote");
444        assert_eq!(quote.content.0, content);
445    }
446
447    #[test]
448    fn test_verify_quote_content() {
449        let generator = create_test_generator();
450        let content = [42u8; 32];
451
452        let quote = generator
453            .create_quote(content, 1024, 0)
454            .expect("valid quote");
455        assert!(verify_quote_content(&quote, &content));
456
457        // Wrong content should fail
458        let wrong_content = [99u8; 32];
459        assert!(!verify_quote_content(&quote, &wrong_content));
460    }
461
462    #[test]
463    fn test_generator_without_signer() {
464        let rewards_address = RewardsAddress::new([1u8; 20]);
465        let metrics_tracker = QuotingMetricsTracker::new(100);
466        let generator = QuoteGenerator::new(rewards_address, metrics_tracker);
467
468        assert!(!generator.can_sign());
469
470        let content = [42u8; 32];
471        let result = generator.create_quote(content, 1024, 0);
472        assert!(result.is_err());
473    }
474
475    #[test]
476    fn test_quote_signature_round_trip_real_keys() {
477        let ml_dsa = MlDsa65::new();
478        let (public_key, secret_key) = ml_dsa.generate_keypair().expect("keypair generation");
479
480        let rewards_address = RewardsAddress::new([2u8; 20]);
481        let metrics_tracker = QuotingMetricsTracker::new(100);
482        let mut generator = QuoteGenerator::new(rewards_address, metrics_tracker);
483
484        let pub_key_bytes = public_key.as_bytes().to_vec();
485        let sk_bytes = secret_key.as_bytes().to_vec();
486        generator.set_signer(pub_key_bytes, move |msg| {
487            let sk = MlDsaSecretKey::from_bytes(&sk_bytes).expect("secret key parse");
488            let ml_dsa = MlDsa65::new();
489            ml_dsa.sign(&sk, msg).expect("signing").as_bytes().to_vec()
490        });
491
492        let content = [7u8; 32];
493        let quote = generator
494            .create_quote(content, 2048, 0)
495            .expect("create quote");
496
497        // Valid signature should verify
498        assert!(verify_quote_signature(&quote));
499
500        // Tamper with the signature — flip a byte
501        let mut tampered_quote = quote;
502        if let Some(byte) = tampered_quote.signature.first_mut() {
503            *byte ^= 0xFF;
504        }
505        assert!(!verify_quote_signature(&tampered_quote));
506    }
507
508    #[test]
509    fn test_empty_signature_fails_verification() {
510        let generator = create_test_generator();
511        let content = [42u8; 32];
512
513        let quote = generator
514            .create_quote(content, 1024, 0)
515            .expect("create quote");
516
517        // The dummy signer produces a 64-byte fake signature, not a valid
518        // ML-DSA-65 signature (3309 bytes), so verification must fail.
519        assert!(!verify_quote_signature(&quote));
520    }
521
522    #[test]
523    fn test_rewards_address_getter() {
524        let addr = RewardsAddress::new([42u8; 20]);
525        let metrics_tracker = QuotingMetricsTracker::new(0);
526        let generator = QuoteGenerator::new(addr, metrics_tracker);
527
528        assert_eq!(*generator.rewards_address(), addr);
529    }
530
531    #[test]
532    fn test_records_stored() {
533        let rewards_address = RewardsAddress::new([1u8; 20]);
534        let metrics_tracker = QuotingMetricsTracker::new(50);
535        let generator = QuoteGenerator::new(rewards_address, metrics_tracker);
536
537        assert_eq!(generator.records_stored(), 50);
538    }
539
540    #[test]
541    fn test_record_store_delegation() {
542        let rewards_address = RewardsAddress::new([1u8; 20]);
543        let metrics_tracker = QuotingMetricsTracker::new(0);
544        let generator = QuoteGenerator::new(rewards_address, metrics_tracker);
545
546        generator.record_store(0);
547        generator.record_store(1);
548        generator.record_store(0);
549
550        assert_eq!(generator.records_stored(), 3);
551    }
552
553    #[test]
554    fn test_create_quote_different_data_types() {
555        let generator = create_test_generator();
556        let content = [10u8; 32];
557
558        // All data types produce the same price (price depends on records_stored, not data_type)
559        let q0 = generator.create_quote(content, 1024, 0).expect("type 0");
560        let q1 = generator.create_quote(content, 512, 1).expect("type 1");
561        let q2 = generator.create_quote(content, 256, 2).expect("type 2");
562
563        // All quotes should have a valid price (minimum floor of 1)
564        assert!(q0.price >= Amount::from(1u64));
565        assert!(q1.price >= Amount::from(1u64));
566        assert!(q2.price >= Amount::from(1u64));
567    }
568
569    #[test]
570    fn test_create_quote_zero_size() {
571        let generator = create_test_generator();
572        let content = [11u8; 32];
573
574        // Price depends on records_stored, not data size
575        let quote = generator.create_quote(content, 0, 0).expect("zero size");
576        assert!(quote.price >= Amount::from(1u64));
577    }
578
579    #[test]
580    fn test_create_quote_large_size() {
581        let generator = create_test_generator();
582        let content = [12u8; 32];
583
584        // Price depends on records_stored, not data size
585        let quote = generator
586            .create_quote(content, 10_000_000, 0)
587            .expect("large size");
588        assert!(quote.price >= Amount::from(1u64));
589    }
590
591    #[test]
592    fn test_verify_quote_signature_empty_pub_key() {
593        let quote = PaymentQuote {
594            content: xor_name::XorName([0u8; 32]),
595            timestamp: SystemTime::now(),
596            price: Amount::from(1u64),
597            rewards_address: RewardsAddress::new([0u8; 20]),
598            pub_key: vec![],
599            signature: vec![],
600        };
601
602        // Empty pub key should fail parsing
603        assert!(!verify_quote_signature(&quote));
604    }
605
606    #[test]
607    fn test_can_sign_after_set_signer() {
608        let rewards_address = RewardsAddress::new([1u8; 20]);
609        let metrics_tracker = QuotingMetricsTracker::new(0);
610        let mut generator = QuoteGenerator::new(rewards_address, metrics_tracker);
611
612        assert!(!generator.can_sign());
613
614        generator.set_signer(vec![0u8; 32], |_| vec![0u8; 32]);
615
616        assert!(generator.can_sign());
617    }
618
619    #[test]
620    fn test_wire_ml_dsa_signer_returns_ok_with_valid_identity() {
621        let identity = saorsa_core::identity::NodeIdentity::generate().expect("keypair generation");
622        let rewards_address = RewardsAddress::new([3u8; 20]);
623        let metrics_tracker = QuotingMetricsTracker::new(0);
624        let mut generator = QuoteGenerator::new(rewards_address, metrics_tracker);
625
626        let result = wire_ml_dsa_signer(&mut generator, &identity);
627        assert!(
628            result.is_ok(),
629            "wire_ml_dsa_signer should succeed: {result:?}"
630        );
631        assert!(generator.can_sign());
632    }
633
634    #[test]
635    fn test_probe_signer_fails_without_signer() {
636        let rewards_address = RewardsAddress::new([1u8; 20]);
637        let metrics_tracker = QuotingMetricsTracker::new(0);
638        let generator = QuoteGenerator::new(rewards_address, metrics_tracker);
639
640        let result = generator.probe_signer();
641        assert!(result.is_err());
642    }
643
644    #[test]
645    fn test_probe_signer_fails_with_empty_signature() {
646        let rewards_address = RewardsAddress::new([1u8; 20]);
647        let metrics_tracker = QuotingMetricsTracker::new(0);
648        let mut generator = QuoteGenerator::new(rewards_address, metrics_tracker);
649
650        generator.set_signer(vec![0u8; 32], |_| vec![]);
651
652        let result = generator.probe_signer();
653        assert!(result.is_err());
654    }
655
656    #[test]
657    fn test_create_merkle_candidate_quote_with_ml_dsa() {
658        let ml_dsa = MlDsa65::new();
659        let (public_key, secret_key) = ml_dsa.generate_keypair().expect("keypair generation");
660
661        let rewards_address = RewardsAddress::new([0x42u8; 20]);
662        let metrics_tracker = QuotingMetricsTracker::new(50);
663        let mut generator = QuoteGenerator::new(rewards_address, metrics_tracker);
664
665        // Wire ML-DSA-65 signing (same as production nodes)
666        let pub_key_bytes = public_key.as_bytes().to_vec();
667        let sk_bytes = secret_key.as_bytes().to_vec();
668        generator.set_signer(pub_key_bytes.clone(), move |msg| {
669            let sk = MlDsaSecretKey::from_bytes(&sk_bytes).expect("sk parse");
670            let ml_dsa = MlDsa65::new();
671            ml_dsa.sign(&sk, msg).expect("sign").as_bytes().to_vec()
672        });
673
674        let timestamp = std::time::SystemTime::now()
675            .duration_since(std::time::UNIX_EPOCH)
676            .expect("system time")
677            .as_secs();
678
679        let result = generator.create_merkle_candidate_quote(2048, 0, timestamp);
680
681        assert!(
682            result.is_ok(),
683            "create_merkle_candidate_quote should succeed: {result:?}"
684        );
685
686        let candidate = result.expect("valid candidate");
687
688        // Verify the returned node has the correct reward address
689        assert_eq!(candidate.reward_address, rewards_address);
690
691        // Verify the timestamp was set correctly
692        assert_eq!(candidate.merkle_payment_timestamp, timestamp);
693
694        // Verify price was calculated from records_stored using the pricing formula
695        assert_eq!(candidate.price, calculate_price(50));
696
697        // Verify the public key is the ML-DSA-65 public key (not ed25519)
698        assert_eq!(
699            candidate.pub_key, pub_key_bytes,
700            "Public key should be raw ML-DSA-65 bytes"
701        );
702
703        // Verify ML-DSA-65 signature is valid using our verifier
704        assert!(
705            verify_merkle_candidate_signature(&candidate),
706            "ML-DSA-65 merkle candidate signature must be valid"
707        );
708
709        // Verify tampered timestamp invalidates ML-DSA signature
710        let mut tampered = candidate;
711        tampered.merkle_payment_timestamp = timestamp + 1;
712        assert!(
713            !verify_merkle_candidate_signature(&tampered),
714            "Tampered timestamp should invalidate the ML-DSA-65 signature"
715        );
716    }
717
718    // =========================================================================
719    // verify_merkle_candidate_signature — direct tests
720    // =========================================================================
721
722    /// Helper: create a validly-signed `MerklePaymentCandidateNode`.
723    fn make_valid_merkle_candidate() -> MerklePaymentCandidateNode {
724        let ml_dsa = MlDsa65::new();
725        let (public_key, secret_key) = ml_dsa.generate_keypair().expect("keygen");
726
727        let rewards_address = RewardsAddress::new([0xABu8; 20]);
728        let timestamp = std::time::SystemTime::now()
729            .duration_since(std::time::UNIX_EPOCH)
730            .expect("system time")
731            .as_secs();
732        let price = Amount::from(42u64);
733
734        let msg = MerklePaymentCandidateNode::bytes_to_sign(&price, &rewards_address, timestamp);
735        let sk = MlDsaSecretKey::from_bytes(secret_key.as_bytes()).expect("sk");
736        let signature = ml_dsa.sign(&sk, &msg).expect("sign").as_bytes().to_vec();
737
738        MerklePaymentCandidateNode {
739            pub_key: public_key.as_bytes().to_vec(),
740            price,
741            reward_address: rewards_address,
742            merkle_payment_timestamp: timestamp,
743            signature,
744        }
745    }
746
747    #[test]
748    fn test_verify_merkle_candidate_valid_signature() {
749        let candidate = make_valid_merkle_candidate();
750        assert!(
751            verify_merkle_candidate_signature(&candidate),
752            "Freshly signed merkle candidate must verify"
753        );
754    }
755
756    #[test]
757    fn test_verify_merkle_candidate_tampered_pub_key() {
758        let mut candidate = make_valid_merkle_candidate();
759        // Flip a byte in the public key
760        if let Some(byte) = candidate.pub_key.first_mut() {
761            *byte ^= 0xFF;
762        }
763        assert!(
764            !verify_merkle_candidate_signature(&candidate),
765            "Tampered pub_key must invalidate the signature"
766        );
767    }
768
769    #[test]
770    fn test_verify_merkle_candidate_tampered_reward_address() {
771        let mut candidate = make_valid_merkle_candidate();
772        candidate.reward_address = RewardsAddress::new([0xFFu8; 20]);
773        assert!(
774            !verify_merkle_candidate_signature(&candidate),
775            "Tampered reward_address must invalidate the signature"
776        );
777    }
778
779    #[test]
780    fn test_verify_merkle_candidate_tampered_price() {
781        let mut candidate = make_valid_merkle_candidate();
782        candidate.price = Amount::from(999_999u64);
783        assert!(
784            !verify_merkle_candidate_signature(&candidate),
785            "Tampered price must invalidate the signature"
786        );
787    }
788
789    #[test]
790    fn test_verify_merkle_candidate_tampered_signature_byte() {
791        let mut candidate = make_valid_merkle_candidate();
792        if let Some(byte) = candidate.signature.first_mut() {
793            *byte ^= 0xFF;
794        }
795        assert!(
796            !verify_merkle_candidate_signature(&candidate),
797            "Tampered signature byte must fail verification"
798        );
799    }
800
801    #[test]
802    fn test_verify_merkle_candidate_empty_pub_key() {
803        let mut candidate = make_valid_merkle_candidate();
804        candidate.pub_key = vec![];
805        assert!(
806            !verify_merkle_candidate_signature(&candidate),
807            "Empty pub_key must fail verification"
808        );
809    }
810
811    #[test]
812    fn test_verify_merkle_candidate_empty_signature() {
813        let mut candidate = make_valid_merkle_candidate();
814        candidate.signature = vec![];
815        assert!(
816            !verify_merkle_candidate_signature(&candidate),
817            "Empty signature must fail verification"
818        );
819    }
820
821    #[test]
822    fn test_verify_merkle_candidate_wrong_length_signature() {
823        let mut candidate = make_valid_merkle_candidate();
824        // ML-DSA-65 signatures are 3309 bytes; use a truncated one
825        candidate.signature = vec![0xAA; 100];
826        assert!(
827            !verify_merkle_candidate_signature(&candidate),
828            "Wrong-length signature must fail verification"
829        );
830    }
831
832    #[test]
833    fn test_verify_merkle_candidate_wrong_length_pub_key() {
834        let mut candidate = make_valid_merkle_candidate();
835        // ML-DSA-65 pub keys are 1952 bytes; use a truncated one
836        candidate.pub_key = vec![0xBB; 100];
837        assert!(
838            !verify_merkle_candidate_signature(&candidate),
839            "Wrong-length pub_key must fail verification"
840        );
841    }
842
843    #[test]
844    fn test_verify_merkle_candidate_cross_key_rejection() {
845        // Sign with one key pair, then swap in a different valid public key
846        let candidate = make_valid_merkle_candidate();
847        let ml_dsa = MlDsa65::new();
848        let (other_pk, _) = ml_dsa.generate_keypair().expect("keygen");
849
850        let mut swapped = candidate;
851        swapped.pub_key = other_pk.as_bytes().to_vec();
852        assert!(
853            !verify_merkle_candidate_signature(&swapped),
854            "Signature from key A must not verify under key B"
855        );
856    }
857}