Skip to main content

cashu/nuts/
nut04.rs

1//! NUT-04: Mint Tokens via Bolt11
2//!
3//! <https://github.com/cashubtc/nuts/blob/main/04.md>
4
5use std::fmt;
6#[cfg(feature = "mint")]
7use std::str::FromStr;
8
9use serde::de::{self, DeserializeOwned, Deserializer, MapAccess, Visitor};
10use serde::ser::{SerializeStruct, Serializer};
11use serde::{Deserialize, Serialize};
12use thiserror::Error;
13
14use super::nut00::{BlindSignature, BlindedMessage, CurrencyUnit, PaymentMethod};
15use crate::nut00::KnownMethod;
16use crate::nut23::QuoteState;
17#[cfg(feature = "mint")]
18use crate::quote_id::QuoteId;
19#[cfg(feature = "mint")]
20use crate::quote_id::QuoteIdError;
21use crate::util::serde_helpers::deserialize_empty_string_as_none;
22use crate::{Amount, PublicKey};
23
24/// NUT04 Error
25#[derive(Debug, Error)]
26pub enum Error {
27    /// Unknown Quote State
28    #[error("Unknown Quote State")]
29    UnknownState,
30    /// Amount overflow
31    #[error("Amount overflow")]
32    AmountOverflow,
33}
34
35/// Mint request [NUT-04]
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
38#[serde(bound = "Q: Serialize + DeserializeOwned")]
39pub struct MintRequest<Q> {
40    /// Quote id
41    #[cfg_attr(feature = "swagger", schema(max_length = 1_000))]
42    pub quote: Q,
43    /// Outputs
44    #[cfg_attr(feature = "swagger", schema(max_items = 1_000))]
45    pub outputs: Vec<BlindedMessage>,
46    /// Signature
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub signature: Option<String>,
49}
50
51#[cfg(feature = "mint")]
52impl TryFrom<MintRequest<String>> for MintRequest<QuoteId> {
53    type Error = QuoteIdError;
54
55    fn try_from(value: MintRequest<String>) -> Result<Self, Self::Error> {
56        Ok(Self {
57            quote: QuoteId::from_str(&value.quote)?,
58            outputs: value.outputs,
59            signature: value.signature,
60        })
61    }
62}
63
64impl<Q> MintRequest<Q> {
65    /// Total [`Amount`] of outputs
66    pub fn total_amount(&self) -> Result<Amount, Error> {
67        Amount::try_sum(
68            self.outputs
69                .iter()
70                .map(|BlindedMessage { amount, .. }| *amount),
71        )
72        .map_err(|_| Error::AmountOverflow)
73    }
74}
75
76/// Mint response [NUT-04]
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
79pub struct MintResponse {
80    /// Blinded Signatures
81    pub signatures: Vec<BlindSignature>,
82}
83
84/// Mint Method Settings
85#[derive(Debug, Clone, PartialEq, Eq, Hash)]
86#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
87pub struct MintMethodSettings {
88    /// Payment Method e.g. bolt11
89    pub method: PaymentMethod,
90    /// Currency Unit e.g. sat
91    pub unit: CurrencyUnit,
92    /// Min Amount
93    pub min_amount: Option<Amount>,
94    /// Max Amount
95    pub max_amount: Option<Amount>,
96    /// Options
97    pub options: Option<MintMethodOptions>,
98}
99
100impl Serialize for MintMethodSettings {
101    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
102    where
103        S: Serializer,
104    {
105        let mut num_fields = 3; // method and unit are always present
106        if self.min_amount.is_some() {
107            num_fields += 1;
108        }
109        if self.max_amount.is_some() {
110            num_fields += 1;
111        }
112
113        let mut description_in_top_level = false;
114        if let Some(MintMethodOptions::Bolt11 { description }) = &self.options {
115            if *description {
116                num_fields += 1;
117                description_in_top_level = true;
118            }
119        }
120
121        let mut state = serializer.serialize_struct("MintMethodSettings", num_fields)?;
122
123        state.serialize_field("method", &self.method)?;
124        state.serialize_field("unit", &self.unit)?;
125
126        if let Some(min_amount) = &self.min_amount {
127            state.serialize_field("min_amount", min_amount)?;
128        }
129
130        if let Some(max_amount) = &self.max_amount {
131            state.serialize_field("max_amount", max_amount)?;
132        }
133
134        // If there's a description flag in Bolt11 options, add it at the top level
135        if description_in_top_level {
136            state.serialize_field("description", &true)?;
137        }
138
139        state.end()
140    }
141}
142
143struct MintMethodSettingsVisitor;
144
145impl<'de> Visitor<'de> for MintMethodSettingsVisitor {
146    type Value = MintMethodSettings;
147
148    fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
149        formatter.write_str("a MintMethodSettings structure")
150    }
151
152    fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
153    where
154        M: MapAccess<'de>,
155    {
156        let mut method: Option<PaymentMethod> = None;
157        let mut unit: Option<CurrencyUnit> = None;
158        let mut min_amount: Option<Amount> = None;
159        let mut max_amount: Option<Amount> = None;
160        let mut description: Option<bool> = None;
161
162        while let Some(key) = map.next_key::<String>()? {
163            match key.as_str() {
164                "method" => {
165                    if method.is_some() {
166                        return Err(de::Error::duplicate_field("method"));
167                    }
168                    method = Some(map.next_value()?);
169                }
170                "unit" => {
171                    if unit.is_some() {
172                        return Err(de::Error::duplicate_field("unit"));
173                    }
174                    unit = Some(map.next_value()?);
175                }
176                "min_amount" => {
177                    if min_amount.is_some() {
178                        return Err(de::Error::duplicate_field("min_amount"));
179                    }
180                    min_amount = Some(map.next_value()?);
181                }
182                "max_amount" => {
183                    if max_amount.is_some() {
184                        return Err(de::Error::duplicate_field("max_amount"));
185                    }
186                    max_amount = Some(map.next_value()?);
187                }
188                "description" => {
189                    if description.is_some() {
190                        return Err(de::Error::duplicate_field("description"));
191                    }
192                    description = Some(map.next_value()?);
193                }
194                "options" => {
195                    // If there are explicit options, they take precedence, except the description
196                    // field which we will handle specially
197                    let options: Option<MintMethodOptions> = map.next_value()?;
198
199                    if let Some(MintMethodOptions::Bolt11 {
200                        description: desc_from_options,
201                    }) = options
202                    {
203                        // If we already found a top-level description, use that instead
204                        if description.is_none() {
205                            description = Some(desc_from_options);
206                        }
207                    }
208                }
209                _ => {
210                    // Skip unknown fields
211                    let _: serde::de::IgnoredAny = map.next_value()?;
212                }
213            }
214        }
215
216        let method = method.ok_or_else(|| de::Error::missing_field("method"))?;
217        let unit = unit.ok_or_else(|| de::Error::missing_field("unit"))?;
218
219        // Create options based on the method and the description flag
220        let options = if method == PaymentMethod::Known(KnownMethod::Bolt11) {
221            description.map(|description| MintMethodOptions::Bolt11 { description })
222        } else {
223            None
224        };
225
226        Ok(MintMethodSettings {
227            method,
228            unit,
229            min_amount,
230            max_amount,
231            options,
232        })
233    }
234}
235
236impl<'de> Deserialize<'de> for MintMethodSettings {
237    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
238    where
239        D: Deserializer<'de>,
240    {
241        deserializer.deserialize_map(MintMethodSettingsVisitor)
242    }
243}
244
245/// Mint Method settings options
246#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
247#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
248#[serde(untagged)]
249pub enum MintMethodOptions {
250    /// Bolt11 Options
251    Bolt11 {
252        /// Mint supports setting bolt11 description
253        description: bool,
254    },
255    /// Custom Options
256    Custom {},
257}
258
259/// Mint Settings
260#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
261#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema), schema(as = nut04::Settings))]
262pub struct Settings {
263    /// Methods to mint
264    pub methods: Vec<MintMethodSettings>,
265    /// Minting disabled
266    pub disabled: bool,
267}
268
269impl Settings {
270    /// Create new [`Settings`]
271    pub fn new(methods: Vec<MintMethodSettings>, disabled: bool) -> Self {
272        Self { methods, disabled }
273    }
274
275    /// Get [`MintMethodSettings`] for unit method pair
276    pub fn get_settings(
277        &self,
278        unit: &CurrencyUnit,
279        method: &PaymentMethod,
280    ) -> Option<MintMethodSettings> {
281        for method_settings in self.methods.iter() {
282            if method_settings.method.eq(method) && method_settings.unit.eq(unit) {
283                return Some(method_settings.clone());
284            }
285        }
286
287        None
288    }
289
290    /// Remove [`MintMethodSettings`] for unit method pair
291    pub fn remove_settings(
292        &mut self,
293        unit: &CurrencyUnit,
294        method: &PaymentMethod,
295    ) -> Option<MintMethodSettings> {
296        self.methods
297            .iter()
298            .position(|settings| &settings.method == method && &settings.unit == unit)
299            .map(|index| self.methods.remove(index))
300    }
301
302    /// Supported nut04 methods
303    pub fn supported_methods(&self) -> Vec<&PaymentMethod> {
304        self.methods.iter().map(|a| &a.method).collect()
305    }
306
307    /// Supported nut04 units
308    pub fn supported_units(&self) -> Vec<&CurrencyUnit> {
309        self.methods.iter().map(|s| &s.unit).collect()
310    }
311}
312
313/// Custom payment method mint quote request
314///
315/// This is a generic request type that works for any custom payment method.
316/// The method name is provided in the URL path, not in the request body.
317///
318/// The `extra` field allows payment-method-specific fields to be included
319/// without being nested. When serialized, extra fields merge into the parent JSON.
320#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
321#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
322pub struct MintQuoteCustomRequest {
323    /// Amount to mint
324    pub amount: Amount,
325    /// Currency unit
326    pub unit: CurrencyUnit,
327    /// Optional description
328    #[serde(skip_serializing_if = "Option::is_none")]
329    pub description: Option<String>,
330    /// NUT-19 Pubkey
331    #[serde(skip_serializing_if = "Option::is_none")]
332    pub pubkey: Option<PublicKey>,
333    /// Extra payment-method-specific fields
334    ///
335    /// These fields are flattened into the JSON representation, allowing
336    /// custom payment methods to include additional data (e.g., ehash share).
337    /// This enables proper validation layering: the mint verifies well-defined
338    /// fields while passing extra through to the payment processor.
339    #[serde(flatten, default, skip_serializing_if = "serde_json::Value::is_null")]
340    #[cfg_attr(feature = "swagger", schema(value_type = Object, additional_properties = true))]
341    pub extra: serde_json::Value,
342}
343
344/// Custom payment method mint quote response
345///
346/// This is a generic response type for custom payment methods.
347///
348/// The `extra` field allows payment-method-specific fields to be included
349/// without being nested. When serialized, extra fields merge into the parent JSON:
350/// ```json
351/// {
352///   "quote": "abc123",
353///   "state": "UNPAID",
354///   "amount": 1000,
355///   "paypal_link": "https://paypal.me/merchant",
356///   "paypal_email": "merchant@example.com"
357/// }
358/// ```
359///
360/// This separation enables proper validation layering: the mint verifies
361/// well-defined fields (amount, unit, state, etc.) while passing extra through
362/// to the gRPC payment processor for method-specific validation.
363///
364/// It also provides a clean upgrade path: when a payment method becomes speced,
365/// its fields can be promoted from `extra` to well-defined struct fields without
366/// breaking existing clients (e.g., bolt12's `amount_paid` and `amount_issued`).
367#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
368#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
369#[serde(bound = "Q: Serialize + for<'a> Deserialize<'a>")]
370pub struct MintQuoteCustomResponse<Q> {
371    /// Quote ID
372    pub quote: Q,
373    /// Payment request string (method-specific format)
374    pub request: String,
375    /// Amount
376    pub amount: Option<Amount>,
377    /// Currency unit
378    pub unit: Option<CurrencyUnit>,
379    /// Quote State
380    pub state: QuoteState,
381    /// Unix timestamp until the quote is valid
382    pub expiry: Option<u64>,
383    /// NUT-19 Pubkey
384    #[serde(
385        default,
386        skip_serializing_if = "Option::is_none",
387        deserialize_with = "deserialize_empty_string_as_none"
388    )]
389    pub pubkey: Option<PublicKey>,
390    /// Extra payment-method-specific fields
391    ///
392    /// These fields are flattened into the JSON representation, allowing
393    /// custom payment methods to include additional data without nesting.
394    #[serde(flatten, default, skip_serializing_if = "serde_json::Value::is_null")]
395    #[cfg_attr(feature = "swagger", schema(value_type = Object, additional_properties = true))]
396    pub extra: serde_json::Value,
397}
398
399#[cfg(feature = "mint")]
400impl<Q: ToString> MintQuoteCustomResponse<Q> {
401    /// Convert the MintQuoteCustomResponse with a quote type Q to a String
402    pub fn to_string_id(&self) -> MintQuoteCustomResponse<String> {
403        MintQuoteCustomResponse {
404            quote: self.quote.to_string(),
405            request: self.request.clone(),
406            amount: self.amount,
407            state: self.state,
408            unit: self.unit.clone(),
409            expiry: self.expiry,
410            pubkey: self.pubkey,
411            extra: self.extra.clone(),
412        }
413    }
414}
415
416#[cfg(feature = "mint")]
417impl From<MintQuoteCustomResponse<QuoteId>> for MintQuoteCustomResponse<String> {
418    fn from(value: MintQuoteCustomResponse<QuoteId>) -> Self {
419        Self {
420            quote: value.quote.to_string(),
421            request: value.request,
422            amount: value.amount,
423            unit: value.unit,
424            expiry: value.expiry,
425            state: value.state,
426            pubkey: value.pubkey,
427            extra: value.extra,
428        }
429    }
430}
431#[cfg(test)]
432mod tests {
433    use serde_json::{from_str, json, to_string};
434
435    use super::*;
436    use crate::nut00::KnownMethod;
437
438    #[test]
439    fn test_mint_method_settings_top_level_description() {
440        // Create JSON with top-level description
441        let json_str = r#"{
442            "method": "bolt11",
443            "unit": "sat",
444            "min_amount": 0,
445            "max_amount": 10000,
446            "description": true
447        }"#;
448
449        // Deserialize it
450        let settings: MintMethodSettings = from_str(json_str).unwrap();
451
452        // Check that description was correctly moved to options
453        assert_eq!(settings.method, PaymentMethod::Known(KnownMethod::Bolt11));
454        assert_eq!(settings.unit, CurrencyUnit::Sat);
455        assert_eq!(settings.min_amount, Some(Amount::from(0)));
456        assert_eq!(settings.max_amount, Some(Amount::from(10000)));
457
458        match settings.options {
459            Some(MintMethodOptions::Bolt11 { description }) => {
460                assert!(description);
461            }
462            _ => panic!("Expected Bolt11 options with description = true"),
463        }
464
465        // Serialize it back
466        let serialized = to_string(&settings).unwrap();
467        let parsed: serde_json::Value = from_str(&serialized).unwrap();
468
469        // Verify the description is at the top level
470        assert_eq!(parsed["description"], json!(true));
471    }
472
473    #[test]
474    fn test_both_description_locations() {
475        // Create JSON with description in both places (top level and in options)
476        let json_str = r#"{
477            "method": "bolt11",
478            "unit": "sat",
479            "min_amount": 0,
480            "max_amount": 10000,
481            "description": true,
482            "options": {
483                "description": false
484            }
485        }"#;
486
487        // Deserialize it - top level should take precedence
488        let settings: MintMethodSettings = from_str(json_str).unwrap();
489
490        match settings.options {
491            Some(MintMethodOptions::Bolt11 { description }) => {
492                assert!(description, "Top-level description should take precedence");
493            }
494            _ => panic!("Expected Bolt11 options with description = true"),
495        }
496    }
497}