Skip to main content

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::{BlindSignature, BlindedMessage, CurrencyUnit, PaymentMethod, Proofs};
14use super::ProofsMethods;
15use crate::nut00::KnownMethod;
16#[cfg(feature = "mint")]
17use crate::quote_id::QuoteId;
18use crate::Amount;
19
20/// NUT05 Error
21#[derive(Debug, Error)]
22pub enum Error {
23    /// Unknown Quote State
24    #[error("Unknown quote state")]
25    UnknownState,
26    /// Amount overflow
27    #[error("Amount Overflow")]
28    AmountOverflow,
29    /// Unsupported unit
30    #[error("Unsupported unit")]
31    UnsupportedUnit,
32    /// Invalid quote id
33    #[error("Invalid quote id")]
34    InvalidQuote,
35}
36
37/// Possible states of a quote
38#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Default, Serialize, Deserialize)]
39#[serde(rename_all = "UPPERCASE")]
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#[serde(bound = "Q: Serialize + DeserializeOwned")]
84pub struct MeltRequest<Q> {
85    /// Quote ID
86    quote: Q,
87    /// Proofs
88    inputs: Proofs,
89    /// Blinded Message that can be used to return change [NUT-08]
90    /// Amount field of BlindedMessages `SHOULD` be set to zero
91    outputs: Option<Vec<BlindedMessage>>,
92    /// Whether the client prefers asynchronous processing
93    #[serde(default)]
94    prefer_async: bool,
95    /// Selected fee option index for onchain melts
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    fee_index: Option<u32>,
98}
99
100#[cfg(feature = "mint")]
101impl TryFrom<MeltRequest<String>> for MeltRequest<QuoteId> {
102    type Error = Error;
103
104    fn try_from(value: MeltRequest<String>) -> Result<Self, Self::Error> {
105        Ok(Self {
106            quote: QuoteId::from_str(&value.quote).map_err(|_e| Error::InvalidQuote)?,
107            inputs: value.inputs,
108            outputs: value.outputs,
109            prefer_async: value.prefer_async,
110            fee_index: value.fee_index,
111        })
112    }
113}
114
115// Basic implementation without trait bounds
116impl<Q> MeltRequest<Q> {
117    /// Quote Id
118    pub fn quote_id(&self) -> &Q {
119        &self.quote
120    }
121
122    /// Get inputs (proofs)
123    pub fn inputs(&self) -> &Proofs {
124        &self.inputs
125    }
126
127    /// Get mutable inputs (proofs)
128    pub fn inputs_mut(&mut self) -> &mut Proofs {
129        &mut self.inputs
130    }
131
132    /// Get outputs (blinded messages for change)
133    pub fn outputs(&self) -> &Option<Vec<BlindedMessage>> {
134        &self.outputs
135    }
136}
137
138impl<Q> MeltRequest<Q>
139where
140    Q: Serialize + DeserializeOwned,
141{
142    /// Create new [`MeltRequest`]
143    pub fn new(quote: Q, inputs: Proofs, outputs: Option<Vec<BlindedMessage>>) -> Self {
144        Self {
145            quote,
146            inputs: inputs.without_dleqs(),
147            outputs,
148            prefer_async: false,
149            fee_index: None,
150        }
151    }
152
153    /// Set the prefer_async flag for asynchronous processing
154    pub fn prefer_async(mut self, prefer_async: bool) -> Self {
155        self.prefer_async = prefer_async;
156        self
157    }
158
159    /// Get the prefer_async flag
160    pub fn is_prefer_async(&self) -> bool {
161        self.prefer_async
162    }
163
164    /// Set the selected fee option index for onchain melts.
165    pub fn fee_index(mut self, fee_index: u32) -> Self {
166        self.fee_index = Some(fee_index);
167        self
168    }
169
170    /// Get the selected fee option index for onchain melts.
171    pub fn selected_fee_index(&self) -> Option<u32> {
172        self.fee_index
173    }
174
175    /// Get quote
176    pub fn quote(&self) -> &Q {
177        &self.quote
178    }
179
180    /// Total [`Amount`] of [`Proofs`]
181    pub fn inputs_amount(&self) -> Result<Amount, Error> {
182        Amount::try_sum(self.inputs.iter().map(|proof| proof.amount))
183            .map_err(|_| Error::AmountOverflow)
184    }
185}
186
187impl<Q> super::nut10::SpendingConditionVerification for MeltRequest<Q>
188where
189    Q: std::fmt::Display,
190{
191    fn inputs(&self) -> &Proofs {
192        &self.inputs
193    }
194
195    fn sig_all_msg_to_sign(&self) -> String {
196        let mut msg = String::new();
197
198        // Add all input secrets and C values in order
199        // msg = secret_0 || C_0 || ... || secret_n || C_n
200        for proof in &self.inputs {
201            msg.push_str(&proof.secret.to_string());
202            msg.push_str(&proof.c.to_hex());
203        }
204
205        // Add all output amounts and B_ values in order (if any)
206        // msg = ... || amount_0 || B_0 || ... || amount_m || B_m
207        if let Some(outputs) = &self.outputs {
208            for output in outputs {
209                msg.push_str(&output.amount.to_string());
210                msg.push_str(&output.blinded_secret.to_hex());
211            }
212        }
213
214        // Add quote ID
215        // msg = ... || quote_id
216        msg.push_str(&self.quote.to_string());
217
218        msg
219    }
220}
221
222/// Melt Method Settings
223#[derive(Debug, Clone, PartialEq, Eq, Hash)]
224pub struct MeltMethodSettings {
225    /// Payment Method e.g. bolt11
226    pub method: PaymentMethod,
227    /// Currency Unit e.g. sat
228    pub unit: CurrencyUnit,
229    /// Min Amount
230    pub min_amount: Option<Amount>,
231    /// Max Amount
232    pub max_amount: Option<Amount>,
233    /// Options
234    pub options: Option<MeltMethodOptions>,
235}
236
237impl Serialize for MeltMethodSettings {
238    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
239    where
240        S: Serializer,
241    {
242        let mut num_fields = 3; // method and unit are always present
243        if self.min_amount.is_some() {
244            num_fields += 1;
245        }
246        if self.max_amount.is_some() {
247            num_fields += 1;
248        }
249
250        let mut amountless_in_top_level = false;
251        if let Some(MeltMethodOptions::Bolt11 { amountless }) = &self.options {
252            if *amountless {
253                num_fields += 1;
254                amountless_in_top_level = true;
255            }
256        }
257
258        let mut state = serializer.serialize_struct("MeltMethodSettings", num_fields)?;
259
260        state.serialize_field("method", &self.method)?;
261        state.serialize_field("unit", &self.unit)?;
262
263        if let Some(min_amount) = &self.min_amount {
264            state.serialize_field("min_amount", min_amount)?;
265        }
266
267        if let Some(max_amount) = &self.max_amount {
268            state.serialize_field("max_amount", max_amount)?;
269        }
270
271        // If there's an amountless flag in Bolt11 options, add it at the top level
272        if amountless_in_top_level {
273            state.serialize_field("amountless", &true)?;
274        }
275
276        state.end()
277    }
278}
279
280struct MeltMethodSettingsVisitor;
281
282impl<'de> Visitor<'de> for MeltMethodSettingsVisitor {
283    type Value = MeltMethodSettings;
284
285    fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
286        formatter.write_str("a MeltMethodSettings structure")
287    }
288
289    fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
290    where
291        M: MapAccess<'de>,
292    {
293        let mut method: Option<PaymentMethod> = None;
294        let mut unit: Option<CurrencyUnit> = None;
295        let mut min_amount: Option<Amount> = None;
296        let mut max_amount: Option<Amount> = None;
297        let mut amountless: Option<bool> = None;
298
299        while let Some(key) = map.next_key::<String>()? {
300            match key.as_str() {
301                "method" => {
302                    if method.is_some() {
303                        return Err(de::Error::duplicate_field("method"));
304                    }
305                    method = Some(map.next_value()?);
306                }
307                "unit" => {
308                    if unit.is_some() {
309                        return Err(de::Error::duplicate_field("unit"));
310                    }
311                    unit = Some(map.next_value()?);
312                }
313                "min_amount" => {
314                    if min_amount.is_some() {
315                        return Err(de::Error::duplicate_field("min_amount"));
316                    }
317                    min_amount = Some(map.next_value()?);
318                }
319                "max_amount" => {
320                    if max_amount.is_some() {
321                        return Err(de::Error::duplicate_field("max_amount"));
322                    }
323                    max_amount = Some(map.next_value()?);
324                }
325                "amountless" => {
326                    if amountless.is_some() {
327                        return Err(de::Error::duplicate_field("amountless"));
328                    }
329                    amountless = Some(map.next_value()?);
330                }
331                "options" => {
332                    // If there are explicit options, they take precedence, except the amountless
333                    // field which we will handle specially
334                    let options: Option<MeltMethodOptions> = map.next_value()?;
335
336                    if let Some(MeltMethodOptions::Bolt11 {
337                        amountless: amountless_from_options,
338                    }) = options
339                    {
340                        // If we already found a top-level amountless, use that instead
341                        if amountless.is_none() {
342                            amountless = Some(amountless_from_options);
343                        }
344                    }
345                }
346                _ => {
347                    // Skip unknown fields
348                    let _: serde::de::IgnoredAny = map.next_value()?;
349                }
350            }
351        }
352
353        let method = method.ok_or_else(|| de::Error::missing_field("method"))?;
354        let unit = unit.ok_or_else(|| de::Error::missing_field("unit"))?;
355
356        // Create options based on the method and the amountless flag
357        let options = if method == PaymentMethod::Known(KnownMethod::Bolt11) && amountless.is_some()
358        {
359            amountless.map(|amountless| MeltMethodOptions::Bolt11 { amountless })
360        } else {
361            None
362        };
363
364        Ok(MeltMethodSettings {
365            method,
366            unit,
367            min_amount,
368            max_amount,
369            options,
370        })
371    }
372}
373
374impl<'de> Deserialize<'de> for MeltMethodSettings {
375    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
376    where
377        D: Deserializer<'de>,
378    {
379        deserializer.deserialize_map(MeltMethodSettingsVisitor)
380    }
381}
382
383/// Mint Method settings options
384#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
385#[serde(untagged)]
386pub enum MeltMethodOptions {
387    /// Bolt11 Options
388    Bolt11 {
389        /// Mint supports paying bolt11 amountless
390        amountless: bool,
391    },
392}
393
394impl Settings {
395    /// Create new [`Settings`]
396    pub fn new(methods: Vec<MeltMethodSettings>, disabled: bool) -> Self {
397        Self { methods, disabled }
398    }
399
400    /// Get [`MeltMethodSettings`] for unit method pair
401    pub fn get_settings(
402        &self,
403        unit: &CurrencyUnit,
404        method: &PaymentMethod,
405    ) -> Option<MeltMethodSettings> {
406        for method_settings in self.methods.iter() {
407            if method_settings.method.eq(method) && method_settings.unit.eq(unit) {
408                return Some(method_settings.clone());
409            }
410        }
411
412        None
413    }
414
415    /// Remove [`MeltMethodSettings`] for unit method pair
416    pub fn remove_settings(
417        &mut self,
418        unit: &CurrencyUnit,
419        method: &PaymentMethod,
420    ) -> Option<MeltMethodSettings> {
421        self.methods
422            .iter()
423            .position(|settings| settings.method.eq(method) && settings.unit.eq(unit))
424            .map(|index| self.methods.remove(index))
425    }
426}
427
428/// Melt Settings
429#[derive(Debug, Clone, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
430pub struct Settings {
431    /// Methods to melt
432    pub methods: Vec<MeltMethodSettings>,
433    /// Minting disabled
434    pub disabled: bool,
435}
436
437impl Settings {
438    /// Supported nut05 methods
439    pub fn supported_methods(&self) -> Vec<&PaymentMethod> {
440        self.methods.iter().map(|a| &a.method).collect()
441    }
442
443    /// Supported nut05 units
444    pub fn supported_units(&self) -> Vec<&CurrencyUnit> {
445        self.methods.iter().map(|s| &s.unit).collect()
446    }
447}
448
449/// Custom payment method melt quote request
450///
451/// This is a generic request type for melting tokens with custom payment methods.
452///
453/// The `extra` field allows payment-method-specific fields to be included
454/// without being nested. When serialized, extra fields merge into the parent JSON.
455#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
456pub struct MeltQuoteCustomRequest {
457    /// Custom payment method name
458    pub method: String,
459    /// Payment request string (method-specific format)
460    pub request: String,
461    /// Currency unit
462    pub unit: CurrencyUnit,
463    /// Extra payment-method-specific fields
464    ///
465    /// These fields are flattened into the JSON representation, allowing
466    /// custom payment methods to include additional data.
467    #[serde(flatten, default, skip_serializing_if = "serde_json::Value::is_null")]
468    pub extra: serde_json::Value,
469}
470
471/// Custom payment method melt quote response
472///
473/// This is a generic response type for custom payment methods.
474///
475/// The `extra` field allows payment-method-specific fields to be included
476/// without being nested. When serialized, extra fields merge into the parent JSON:
477/// ```json
478/// {
479///   "quote": "abc123",
480///   "state": "UNPAID",
481///   "amount": 1000,
482///   "fee_reserve": 10,
483///   "custom_field": "value"
484/// }
485/// ```
486///
487/// This separation enables proper validation layering: the mint verifies
488/// well-defined fields (amount, optional fee_reserve, state, etc.) while
489/// passing extra through to the gRPC payment processor for method-specific validation.
490///
491/// It also provides a clean upgrade path: when a payment method becomes speced,
492/// its fields can be promoted from `extra` to well-defined struct fields without
493/// breaking existing clients.
494#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
495#[serde(bound = "Q: Serialize + for<'a> Deserialize<'a>")]
496pub struct MeltQuoteCustomResponse<Q> {
497    /// Quote ID
498    pub quote: Q,
499    /// Amount to be melted
500    pub amount: Amount,
501    /// Fee reserve required, if provided
502    #[serde(default, skip_serializing_if = "Option::is_none")]
503    pub fee_reserve: Option<Amount>,
504    /// Quote State
505    pub state: QuoteState,
506    /// Unix timestamp until the quote is valid
507    pub expiry: u64,
508    /// Payment preimage (if payment completed)
509    #[serde(skip_serializing_if = "Option::is_none")]
510    pub payment_preimage: Option<String>,
511    /// Change (blinded signatures for overpaid amount)
512    #[serde(skip_serializing_if = "Option::is_none")]
513    pub change: Option<Vec<BlindSignature>>,
514    /// Payment request (optional, for reference)
515    #[serde(skip_serializing_if = "Option::is_none")]
516    pub request: Option<String>,
517    /// Currency unit
518    #[serde(skip_serializing_if = "Option::is_none")]
519    pub unit: Option<CurrencyUnit>,
520    /// Extra payment-method-specific fields
521    ///
522    /// These fields are flattened into the JSON representation, allowing
523    /// custom payment methods to include additional data without nesting.
524    #[serde(flatten, default, skip_serializing_if = "serde_json::Value::is_null")]
525    pub extra: serde_json::Value,
526}
527
528impl<Q: ToString> MeltQuoteCustomResponse<Q> {
529    /// Convert the MeltQuoteCustomResponse with a quote type Q to a String
530    pub fn to_string_id(&self) -> MeltQuoteCustomResponse<String> {
531        MeltQuoteCustomResponse {
532            quote: self.quote.to_string(),
533            amount: self.amount,
534            fee_reserve: self.fee_reserve,
535            state: self.state,
536            expiry: self.expiry,
537            payment_preimage: self.payment_preimage.clone(),
538            change: self.change.clone(),
539            request: self.request.clone(),
540            unit: self.unit.clone(),
541            extra: self.extra.clone(),
542        }
543    }
544}
545
546#[cfg(feature = "mint")]
547impl From<MeltQuoteCustomResponse<QuoteId>> for MeltQuoteCustomResponse<String> {
548    fn from(value: MeltQuoteCustomResponse<QuoteId>) -> Self {
549        Self {
550            quote: value.quote.to_string(),
551            amount: value.amount,
552            fee_reserve: value.fee_reserve,
553            state: value.state,
554            expiry: value.expiry,
555            payment_preimage: value.payment_preimage,
556            change: value.change,
557            request: value.request,
558            unit: value.unit,
559            extra: value.extra,
560        }
561    }
562}
563
564#[cfg(test)]
565mod tests {
566    use serde_json::{from_str, json, to_string};
567
568    use super::*;
569    use crate::nut00::KnownMethod;
570
571    #[test]
572    fn test_melt_method_settings_top_level_amountless() {
573        // Create JSON with top-level amountless
574        let json_str = r#"{
575            "method": "bolt11",
576            "unit": "sat",
577            "min_amount": 0,
578            "max_amount": 10000,
579            "amountless": true
580        }"#;
581
582        // Deserialize it
583        let settings: MeltMethodSettings = from_str(json_str).unwrap();
584
585        // Check that amountless was correctly moved to options
586        assert_eq!(settings.method, PaymentMethod::Known(KnownMethod::Bolt11));
587        assert_eq!(settings.unit, CurrencyUnit::Sat);
588        assert_eq!(settings.min_amount, Some(Amount::from(0)));
589        assert_eq!(settings.max_amount, Some(Amount::from(10000)));
590
591        match settings.options {
592            Some(MeltMethodOptions::Bolt11 { amountless }) => {
593                assert!(amountless);
594            }
595            _ => panic!("Expected Bolt11 options with amountless = true"),
596        }
597
598        // Serialize it back
599        let serialized = to_string(&settings).unwrap();
600        let parsed: serde_json::Value = from_str(&serialized).unwrap();
601
602        // Verify the amountless is at the top level
603        assert_eq!(parsed["amountless"], json!(true));
604    }
605
606    #[test]
607    fn test_both_amountless_locations() {
608        // Create JSON with amountless in both places (top level and in options)
609        let json_str = r#"{
610            "method": "bolt11",
611            "unit": "sat",
612            "min_amount": 0,
613            "max_amount": 10000,
614            "amountless": true,
615            "options": {
616                "amountless": false
617            }
618        }"#;
619
620        // Deserialize it - top level should take precedence
621        let settings: MeltMethodSettings = from_str(json_str).unwrap();
622
623        match settings.options {
624            Some(MeltMethodOptions::Bolt11 { amountless }) => {
625                assert!(amountless, "Top-level amountless should take precedence");
626            }
627            _ => panic!("Expected Bolt11 options with amountless = true"),
628        }
629    }
630
631    #[test]
632    fn test_melt_quote_custom_response_fee_reserve_optional() {
633        let json_str = r#"{
634            "quote": "abc123",
635            "state": "UNPAID",
636            "amount": 1000,
637            "expiry": 1234567890,
638            "custom_field": "value"
639        }"#;
640
641        let response: MeltQuoteCustomResponse<String> = from_str(json_str).unwrap();
642
643        assert_eq!(response.fee_reserve, None);
644        assert_eq!(response.extra["custom_field"], json!("value"));
645
646        let serialized = to_string(&response).unwrap();
647        let parsed: serde_json::Value = from_str(&serialized).unwrap();
648
649        assert!(parsed.get("fee_reserve").is_none());
650    }
651
652    #[test]
653    fn test_melt_quote_custom_response_serializes_fee_reserve_when_present() {
654        let response = MeltQuoteCustomResponse {
655            quote: "abc123".to_string(),
656            amount: Amount::from(1000),
657            fee_reserve: Some(Amount::from(10)),
658            state: QuoteState::Unpaid,
659            expiry: 1234567890,
660            payment_preimage: None,
661            change: None,
662            request: None,
663            unit: None,
664            extra: serde_json::Value::Null,
665        };
666
667        let serialized = to_string(&response).unwrap();
668        let parsed: serde_json::Value = from_str(&serialized).unwrap();
669
670        assert_eq!(parsed["fee_reserve"], json!(10));
671    }
672}