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