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