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