Skip to main content

saorsa_node/payment/
quote.rs

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