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};
15#[cfg(feature = "mint")]
16use crate::quote_id::QuoteId;
17#[cfg(feature = "mint")]
18use crate::quote_id::QuoteIdError;
19use crate::Amount;
20
21/// NUT04 Error
22#[derive(Debug, Error)]
23pub enum Error {
24    /// Unknown Quote State
25    #[error("Unknown Quote State")]
26    UnknownState,
27    /// Amount overflow
28    #[error("Amount overflow")]
29    AmountOverflow,
30}
31
32/// Mint request [NUT-04]
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
35#[serde(bound = "Q: Serialize + DeserializeOwned")]
36pub struct MintRequest<Q> {
37    /// Quote id
38    #[cfg_attr(feature = "swagger", schema(max_length = 1_000))]
39    pub quote: Q,
40    /// Outputs
41    #[cfg_attr(feature = "swagger", schema(max_items = 1_000))]
42    pub outputs: Vec<BlindedMessage>,
43    /// Signature
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub signature: Option<String>,
46}
47
48#[cfg(feature = "mint")]
49impl TryFrom<MintRequest<String>> for MintRequest<QuoteId> {
50    type Error = QuoteIdError;
51
52    fn try_from(value: MintRequest<String>) -> Result<Self, Self::Error> {
53        Ok(Self {
54            quote: QuoteId::from_str(&value.quote)?,
55            outputs: value.outputs,
56            signature: value.signature,
57        })
58    }
59}
60
61impl<Q> MintRequest<Q> {
62    /// Total [`Amount`] of outputs
63    pub fn total_amount(&self) -> Result<Amount, Error> {
64        Amount::try_sum(
65            self.outputs
66                .iter()
67                .map(|BlindedMessage { amount, .. }| *amount),
68        )
69        .map_err(|_| Error::AmountOverflow)
70    }
71}
72
73/// Mint response [NUT-04]
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
76pub struct MintResponse {
77    /// Blinded Signatures
78    pub signatures: Vec<BlindSignature>,
79}
80
81/// Mint Method Settings
82#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
83#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
84pub struct MintMethodSettings {
85    /// Payment Method e.g. bolt11
86    pub method: PaymentMethod,
87    /// Currency Unit e.g. sat
88    pub unit: CurrencyUnit,
89    /// Min Amount
90    pub min_amount: Option<Amount>,
91    /// Max Amount
92    pub max_amount: Option<Amount>,
93    /// Options
94    pub options: Option<MintMethodOptions>,
95}
96
97impl Serialize for MintMethodSettings {
98    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
99    where
100        S: Serializer,
101    {
102        let mut num_fields = 3; // method and unit are always present
103        if self.min_amount.is_some() {
104            num_fields += 1;
105        }
106        if self.max_amount.is_some() {
107            num_fields += 1;
108        }
109
110        let mut description_in_top_level = false;
111        if let Some(MintMethodOptions::Bolt11 { description }) = &self.options {
112            if *description {
113                num_fields += 1;
114                description_in_top_level = true;
115            }
116        }
117
118        let mut state = serializer.serialize_struct("MintMethodSettings", num_fields)?;
119
120        state.serialize_field("method", &self.method)?;
121        state.serialize_field("unit", &self.unit)?;
122
123        if let Some(min_amount) = &self.min_amount {
124            state.serialize_field("min_amount", min_amount)?;
125        }
126
127        if let Some(max_amount) = &self.max_amount {
128            state.serialize_field("max_amount", max_amount)?;
129        }
130
131        // If there's a description flag in Bolt11 options, add it at the top level
132        if description_in_top_level {
133            state.serialize_field("description", &true)?;
134        }
135
136        state.end()
137    }
138}
139
140struct MintMethodSettingsVisitor;
141
142impl<'de> Visitor<'de> for MintMethodSettingsVisitor {
143    type Value = MintMethodSettings;
144
145    fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
146        formatter.write_str("a MintMethodSettings structure")
147    }
148
149    fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
150    where
151        M: MapAccess<'de>,
152    {
153        let mut method: Option<PaymentMethod> = None;
154        let mut unit: Option<CurrencyUnit> = None;
155        let mut min_amount: Option<Amount> = None;
156        let mut max_amount: Option<Amount> = None;
157        let mut description: Option<bool> = None;
158
159        while let Some(key) = map.next_key::<String>()? {
160            match key.as_str() {
161                "method" => {
162                    if method.is_some() {
163                        return Err(de::Error::duplicate_field("method"));
164                    }
165                    method = Some(map.next_value()?);
166                }
167                "unit" => {
168                    if unit.is_some() {
169                        return Err(de::Error::duplicate_field("unit"));
170                    }
171                    unit = Some(map.next_value()?);
172                }
173                "min_amount" => {
174                    if min_amount.is_some() {
175                        return Err(de::Error::duplicate_field("min_amount"));
176                    }
177                    min_amount = Some(map.next_value()?);
178                }
179                "max_amount" => {
180                    if max_amount.is_some() {
181                        return Err(de::Error::duplicate_field("max_amount"));
182                    }
183                    max_amount = Some(map.next_value()?);
184                }
185                "description" => {
186                    if description.is_some() {
187                        return Err(de::Error::duplicate_field("description"));
188                    }
189                    description = Some(map.next_value()?);
190                }
191                "options" => {
192                    // If there are explicit options, they take precedence, except the description
193                    // field which we will handle specially
194                    let options: Option<MintMethodOptions> = map.next_value()?;
195
196                    if let Some(MintMethodOptions::Bolt11 {
197                        description: desc_from_options,
198                    }) = options
199                    {
200                        // If we already found a top-level description, use that instead
201                        if description.is_none() {
202                            description = Some(desc_from_options);
203                        }
204                    }
205                }
206                _ => {
207                    // Skip unknown fields
208                    let _: serde::de::IgnoredAny = map.next_value()?;
209                }
210            }
211        }
212
213        let method = method.ok_or_else(|| de::Error::missing_field("method"))?;
214        let unit = unit.ok_or_else(|| de::Error::missing_field("unit"))?;
215
216        // Create options based on the method and the description flag
217        let options = if method == PaymentMethod::Bolt11 {
218            description.map(|description| MintMethodOptions::Bolt11 { description })
219        } else {
220            None
221        };
222
223        Ok(MintMethodSettings {
224            method,
225            unit,
226            min_amount,
227            max_amount,
228            options,
229        })
230    }
231}
232
233impl<'de> Deserialize<'de> for MintMethodSettings {
234    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
235    where
236        D: Deserializer<'de>,
237    {
238        deserializer.deserialize_map(MintMethodSettingsVisitor)
239    }
240}
241
242/// Mint Method settings options
243#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
244#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
245#[serde(untagged)]
246pub enum MintMethodOptions {
247    /// Bolt11 Options
248    Bolt11 {
249        /// Mint supports setting bolt11 description
250        description: bool,
251    },
252}
253
254/// Mint Settings
255#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
256#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema), schema(as = nut04::Settings))]
257pub struct Settings {
258    /// Methods to mint
259    pub methods: Vec<MintMethodSettings>,
260    /// Minting disabled
261    pub disabled: bool,
262}
263
264impl Settings {
265    /// Create new [`Settings`]
266    pub fn new(methods: Vec<MintMethodSettings>, disabled: bool) -> Self {
267        Self { methods, disabled }
268    }
269
270    /// Get [`MintMethodSettings`] for unit method pair
271    pub fn get_settings(
272        &self,
273        unit: &CurrencyUnit,
274        method: &PaymentMethod,
275    ) -> Option<MintMethodSettings> {
276        for method_settings in self.methods.iter() {
277            if method_settings.method.eq(method) && method_settings.unit.eq(unit) {
278                return Some(method_settings.clone());
279            }
280        }
281
282        None
283    }
284
285    /// Remove [`MintMethodSettings`] for unit method pair
286    pub fn remove_settings(
287        &mut self,
288        unit: &CurrencyUnit,
289        method: &PaymentMethod,
290    ) -> Option<MintMethodSettings> {
291        self.methods
292            .iter()
293            .position(|settings| &settings.method == method && &settings.unit == unit)
294            .map(|index| self.methods.remove(index))
295    }
296
297    /// Supported nut04 methods
298    pub fn supported_methods(&self) -> Vec<&PaymentMethod> {
299        self.methods.iter().map(|a| &a.method).collect()
300    }
301
302    /// Supported nut04 units
303    pub fn supported_units(&self) -> Vec<&CurrencyUnit> {
304        self.methods.iter().map(|s| &s.unit).collect()
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use serde_json::{from_str, json, to_string};
311
312    use super::*;
313
314    #[test]
315    fn test_mint_method_settings_top_level_description() {
316        // Create JSON with top-level description
317        let json_str = r#"{
318            "method": "bolt11",
319            "unit": "sat",
320            "min_amount": 0,
321            "max_amount": 10000,
322            "description": true
323        }"#;
324
325        // Deserialize it
326        let settings: MintMethodSettings = from_str(json_str).unwrap();
327
328        // Check that description was correctly moved to options
329        assert_eq!(settings.method, PaymentMethod::Bolt11);
330        assert_eq!(settings.unit, CurrencyUnit::Sat);
331        assert_eq!(settings.min_amount, Some(Amount::from(0)));
332        assert_eq!(settings.max_amount, Some(Amount::from(10000)));
333
334        match settings.options {
335            Some(MintMethodOptions::Bolt11 { description }) => {
336                assert!(description);
337            }
338            _ => panic!("Expected Bolt11 options with description = true"),
339        }
340
341        // Serialize it back
342        let serialized = to_string(&settings).unwrap();
343        let parsed: serde_json::Value = from_str(&serialized).unwrap();
344
345        // Verify the description is at the top level
346        assert_eq!(parsed["description"], json!(true));
347    }
348
349    #[test]
350    fn test_both_description_locations() {
351        // Create JSON with description in both places (top level and in options)
352        let json_str = r#"{
353            "method": "bolt11",
354            "unit": "sat",
355            "min_amount": 0,
356            "max_amount": 10000,
357            "description": true,
358            "options": {
359                "description": false
360            }
361        }"#;
362
363        // Deserialize it - top level should take precedence
364        let settings: MintMethodSettings = from_str(json_str).unwrap();
365
366        match settings.options {
367            Some(MintMethodOptions::Bolt11 { description }) => {
368                assert!(description, "Top-level description should take precedence");
369            }
370            _ => panic!("Expected Bolt11 options with description = true"),
371        }
372    }
373}