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