cashu/nuts/
nut05.rs

1//! NUT-05: Melting Tokens
2//!
3//! <https://github.com/cashubtc/nuts/blob/main/05.md>
4
5use std::fmt;
6use std::str::FromStr;
7
8use serde::de::{self, DeserializeOwned, Deserializer, MapAccess, Visitor};
9use serde::ser::{SerializeStruct, Serializer};
10use serde::{Deserialize, Serialize};
11use thiserror::Error;
12
13use super::nut00::{BlindedMessage, CurrencyUnit, PaymentMethod, Proofs};
14use super::ProofsMethods;
15#[cfg(feature = "mint")]
16use crate::quote_id::QuoteId;
17use crate::Amount;
18
19/// NUT05 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    /// Unsupported unit
29    #[error("Unsupported unit")]
30    UnsupportedUnit,
31    /// Invalid quote id
32    #[error("Invalid quote id")]
33    InvalidQuote,
34}
35
36/// Possible states of a quote
37#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Default, Serialize, Deserialize)]
38#[serde(rename_all = "UPPERCASE")]
39#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema), schema(as = MeltQuoteState))]
40pub enum QuoteState {
41    /// Quote has not been paid
42    #[default]
43    Unpaid,
44    /// Quote has been paid
45    Paid,
46    /// Paying quote is in progress
47    Pending,
48    /// Unknown state
49    Unknown,
50    /// Failed
51    Failed,
52}
53
54impl fmt::Display for QuoteState {
55    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
56        match self {
57            Self::Unpaid => write!(f, "UNPAID"),
58            Self::Paid => write!(f, "PAID"),
59            Self::Pending => write!(f, "PENDING"),
60            Self::Unknown => write!(f, "UNKNOWN"),
61            Self::Failed => write!(f, "FAILED"),
62        }
63    }
64}
65
66impl FromStr for QuoteState {
67    type Err = Error;
68
69    fn from_str(state: &str) -> Result<Self, Self::Err> {
70        match state {
71            "PENDING" => Ok(Self::Pending),
72            "PAID" => Ok(Self::Paid),
73            "UNPAID" => Ok(Self::Unpaid),
74            "UNKNOWN" => Ok(Self::Unknown),
75            "FAILED" => Ok(Self::Failed),
76            _ => Err(Error::UnknownState),
77        }
78    }
79}
80
81/// Melt Bolt11 Request [NUT-05]
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
84#[serde(bound = "Q: Serialize + DeserializeOwned")]
85pub struct MeltRequest<Q> {
86    /// Quote ID
87    quote: Q,
88    /// Proofs
89    #[cfg_attr(feature = "swagger", schema(value_type = Vec<crate::Proof>))]
90    inputs: Proofs,
91    /// Blinded Message that can be used to return change [NUT-08]
92    /// Amount field of BlindedMessages `SHOULD` be set to zero
93    outputs: Option<Vec<BlindedMessage>>,
94}
95
96#[cfg(feature = "mint")]
97impl TryFrom<MeltRequest<String>> for MeltRequest<QuoteId> {
98    type Error = Error;
99
100    fn try_from(value: MeltRequest<String>) -> Result<Self, Self::Error> {
101        Ok(Self {
102            quote: QuoteId::from_str(&value.quote).map_err(|_e| Error::InvalidQuote)?,
103            inputs: value.inputs,
104            outputs: value.outputs,
105        })
106    }
107}
108
109// Basic implementation without trait bounds
110impl<Q> MeltRequest<Q> {
111    /// Quote Id
112    pub fn quote_id(&self) -> &Q {
113        &self.quote
114    }
115
116    /// Get inputs (proofs)
117    pub fn inputs(&self) -> &Proofs {
118        &self.inputs
119    }
120
121    /// Get mutable inputs (proofs)
122    pub fn inputs_mut(&mut self) -> &mut Proofs {
123        &mut self.inputs
124    }
125
126    /// Get outputs (blinded messages for change)
127    pub fn outputs(&self) -> &Option<Vec<BlindedMessage>> {
128        &self.outputs
129    }
130}
131
132impl<Q> MeltRequest<Q>
133where
134    Q: Serialize + DeserializeOwned,
135{
136    /// Create new [`MeltRequest`]
137    pub fn new(quote: Q, inputs: Proofs, outputs: Option<Vec<BlindedMessage>>) -> Self {
138        Self {
139            quote,
140            inputs: inputs.without_dleqs(),
141            outputs,
142        }
143    }
144
145    /// Get quote
146    pub fn quote(&self) -> &Q {
147        &self.quote
148    }
149
150    /// Total [`Amount`] of [`Proofs`]
151    pub fn inputs_amount(&self) -> Result<Amount, Error> {
152        Amount::try_sum(self.inputs.iter().map(|proof| proof.amount))
153            .map_err(|_| Error::AmountOverflow)
154    }
155}
156
157impl<Q> super::nut10::SpendingConditionVerification for MeltRequest<Q>
158where
159    Q: std::fmt::Display,
160{
161    fn inputs(&self) -> &Proofs {
162        &self.inputs
163    }
164
165    fn sig_all_msg_to_sign(&self) -> String {
166        let mut msg = String::new();
167
168        // Add all input secrets and C values in order
169        // msg = secret_0 || C_0 || ... || secret_n || C_n
170        for proof in &self.inputs {
171            msg.push_str(&proof.secret.to_string());
172            msg.push_str(&proof.c.to_hex());
173        }
174
175        // Add all output amounts and B_ values in order (if any)
176        // msg = ... || amount_0 || B_0 || ... || amount_m || B_m
177        if let Some(outputs) = &self.outputs {
178            for output in outputs {
179                msg.push_str(&output.amount.to_string());
180                msg.push_str(&output.blinded_secret.to_hex());
181            }
182        }
183
184        // Add quote ID
185        // msg = ... || quote_id
186        msg.push_str(&self.quote.to_string());
187
188        msg
189    }
190}
191
192/// Melt Method Settings
193#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
194#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
195pub struct MeltMethodSettings {
196    /// Payment Method e.g. bolt11
197    pub method: PaymentMethod,
198    /// Currency Unit e.g. sat
199    pub unit: CurrencyUnit,
200    /// Min Amount
201    pub min_amount: Option<Amount>,
202    /// Max Amount
203    pub max_amount: Option<Amount>,
204    /// Options
205    pub options: Option<MeltMethodOptions>,
206}
207
208impl Serialize for MeltMethodSettings {
209    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
210    where
211        S: Serializer,
212    {
213        let mut num_fields = 3; // method and unit are always present
214        if self.min_amount.is_some() {
215            num_fields += 1;
216        }
217        if self.max_amount.is_some() {
218            num_fields += 1;
219        }
220
221        let mut amountless_in_top_level = false;
222        if let Some(MeltMethodOptions::Bolt11 { amountless }) = &self.options {
223            if *amountless {
224                num_fields += 1;
225                amountless_in_top_level = true;
226            }
227        }
228
229        let mut state = serializer.serialize_struct("MeltMethodSettings", num_fields)?;
230
231        state.serialize_field("method", &self.method)?;
232        state.serialize_field("unit", &self.unit)?;
233
234        if let Some(min_amount) = &self.min_amount {
235            state.serialize_field("min_amount", min_amount)?;
236        }
237
238        if let Some(max_amount) = &self.max_amount {
239            state.serialize_field("max_amount", max_amount)?;
240        }
241
242        // If there's an amountless flag in Bolt11 options, add it at the top level
243        if amountless_in_top_level {
244            state.serialize_field("amountless", &true)?;
245        }
246
247        state.end()
248    }
249}
250
251struct MeltMethodSettingsVisitor;
252
253impl<'de> Visitor<'de> for MeltMethodSettingsVisitor {
254    type Value = MeltMethodSettings;
255
256    fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
257        formatter.write_str("a MeltMethodSettings structure")
258    }
259
260    fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
261    where
262        M: MapAccess<'de>,
263    {
264        let mut method: Option<PaymentMethod> = None;
265        let mut unit: Option<CurrencyUnit> = None;
266        let mut min_amount: Option<Amount> = None;
267        let mut max_amount: Option<Amount> = None;
268        let mut amountless: Option<bool> = None;
269
270        while let Some(key) = map.next_key::<String>()? {
271            match key.as_str() {
272                "method" => {
273                    if method.is_some() {
274                        return Err(de::Error::duplicate_field("method"));
275                    }
276                    method = Some(map.next_value()?);
277                }
278                "unit" => {
279                    if unit.is_some() {
280                        return Err(de::Error::duplicate_field("unit"));
281                    }
282                    unit = Some(map.next_value()?);
283                }
284                "min_amount" => {
285                    if min_amount.is_some() {
286                        return Err(de::Error::duplicate_field("min_amount"));
287                    }
288                    min_amount = Some(map.next_value()?);
289                }
290                "max_amount" => {
291                    if max_amount.is_some() {
292                        return Err(de::Error::duplicate_field("max_amount"));
293                    }
294                    max_amount = Some(map.next_value()?);
295                }
296                "amountless" => {
297                    if amountless.is_some() {
298                        return Err(de::Error::duplicate_field("amountless"));
299                    }
300                    amountless = Some(map.next_value()?);
301                }
302                "options" => {
303                    // If there are explicit options, they take precedence, except the amountless
304                    // field which we will handle specially
305                    let options: Option<MeltMethodOptions> = map.next_value()?;
306
307                    if let Some(MeltMethodOptions::Bolt11 {
308                        amountless: amountless_from_options,
309                    }) = options
310                    {
311                        // If we already found a top-level amountless, use that instead
312                        if amountless.is_none() {
313                            amountless = Some(amountless_from_options);
314                        }
315                    }
316                }
317                _ => {
318                    // Skip unknown fields
319                    let _: serde::de::IgnoredAny = map.next_value()?;
320                }
321            }
322        }
323
324        let method = method.ok_or_else(|| de::Error::missing_field("method"))?;
325        let unit = unit.ok_or_else(|| de::Error::missing_field("unit"))?;
326
327        // Create options based on the method and the amountless flag
328        let options = if method == PaymentMethod::Bolt11 && amountless.is_some() {
329            amountless.map(|amountless| MeltMethodOptions::Bolt11 { amountless })
330        } else {
331            None
332        };
333
334        Ok(MeltMethodSettings {
335            method,
336            unit,
337            min_amount,
338            max_amount,
339            options,
340        })
341    }
342}
343
344impl<'de> Deserialize<'de> for MeltMethodSettings {
345    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
346    where
347        D: Deserializer<'de>,
348    {
349        deserializer.deserialize_map(MeltMethodSettingsVisitor)
350    }
351}
352
353/// Mint Method settings options
354#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
355#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
356#[serde(untagged)]
357pub enum MeltMethodOptions {
358    /// Bolt11 Options
359    Bolt11 {
360        /// Mint supports paying bolt11 amountless
361        amountless: bool,
362    },
363}
364
365impl Settings {
366    /// Create new [`Settings`]
367    pub fn new(methods: Vec<MeltMethodSettings>, disabled: bool) -> Self {
368        Self { methods, disabled }
369    }
370
371    /// Get [`MeltMethodSettings`] for unit method pair
372    pub fn get_settings(
373        &self,
374        unit: &CurrencyUnit,
375        method: &PaymentMethod,
376    ) -> Option<MeltMethodSettings> {
377        for method_settings in self.methods.iter() {
378            if method_settings.method.eq(method) && method_settings.unit.eq(unit) {
379                return Some(method_settings.clone());
380            }
381        }
382
383        None
384    }
385
386    /// Remove [`MeltMethodSettings`] for unit method pair
387    pub fn remove_settings(
388        &mut self,
389        unit: &CurrencyUnit,
390        method: &PaymentMethod,
391    ) -> Option<MeltMethodSettings> {
392        self.methods
393            .iter()
394            .position(|settings| settings.method.eq(method) && settings.unit.eq(unit))
395            .map(|index| self.methods.remove(index))
396    }
397}
398
399/// Melt Settings
400#[derive(Debug, Clone, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
401#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema), schema(as = nut05::Settings))]
402pub struct Settings {
403    /// Methods to melt
404    pub methods: Vec<MeltMethodSettings>,
405    /// Minting disabled
406    pub disabled: bool,
407}
408
409impl Settings {
410    /// Supported nut05 methods
411    pub fn supported_methods(&self) -> Vec<&PaymentMethod> {
412        self.methods.iter().map(|a| &a.method).collect()
413    }
414
415    /// Supported nut05 units
416    pub fn supported_units(&self) -> Vec<&CurrencyUnit> {
417        self.methods.iter().map(|s| &s.unit).collect()
418    }
419}
420
421#[cfg(test)]
422mod tests {
423    use serde_json::{from_str, json, to_string};
424
425    use super::*;
426
427    #[test]
428    fn test_melt_method_settings_top_level_amountless() {
429        // Create JSON with top-level amountless
430        let json_str = r#"{
431            "method": "bolt11",
432            "unit": "sat",
433            "min_amount": 0,
434            "max_amount": 10000,
435            "amountless": true
436        }"#;
437
438        // Deserialize it
439        let settings: MeltMethodSettings = from_str(json_str).unwrap();
440
441        // Check that amountless was correctly moved to options
442        assert_eq!(settings.method, PaymentMethod::Bolt11);
443        assert_eq!(settings.unit, CurrencyUnit::Sat);
444        assert_eq!(settings.min_amount, Some(Amount::from(0)));
445        assert_eq!(settings.max_amount, Some(Amount::from(10000)));
446
447        match settings.options {
448            Some(MeltMethodOptions::Bolt11 { amountless }) => {
449                assert!(amountless);
450            }
451            _ => panic!("Expected Bolt11 options with amountless = true"),
452        }
453
454        // Serialize it back
455        let serialized = to_string(&settings).unwrap();
456        let parsed: serde_json::Value = from_str(&serialized).unwrap();
457
458        // Verify the amountless is at the top level
459        assert_eq!(parsed["amountless"], json!(true));
460    }
461
462    #[test]
463    fn test_both_amountless_locations() {
464        // Create JSON with amountless in both places (top level and in options)
465        let json_str = r#"{
466            "method": "bolt11",
467            "unit": "sat",
468            "min_amount": 0,
469            "max_amount": 10000,
470            "amountless": true,
471            "options": {
472                "amountless": false
473            }
474        }"#;
475
476        // Deserialize it - top level should take precedence
477        let settings: MeltMethodSettings = from_str(json_str).unwrap();
478
479        match settings.options {
480            Some(MeltMethodOptions::Bolt11 { amountless }) => {
481                assert!(amountless, "Top-level amountless should take precedence");
482            }
483            _ => panic!("Expected Bolt11 options with amountless = true"),
484        }
485    }
486}