Skip to main content

cashu/nuts/
nut29.rs

1//! NUT-29: Batch Mint Tokens
2//!
3//! <https://github.com/cashubtc/nuts/blob/main/29.md>
4
5use bitcoin::secp256k1::schnorr::Signature;
6use serde::de::DeserializeOwned;
7use serde::{Deserialize, Serialize};
8
9use super::nut01::Error as Nut01Error;
10use super::{PublicKey, SecretKey};
11use crate::{Amount, BlindedMessage};
12
13/// Error types for batch operations
14#[derive(Debug, thiserror::Error)]
15pub enum Error {
16    /// Signature error from nut20
17    #[error(transparent)]
18    Signature(#[from] super::nut20::Error),
19    /// NUT-01 error
20    #[error(transparent)]
21    Nut01(#[from] Nut01Error),
22}
23
24/// NUT-29 Settings for mint info
25#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
26pub struct Settings {
27    /// Maximum number of quotes allowed in a single batch request
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub max_batch_size: Option<u64>,
30    /// Supported payment methods for batch minting
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub methods: Option<Vec<String>>,
33}
34
35impl Settings {
36    /// Create new NUT-29 settings
37    pub fn new(max_batch_size: Option<u64>, methods: Option<Vec<String>>) -> Self {
38        Self {
39            max_batch_size,
40            methods,
41        }
42    }
43
44    /// Check if settings are empty (no configuration)
45    pub fn is_empty(&self) -> bool {
46        self.max_batch_size.is_none() && self.methods.is_none()
47    }
48}
49
50/// Batch check mint quote request per NUT-29
51///
52/// Used to check the state of multiple mint quotes at once.
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(bound = "Q: Serialize + DeserializeOwned")]
55pub struct BatchCheckMintQuoteRequest<Q> {
56    /// Array of unique mint quote IDs to check
57    pub quotes: Vec<Q>,
58}
59
60/// Batch mint request per NUT-29
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(bound = "Q: Serialize + DeserializeOwned")]
63pub struct BatchMintRequest<Q> {
64    /// Array of unique quote IDs
65    pub quotes: Vec<Q>,
66    /// Optional expected amounts per quote (for bolt12-like methods)
67    pub quote_amounts: Option<Vec<Amount>>,
68    /// Shared outputs across all quotes
69    pub outputs: Vec<BlindedMessage>,
70    /// Signatures per quote (None if all quotes are unlocked)
71    pub signatures: Option<Vec<Option<String>>>,
72}
73
74impl<Q> BatchMintRequest<Q>
75where
76    Q: ToString,
77{
78    /// Constructs the message to be signed according to NUT-20 for a quote.
79    ///
80    /// The message is constructed by concatenating (as UTF-8 encoded bytes):
81    /// 1. The quote ID (as UTF-8)
82    /// 2. All blinded secrets (B_0 through B_n) converted to hex strings (as UTF-8)
83    ///
84    /// Format: `quote_id || B_0 || B_1 || ... || B_n`
85    /// where each component is encoded as UTF-8 bytes.
86    pub fn msg_to_sign(&self, quote: &Q) -> Vec<u8> {
87        let quote_id = quote.to_string();
88        let capacity = quote_id.len() + (self.outputs.len() * 66);
89        let mut msg = Vec::with_capacity(capacity);
90
91        msg.extend_from_slice(quote_id.as_bytes());
92
93        for output in &self.outputs {
94            msg.extend_from_slice(output.blinded_secret.to_hex().as_bytes());
95        }
96
97        msg
98    }
99
100    /// Sign one quote inside a batch mint request.
101    pub fn sign_quote(&self, quote: &Q, secret_key: &SecretKey) -> Result<String, Error> {
102        let msg = self.msg_to_sign(quote);
103        let signature: Signature = secret_key.sign(&msg)?;
104        Ok(signature.to_string())
105    }
106
107    /// Verify one quote signature inside a batch mint request.
108    pub fn verify_quote_signature(
109        &self,
110        quote: &Q,
111        signature: &str,
112        pubkey: &PublicKey,
113    ) -> Result<(), Error> {
114        let signature = signature
115            .parse::<Signature>()
116            .map_err(|_| super::nut20::Error::InvalidSignature)?;
117        let msg = self.msg_to_sign(quote);
118
119        pubkey
120            .verify(&msg, &signature)
121            .map_err(|_| super::nut20::Error::InvalidSignature)?;
122
123        Ok(())
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use std::str::FromStr;
130
131    use super::*;
132    use crate::Id;
133
134    fn dummy_blinded_message() -> BlindedMessage {
135        let secret_key = SecretKey::generate();
136        BlindedMessage {
137            amount: Amount::from(1),
138            keyset_id: Id::from_str("009a1f293253e41e").unwrap(),
139            blinded_secret: secret_key.public_key(),
140            witness: None,
141        }
142    }
143
144    #[test]
145    fn test_sign_and_verify_batch_quote_roundtrip() {
146        let secret_key = SecretKey::generate();
147        let pubkey = secret_key.public_key();
148        let quote_id = "test-quote-id-123".to_string();
149        let outputs = vec![dummy_blinded_message(), dummy_blinded_message()];
150        let request = BatchMintRequest {
151            quotes: vec![quote_id.clone()],
152            quote_amounts: None,
153            outputs,
154            signatures: None,
155        };
156
157        let signature = request
158            .sign_quote(&quote_id, &secret_key)
159            .expect("signing should succeed");
160
161        request
162            .verify_quote_signature(&quote_id, &signature, &pubkey)
163            .expect("verification should succeed with correct key");
164    }
165
166    #[test]
167    fn test_verify_batch_quote_wrong_key() {
168        let signing_key = SecretKey::generate();
169        let wrong_key = SecretKey::generate();
170        let quote_id = "test-quote-wrong-key".to_string();
171        let outputs = vec![dummy_blinded_message()];
172        let request = BatchMintRequest {
173            quotes: vec![quote_id.clone()],
174            quote_amounts: None,
175            outputs,
176            signatures: None,
177        };
178
179        let signature = request
180            .sign_quote(&quote_id, &signing_key)
181            .expect("signing should succeed");
182
183        let result = request.verify_quote_signature(&quote_id, &signature, &wrong_key.public_key());
184        assert!(result.is_err(), "verification should fail with wrong key");
185    }
186
187    #[test]
188    fn test_verify_batch_quote_tampered_outputs() {
189        let secret_key = SecretKey::generate();
190        let pubkey = secret_key.public_key();
191        let quote_id = "test-quote-tampered".to_string();
192        let outputs = vec![dummy_blinded_message(), dummy_blinded_message()];
193        let request = BatchMintRequest {
194            quotes: vec![quote_id.clone()],
195            quote_amounts: None,
196            outputs,
197            signatures: None,
198        };
199
200        let signature = request
201            .sign_quote(&quote_id, &secret_key)
202            .expect("signing should succeed");
203
204        let tampered_request = BatchMintRequest {
205            quotes: vec![quote_id.clone()],
206            quote_amounts: None,
207            outputs: vec![dummy_blinded_message(), dummy_blinded_message()],
208            signatures: None,
209        };
210
211        let result = tampered_request.verify_quote_signature(&quote_id, &signature, &pubkey);
212        assert!(
213            result.is_err(),
214            "verification should fail with tampered outputs"
215        );
216    }
217
218    #[test]
219    fn test_verify_batch_quote_wrong_quote_id() {
220        let secret_key = SecretKey::generate();
221        let pubkey = secret_key.public_key();
222        let quote_id = "original-quote-id".to_string();
223        let outputs = vec![dummy_blinded_message()];
224        let request = BatchMintRequest {
225            quotes: vec![quote_id.clone()],
226            quote_amounts: None,
227            outputs,
228            signatures: None,
229        };
230
231        let signature = request
232            .sign_quote(&quote_id, &secret_key)
233            .expect("signing should succeed");
234
235        let different_quote_id = "different-quote-id".to_string();
236        let result = request.verify_quote_signature(&different_quote_id, &signature, &pubkey);
237        assert!(
238            result.is_err(),
239            "verification should fail with different quote ID"
240        );
241    }
242
243    #[test]
244    fn test_sign_batch_quote_empty_outputs() {
245        let secret_key = SecretKey::generate();
246        let pubkey = secret_key.public_key();
247        let quote_id = "test-empty-outputs".to_string();
248        let request = BatchMintRequest {
249            quotes: vec![quote_id.clone()],
250            quote_amounts: None,
251            outputs: vec![],
252            signatures: None,
253        };
254
255        let signature = request
256            .sign_quote(&quote_id, &secret_key)
257            .expect("signing should succeed");
258
259        request
260            .verify_quote_signature(&quote_id, &signature, &pubkey)
261            .expect("verification should succeed even with empty outputs");
262    }
263
264    #[test]
265    fn test_sign_batch_quote_multiple_quotes_different_sigs() {
266        let secret_key = SecretKey::generate();
267        let outputs = vec![dummy_blinded_message(), dummy_blinded_message()];
268        let quote_1 = "quote-1".to_string();
269        let quote_2 = "quote-2".to_string();
270        let request = BatchMintRequest {
271            quotes: vec![quote_1.clone(), quote_2.clone()],
272            quote_amounts: None,
273            outputs,
274            signatures: None,
275        };
276
277        let sig1 = request
278            .sign_quote(&quote_1, &secret_key)
279            .expect("signing should succeed");
280        let sig2 = request
281            .sign_quote(&quote_2, &secret_key)
282            .expect("signing should succeed");
283
284        assert_ne!(
285            sig1, sig2,
286            "signatures for different quote IDs should differ"
287        );
288    }
289}