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