Skip to main content

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};
15use crate::nut00::KnownMethod;
16#[cfg(feature = "mint")]
17use crate::quote_id::QuoteId;
18#[cfg(feature = "mint")]
19use crate::quote_id::QuoteIdError;
20use crate::util::serde_helpers::deserialize_empty_string_as_none;
21use crate::{Amount, PublicKey};
22
23/// NUT04 Error
24#[derive(Debug, Error)]
25pub enum Error {
26    /// Unknown Quote State
27    #[error("Unknown Quote State")]
28    UnknownState,
29    /// Amount overflow
30    #[error("Amount overflow")]
31    AmountOverflow,
32}
33
34/// Mint request [NUT-04]
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(bound = "Q: Serialize + DeserializeOwned")]
37pub struct MintRequest<Q> {
38    /// Quote id
39    pub quote: Q,
40    /// Outputs
41    pub outputs: Vec<BlindedMessage>,
42    /// Signature
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub signature: Option<String>,
45}
46
47#[cfg(feature = "mint")]
48impl TryFrom<MintRequest<String>> for MintRequest<QuoteId> {
49    type Error = QuoteIdError;
50
51    fn try_from(value: MintRequest<String>) -> Result<Self, Self::Error> {
52        Ok(Self {
53            quote: QuoteId::from_str(&value.quote)?,
54            outputs: value.outputs,
55            signature: value.signature,
56        })
57    }
58}
59
60impl<Q> MintRequest<Q> {
61    /// Total [`Amount`] of outputs
62    pub fn total_amount(&self) -> Result<Amount, Error> {
63        Amount::try_sum(
64            self.outputs
65                .iter()
66                .map(|BlindedMessage { amount, .. }| *amount),
67        )
68        .map_err(|_| Error::AmountOverflow)
69    }
70}
71
72/// Mint response [NUT-04]
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74pub struct MintResponse {
75    /// Blinded Signatures
76    pub signatures: Vec<BlindSignature>,
77}
78
79/// Mint Method Settings
80#[derive(Debug, Clone, PartialEq, Eq, Hash)]
81pub struct MintMethodSettings {
82    /// Payment Method e.g. bolt11
83    pub method: PaymentMethod,
84    /// Currency Unit e.g. sat
85    pub unit: CurrencyUnit,
86    /// Min Amount
87    pub min_amount: Option<Amount>,
88    /// Max Amount
89    pub max_amount: Option<Amount>,
90    /// Options
91    pub options: Option<MintMethodOptions>,
92}
93
94impl Serialize for MintMethodSettings {
95    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
96    where
97        S: Serializer,
98    {
99        let mut num_fields = 2; // method and unit are always present
100        if self.min_amount.is_some() {
101            num_fields += 1;
102        }
103        if self.max_amount.is_some() {
104            num_fields += 1;
105        }
106
107        let mut description_in_top_level = false;
108        let mut onchain_confirmations: Option<u32> = None;
109
110        match &self.options {
111            Some(MintMethodOptions::Bolt11 { description }) if *description => {
112                num_fields += 1;
113                description_in_top_level = true;
114            }
115            Some(MintMethodOptions::Onchain { confirmations }) => {
116                onchain_confirmations = Some(*confirmations);
117                num_fields += 1; // for the "options" field
118            }
119            _ => {}
120        }
121
122        let mut state = serializer.serialize_struct("MintMethodSettings", num_fields)?;
123
124        state.serialize_field("method", &self.method)?;
125        state.serialize_field("unit", &self.unit)?;
126
127        if let Some(min_amount) = &self.min_amount {
128            state.serialize_field("min_amount", min_amount)?;
129        }
130
131        if let Some(max_amount) = &self.max_amount {
132            state.serialize_field("max_amount", max_amount)?;
133        }
134
135        // If there's a description flag in Bolt11 options, add it at the top level
136        if description_in_top_level {
137            state.serialize_field("description", &true)?;
138        }
139
140        // Serialize onchain options as a nested "options" object
141        if let Some(confirmations) = onchain_confirmations {
142            #[derive(Serialize)]
143            struct OnchainOptions {
144                confirmations: u32,
145            }
146            state.serialize_field("options", &OnchainOptions { confirmations })?;
147        }
148
149        state.end()
150    }
151}
152
153struct MintMethodSettingsVisitor;
154
155impl<'de> Visitor<'de> for MintMethodSettingsVisitor {
156    type Value = MintMethodSettings;
157
158    fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
159        formatter.write_str("a MintMethodSettings structure")
160    }
161
162    fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
163    where
164        M: MapAccess<'de>,
165    {
166        let mut method: Option<PaymentMethod> = None;
167        let mut unit: Option<CurrencyUnit> = None;
168        let mut min_amount: Option<Amount> = None;
169        let mut max_amount: Option<Amount> = None;
170        let mut description: Option<bool> = None;
171        let mut confirmations: Option<u32> = None;
172
173        while let Some(key) = map.next_key::<String>()? {
174            match key.as_str() {
175                "method" => {
176                    if method.is_some() {
177                        return Err(de::Error::duplicate_field("method"));
178                    }
179                    method = Some(map.next_value()?);
180                }
181                "unit" => {
182                    if unit.is_some() {
183                        return Err(de::Error::duplicate_field("unit"));
184                    }
185                    unit = Some(map.next_value()?);
186                }
187                "min_amount" => {
188                    if min_amount.is_some() {
189                        return Err(de::Error::duplicate_field("min_amount"));
190                    }
191                    min_amount = Some(map.next_value()?);
192                }
193                "max_amount" => {
194                    if max_amount.is_some() {
195                        return Err(de::Error::duplicate_field("max_amount"));
196                    }
197                    max_amount = Some(map.next_value()?);
198                }
199                "description" => {
200                    if description.is_some() {
201                        return Err(de::Error::duplicate_field("description"));
202                    }
203                    description = Some(map.next_value()?);
204                }
205                "confirmations" => {
206                    return Err(de::Error::unknown_field("confirmations", &["options"]));
207                }
208                "options" => {
209                    // If there are explicit options, they take precedence, except the description
210                    // field which we will handle specially
211                    let options: Option<MintMethodOptions> = map.next_value()?;
212
213                    if let Some(MintMethodOptions::Bolt11 {
214                        description: desc_from_options,
215                    }) = options
216                    {
217                        // If we already found a top-level description, use that instead
218                        if description.is_none() {
219                            description = Some(desc_from_options);
220                        }
221                    }
222
223                    if let Some(MintMethodOptions::Onchain {
224                        confirmations: conf_from_options,
225                    }) = options
226                    {
227                        confirmations = Some(conf_from_options);
228                    }
229                }
230                _ => {
231                    // Skip unknown fields
232                    let _: serde::de::IgnoredAny = map.next_value()?;
233                }
234            }
235        }
236
237        let method = method.ok_or_else(|| de::Error::missing_field("method"))?;
238        let unit = unit.ok_or_else(|| de::Error::missing_field("unit"))?;
239
240        // Create options based on the method and the description flag
241        let options = if method == PaymentMethod::Known(KnownMethod::Bolt11) {
242            description.map(|desc| MintMethodOptions::Bolt11 { description: desc })
243        } else if method == PaymentMethod::Known(KnownMethod::Onchain) {
244            confirmations.map(|conf| MintMethodOptions::Onchain {
245                confirmations: conf,
246            })
247        } else {
248            None
249        };
250
251        Ok(MintMethodSettings {
252            method,
253            unit,
254            min_amount,
255            max_amount,
256            options,
257        })
258    }
259}
260
261impl<'de> Deserialize<'de> for MintMethodSettings {
262    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
263    where
264        D: Deserializer<'de>,
265    {
266        deserializer.deserialize_map(MintMethodSettingsVisitor)
267    }
268}
269
270/// Mint Method settings options
271#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
272#[serde(untagged)]
273pub enum MintMethodOptions {
274    /// Bolt11 Options
275    Bolt11 {
276        /// Mint supports setting bolt11 description
277        description: bool,
278    },
279    /// Onchain Options
280    Onchain {
281        /// Minimum number of confirmations required
282        confirmations: u32,
283    },
284    /// Custom Options
285    Custom {},
286}
287
288/// Mint Settings
289#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
290pub struct Settings {
291    /// Methods to mint
292    pub methods: Vec<MintMethodSettings>,
293    /// Minting disabled
294    pub disabled: bool,
295}
296
297impl Settings {
298    /// Create new [`Settings`]
299    pub fn new(methods: Vec<MintMethodSettings>, disabled: bool) -> Self {
300        Self { methods, disabled }
301    }
302
303    /// Get [`MintMethodSettings`] for unit method pair
304    pub fn get_settings(
305        &self,
306        unit: &CurrencyUnit,
307        method: &PaymentMethod,
308    ) -> Option<MintMethodSettings> {
309        for method_settings in self.methods.iter() {
310            if method_settings.method.eq(method) && method_settings.unit.eq(unit) {
311                return Some(method_settings.clone());
312            }
313        }
314
315        None
316    }
317
318    /// Remove [`MintMethodSettings`] for unit method pair
319    pub fn remove_settings(
320        &mut self,
321        unit: &CurrencyUnit,
322        method: &PaymentMethod,
323    ) -> Option<MintMethodSettings> {
324        self.methods
325            .iter()
326            .position(|settings| &settings.method == method && &settings.unit == unit)
327            .map(|index| self.methods.remove(index))
328    }
329
330    /// Supported nut04 methods
331    pub fn supported_methods(&self) -> Vec<&PaymentMethod> {
332        self.methods.iter().map(|a| &a.method).collect()
333    }
334
335    /// Supported nut04 units
336    pub fn supported_units(&self) -> Vec<&CurrencyUnit> {
337        self.methods.iter().map(|s| &s.unit).collect()
338    }
339}
340
341/// Custom payment method mint quote request
342///
343/// This is a generic request type that works for any custom payment method.
344/// The method name is provided in the URL path, not in the request body.
345///
346/// The `extra` field allows payment-method-specific fields to be included
347/// without being nested. When serialized, extra fields merge into the parent JSON.
348#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
349pub struct MintQuoteCustomRequest {
350    /// Amount to mint
351    pub amount: Amount,
352    /// Currency unit
353    pub unit: CurrencyUnit,
354    /// Optional description
355    #[serde(skip_serializing_if = "Option::is_none")]
356    pub description: Option<String>,
357    /// NUT-19 Pubkey
358    #[serde(skip_serializing_if = "Option::is_none")]
359    pub pubkey: Option<PublicKey>,
360    /// Extra payment-method-specific fields
361    ///
362    /// These fields are flattened into the JSON representation, allowing
363    /// custom payment methods to include additional data (e.g., ehash share).
364    /// This enables proper validation layering: the mint verifies well-defined
365    /// fields while passing extra through to the payment processor.
366    #[serde(flatten, default, skip_serializing_if = "serde_json::Value::is_null")]
367    pub extra: serde_json::Value,
368}
369
370/// Custom payment method mint quote response
371///
372/// This is a generic response type for custom payment methods.
373///
374/// The `extra` field allows payment-method-specific fields to be included
375/// without being nested. When serialized, extra fields merge into the parent JSON:
376/// ```json
377/// {
378///   "quote": "abc123",
379///   "amount": 1000,
380///   "amount_paid": 0,
381///   "amount_issued": 0,
382///   "paypal_link": "https://paypal.me/merchant",
383///   "paypal_email": "merchant@example.com"
384/// }
385/// ```
386///
387/// This separation enables proper validation layering: the mint verifies
388/// well-defined fields (amount, unit, etc.) while passing extra through
389/// to the gRPC payment processor for method-specific validation.
390///
391/// It also provides a clean upgrade path: when a payment method becomes speced,
392/// its fields can be promoted from `extra` to well-defined struct fields without
393/// breaking existing clients (e.g., bolt12's `amount_paid` and `amount_issued`).
394#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
395#[serde(bound = "Q: Serialize + for<'a> Deserialize<'a>")]
396pub struct MintQuoteCustomResponse<Q> {
397    /// Quote ID
398    pub quote: Q,
399    /// Payment request string (method-specific format)
400    pub request: String,
401    /// Amount
402    pub amount: Option<Amount>,
403    /// Amount that has been paid
404    pub amount_paid: Amount,
405    /// Amount that has been issued
406    pub amount_issued: Amount,
407    /// Currency unit
408    pub unit: Option<CurrencyUnit>,
409    /// Unix timestamp until the quote is valid
410    pub expiry: Option<u64>,
411    /// NUT-19 Pubkey
412    #[serde(
413        default,
414        skip_serializing_if = "Option::is_none",
415        deserialize_with = "deserialize_empty_string_as_none"
416    )]
417    pub pubkey: Option<PublicKey>,
418    /// Extra payment-method-specific fields
419    ///
420    /// These fields are flattened into the JSON representation, allowing
421    /// custom payment methods to include additional data without nesting.
422    #[serde(flatten, default, skip_serializing_if = "serde_json::Value::is_null")]
423    pub extra: serde_json::Value,
424}
425
426#[cfg(feature = "mint")]
427impl<Q: ToString> MintQuoteCustomResponse<Q> {
428    /// Convert the MintQuoteCustomResponse with a quote type Q to a String
429    pub fn to_string_id(&self) -> MintQuoteCustomResponse<String> {
430        MintQuoteCustomResponse {
431            quote: self.quote.to_string(),
432            request: self.request.clone(),
433            amount: self.amount,
434            amount_paid: self.amount_paid,
435            amount_issued: self.amount_issued,
436            unit: self.unit.clone(),
437            expiry: self.expiry,
438            pubkey: self.pubkey,
439            extra: self.extra.clone(),
440        }
441    }
442}
443
444#[cfg(feature = "mint")]
445impl From<MintQuoteCustomResponse<QuoteId>> for MintQuoteCustomResponse<String> {
446    fn from(value: MintQuoteCustomResponse<QuoteId>) -> Self {
447        Self {
448            quote: value.quote.to_string(),
449            request: value.request,
450            amount: value.amount,
451            amount_paid: value.amount_paid,
452            amount_issued: value.amount_issued,
453            unit: value.unit,
454            expiry: value.expiry,
455            pubkey: value.pubkey,
456            extra: value.extra,
457        }
458    }
459}
460#[cfg(test)]
461mod tests {
462    use std::fmt;
463
464    use serde::ser::{Impossible, SerializeStruct, Serializer};
465    use serde_json::{from_str, json, to_string};
466
467    use super::*;
468    use crate::nut00::KnownMethod;
469
470    #[derive(Debug)]
471    struct FieldCountError(String);
472
473    impl fmt::Display for FieldCountError {
474        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
475            f.write_str(&self.0)
476        }
477    }
478
479    impl std::error::Error for FieldCountError {}
480
481    impl serde::ser::Error for FieldCountError {
482        fn custom<T>(msg: T) -> Self
483        where
484            T: fmt::Display,
485        {
486            Self(msg.to_string())
487        }
488    }
489
490    struct FieldCountSerializer;
491
492    struct FieldCountStruct {
493        declared: usize,
494        actual: usize,
495    }
496
497    impl Serializer for FieldCountSerializer {
498        type Ok = ();
499        type Error = FieldCountError;
500        type SerializeSeq = Impossible<Self::Ok, Self::Error>;
501        type SerializeTuple = Impossible<Self::Ok, Self::Error>;
502        type SerializeTupleStruct = Impossible<Self::Ok, Self::Error>;
503        type SerializeTupleVariant = Impossible<Self::Ok, Self::Error>;
504        type SerializeMap = Impossible<Self::Ok, Self::Error>;
505        type SerializeStruct = FieldCountStruct;
506        type SerializeStructVariant = Impossible<Self::Ok, Self::Error>;
507
508        fn serialize_bool(self, _v: bool) -> Result<Self::Ok, Self::Error> {
509            Err(FieldCountError("unsupported bool".to_string()))
510        }
511
512        fn serialize_i8(self, _v: i8) -> Result<Self::Ok, Self::Error> {
513            Err(FieldCountError("unsupported i8".to_string()))
514        }
515
516        fn serialize_i16(self, _v: i16) -> Result<Self::Ok, Self::Error> {
517            Err(FieldCountError("unsupported i16".to_string()))
518        }
519
520        fn serialize_i32(self, _v: i32) -> Result<Self::Ok, Self::Error> {
521            Err(FieldCountError("unsupported i32".to_string()))
522        }
523
524        fn serialize_i64(self, _v: i64) -> Result<Self::Ok, Self::Error> {
525            Err(FieldCountError("unsupported i64".to_string()))
526        }
527
528        fn serialize_u8(self, _v: u8) -> Result<Self::Ok, Self::Error> {
529            Err(FieldCountError("unsupported u8".to_string()))
530        }
531
532        fn serialize_u16(self, _v: u16) -> Result<Self::Ok, Self::Error> {
533            Err(FieldCountError("unsupported u16".to_string()))
534        }
535
536        fn serialize_u32(self, _v: u32) -> Result<Self::Ok, Self::Error> {
537            Err(FieldCountError("unsupported u32".to_string()))
538        }
539
540        fn serialize_u64(self, _v: u64) -> Result<Self::Ok, Self::Error> {
541            Err(FieldCountError("unsupported u64".to_string()))
542        }
543
544        fn serialize_f32(self, _v: f32) -> Result<Self::Ok, Self::Error> {
545            Err(FieldCountError("unsupported f32".to_string()))
546        }
547
548        fn serialize_f64(self, _v: f64) -> Result<Self::Ok, Self::Error> {
549            Err(FieldCountError("unsupported f64".to_string()))
550        }
551
552        fn serialize_char(self, _v: char) -> Result<Self::Ok, Self::Error> {
553            Err(FieldCountError("unsupported char".to_string()))
554        }
555
556        fn serialize_str(self, _v: &str) -> Result<Self::Ok, Self::Error> {
557            Err(FieldCountError("unsupported str".to_string()))
558        }
559
560        fn serialize_bytes(self, _v: &[u8]) -> Result<Self::Ok, Self::Error> {
561            Err(FieldCountError("unsupported bytes".to_string()))
562        }
563
564        fn serialize_none(self) -> Result<Self::Ok, Self::Error> {
565            Err(FieldCountError("unsupported none".to_string()))
566        }
567
568        fn serialize_some<T>(self, _value: &T) -> Result<Self::Ok, Self::Error>
569        where
570            T: ?Sized + Serialize,
571        {
572            Err(FieldCountError("unsupported some".to_string()))
573        }
574
575        fn serialize_unit(self) -> Result<Self::Ok, Self::Error> {
576            Err(FieldCountError("unsupported unit".to_string()))
577        }
578
579        fn serialize_unit_struct(self, _name: &'static str) -> Result<Self::Ok, Self::Error> {
580            Err(FieldCountError("unsupported unit struct".to_string()))
581        }
582
583        fn serialize_unit_variant(
584            self,
585            _name: &'static str,
586            _variant_index: u32,
587            _variant: &'static str,
588        ) -> Result<Self::Ok, Self::Error> {
589            Err(FieldCountError("unsupported unit variant".to_string()))
590        }
591
592        fn serialize_newtype_struct<T>(
593            self,
594            _name: &'static str,
595            _value: &T,
596        ) -> Result<Self::Ok, Self::Error>
597        where
598            T: ?Sized + Serialize,
599        {
600            Err(FieldCountError("unsupported newtype struct".to_string()))
601        }
602
603        fn serialize_newtype_variant<T>(
604            self,
605            _name: &'static str,
606            _variant_index: u32,
607            _variant: &'static str,
608            _value: &T,
609        ) -> Result<Self::Ok, Self::Error>
610        where
611            T: ?Sized + Serialize,
612        {
613            Err(FieldCountError("unsupported newtype variant".to_string()))
614        }
615
616        fn serialize_seq(self, _len: Option<usize>) -> Result<Self::SerializeSeq, Self::Error> {
617            Err(FieldCountError("unsupported seq".to_string()))
618        }
619
620        fn serialize_tuple(self, _len: usize) -> Result<Self::SerializeTuple, Self::Error> {
621            Err(FieldCountError("unsupported tuple".to_string()))
622        }
623
624        fn serialize_tuple_struct(
625            self,
626            _name: &'static str,
627            _len: usize,
628        ) -> Result<Self::SerializeTupleStruct, Self::Error> {
629            Err(FieldCountError("unsupported tuple struct".to_string()))
630        }
631
632        fn serialize_tuple_variant(
633            self,
634            _name: &'static str,
635            _variant_index: u32,
636            _variant: &'static str,
637            _len: usize,
638        ) -> Result<Self::SerializeTupleVariant, Self::Error> {
639            Err(FieldCountError("unsupported tuple variant".to_string()))
640        }
641
642        fn serialize_map(self, _len: Option<usize>) -> Result<Self::SerializeMap, Self::Error> {
643            Err(FieldCountError("unsupported map".to_string()))
644        }
645
646        fn serialize_struct(
647            self,
648            _name: &'static str,
649            len: usize,
650        ) -> Result<Self::SerializeStruct, Self::Error> {
651            Ok(FieldCountStruct {
652                declared: len,
653                actual: 0,
654            })
655        }
656
657        fn serialize_struct_variant(
658            self,
659            _name: &'static str,
660            _variant_index: u32,
661            _variant: &'static str,
662            _len: usize,
663        ) -> Result<Self::SerializeStructVariant, Self::Error> {
664            Err(FieldCountError("unsupported struct variant".to_string()))
665        }
666    }
667
668    impl SerializeStruct for FieldCountStruct {
669        type Ok = ();
670        type Error = FieldCountError;
671
672        fn serialize_field<T>(&mut self, _key: &'static str, _value: &T) -> Result<(), Self::Error>
673        where
674            T: ?Sized + Serialize,
675        {
676            self.actual += 1;
677            Ok(())
678        }
679
680        fn end(self) -> Result<Self::Ok, Self::Error> {
681            if self.actual == self.declared {
682                Ok(())
683            } else {
684                Err(FieldCountError(format!(
685                    "declared {} fields but serialized {}",
686                    self.declared, self.actual
687                )))
688            }
689        }
690    }
691
692    fn assert_mint_method_settings_field_count(settings: &MintMethodSettings) {
693        settings.serialize(FieldCountSerializer).unwrap();
694    }
695
696    #[test]
697    fn test_mint_request_total_amount() {
698        let request: MintRequest<String> = from_str(
699            r#"{
700                "quote": "quote-id",
701                "outputs": [
702                    {
703                        "amount": 2,
704                        "id": "00bfa73302d12ffd",
705                        "B_": "038ec853d65ae1b79b5cdbc2774150b2cb288d6d26e12958a16fb33c32d9a86c39"
706                    },
707                    {
708                        "amount": 4,
709                        "id": "00bfa73302d12ffd",
710                        "B_": "02c97ee3d1db41cf0a3ddb601724be8711a032950811bf326f8219c50c4808d3cd"
711                    }
712                ]
713            }"#,
714        )
715        .unwrap();
716
717        assert_eq!(request.total_amount().unwrap(), Amount::from(6));
718    }
719
720    #[test]
721    fn test_mint_method_settings_serialize_field_count() {
722        assert_mint_method_settings_field_count(&MintMethodSettings {
723            method: PaymentMethod::Known(KnownMethod::Bolt11),
724            unit: CurrencyUnit::Sat,
725            min_amount: Some(Amount::from(1)),
726            max_amount: Some(Amount::from(1000)),
727            options: Some(MintMethodOptions::Bolt11 { description: true }),
728        });
729
730        assert_mint_method_settings_field_count(&MintMethodSettings {
731            method: PaymentMethod::Known(KnownMethod::Bolt11),
732            unit: CurrencyUnit::Sat,
733            min_amount: Some(Amount::from(1)),
734            max_amount: None,
735            options: None,
736        });
737
738        assert_mint_method_settings_field_count(&MintMethodSettings {
739            method: PaymentMethod::Known(KnownMethod::Bolt11),
740            unit: CurrencyUnit::Sat,
741            min_amount: None,
742            max_amount: Some(Amount::from(1000)),
743            options: None,
744        });
745
746        assert_mint_method_settings_field_count(&MintMethodSettings {
747            method: PaymentMethod::Known(KnownMethod::Bolt11),
748            unit: CurrencyUnit::Sat,
749            min_amount: None,
750            max_amount: None,
751            options: Some(MintMethodOptions::Bolt11 { description: true }),
752        });
753
754        assert_mint_method_settings_field_count(&MintMethodSettings {
755            method: PaymentMethod::Known(KnownMethod::Onchain),
756            unit: CurrencyUnit::Sat,
757            min_amount: None,
758            max_amount: None,
759            options: Some(MintMethodOptions::Onchain { confirmations: 3 }),
760        });
761    }
762
763    #[test]
764    fn test_mint_method_settings_top_level_description() {
765        // Create JSON with top-level description
766        let json_str = r#"{
767            "method": "bolt11",
768            "unit": "sat",
769            "min_amount": 0,
770            "max_amount": 10000,
771            "description": true
772        }"#;
773
774        // Deserialize it
775        let settings: MintMethodSettings = from_str(json_str).unwrap();
776
777        // Check that description was correctly moved to options
778        assert_eq!(settings.method, PaymentMethod::Known(KnownMethod::Bolt11));
779        assert_eq!(settings.unit, CurrencyUnit::Sat);
780        assert_eq!(settings.min_amount, Some(Amount::from(0)));
781        assert_eq!(settings.max_amount, Some(Amount::from(10000)));
782
783        match settings.options {
784            Some(MintMethodOptions::Bolt11 { description }) => {
785                assert!(description);
786            }
787            _ => panic!("Expected Bolt11 options with description = true"),
788        }
789
790        // Serialize it back
791        let serialized = to_string(&settings).unwrap();
792        let parsed: serde_json::Value = from_str(&serialized).unwrap();
793
794        // Verify the description is at the top level
795        assert_eq!(parsed["description"], json!(true));
796    }
797
798    #[test]
799    fn test_mint_method_settings_does_not_serialize_false_description() {
800        let settings = MintMethodSettings {
801            method: PaymentMethod::Known(KnownMethod::Bolt11),
802            unit: CurrencyUnit::Sat,
803            min_amount: None,
804            max_amount: None,
805            options: Some(MintMethodOptions::Bolt11 { description: false }),
806        };
807
808        let serialized = to_string(&settings).unwrap();
809        let parsed: serde_json::Value = from_str(&serialized).unwrap();
810
811        assert_eq!(parsed["method"], json!("bolt11"));
812        assert!(parsed.get("description").is_none());
813        assert!(parsed.get("options").is_none());
814    }
815
816    #[test]
817    fn test_both_description_locations() {
818        // Create JSON with description in both places (top level and in options)
819        let json_str = r#"{
820            "method": "bolt11",
821            "unit": "sat",
822            "min_amount": 0,
823            "max_amount": 10000,
824            "description": true,
825            "options": {
826                "description": false
827            }
828        }"#;
829
830        // Deserialize it - top level should take precedence
831        let settings: MintMethodSettings = from_str(json_str).unwrap();
832
833        match settings.options {
834            Some(MintMethodOptions::Bolt11 { description }) => {
835                assert!(description, "Top-level description should take precedence");
836            }
837            _ => panic!("Expected Bolt11 options with description = true"),
838        }
839    }
840
841    #[test]
842    fn custom_mint_quote_response_has_no_typed_state() {
843        let response = MintQuoteCustomResponse {
844            quote: "abc123".to_string(),
845            request: "paypal://pay?id=123".to_string(),
846            amount: Some(Amount::from(1000)),
847            amount_paid: Amount::ZERO,
848            amount_issued: Amount::ZERO,
849            unit: Some(CurrencyUnit::Sat),
850            expiry: Some(9999999),
851            pubkey: None,
852            extra: serde_json::Value::Null,
853        };
854
855        let serialized = to_string(&response).unwrap();
856        let parsed: serde_json::Value = from_str(&serialized).unwrap();
857
858        assert!(parsed.get("state").is_none());
859        assert_eq!(parsed["amount_paid"], json!(0));
860        assert_eq!(parsed["amount_issued"], json!(0));
861    }
862
863    #[test]
864    fn custom_mint_quote_response_flattens_extra_on_wire() {
865        let response = MintQuoteCustomResponse {
866            quote: "q1".to_string(),
867            request: "custom://pay".to_string(),
868            amount: Some(Amount::from(100)),
869            amount_paid: Amount::ZERO,
870            amount_issued: Amount::ZERO,
871            unit: Some(CurrencyUnit::Sat),
872            expiry: Some(9999),
873            pubkey: None,
874            extra: json!({"payment_url": "https://example.com", "ref": 42}),
875        };
876
877        let serialized = to_string(&response).expect("serializes");
878        let parsed: serde_json::Value = from_str(&serialized).expect("parses");
879
880        assert_eq!(parsed["payment_url"], json!("https://example.com"));
881        assert_eq!(parsed["ref"], json!(42));
882        assert!(
883            parsed.get("extra").is_none(),
884            "extra must be flattened, not nested under an 'extra' key"
885        );
886    }
887
888    #[test]
889    fn test_onchain_settings_nested_options_round_trip() {
890        // NUT-26 spec format: confirmations nested inside "options"
891        let json_str = r#"{
892            "method": "onchain",
893            "unit": "sat",
894            "min_amount": 1000,
895            "max_amount": 1000000,
896            "options": {
897                "confirmations": 3
898            }
899        }"#;
900
901        let settings: MintMethodSettings = from_str(json_str).unwrap();
902
903        assert_eq!(settings.method, PaymentMethod::Known(KnownMethod::Onchain));
904        assert_eq!(settings.unit, CurrencyUnit::Sat);
905        assert_eq!(settings.min_amount, Some(Amount::from(1000)));
906        assert_eq!(settings.max_amount, Some(Amount::from(1000000)));
907
908        match settings.options {
909            Some(MintMethodOptions::Onchain { confirmations }) => {
910                assert_eq!(confirmations, 3);
911            }
912            _ => panic!("Expected Onchain options with confirmations = 3"),
913        }
914
915        // Serialize it back and verify the nested "options" structure
916        let serialized = to_string(&settings).unwrap();
917        let parsed: serde_json::Value = from_str(&serialized).unwrap();
918
919        assert_eq!(parsed["method"], json!("onchain"));
920        assert_eq!(parsed["options"]["confirmations"], json!(3));
921        // Verify confirmations is NOT at top level
922        assert!(parsed.get("confirmations").is_none());
923    }
924
925    #[test]
926    fn test_onchain_settings_top_level_confirmations_rejected() {
927        let json_str = r#"{
928            "method": "onchain",
929            "unit": "sat",
930            "confirmations": 6
931        }"#;
932
933        let err = from_str::<MintMethodSettings>(json_str).unwrap_err();
934        assert!(err.to_string().contains("unknown field"));
935    }
936
937    #[test]
938    fn test_onchain_settings_top_level_and_nested_rejected() {
939        let json_str = r#"{
940            "method": "onchain",
941            "unit": "sat",
942            "confirmations": 6,
943            "options": {
944                "confirmations": 3
945            }
946        }"#;
947
948        let err = from_str::<MintMethodSettings>(json_str).unwrap_err();
949        assert!(err.to_string().contains("unknown field"));
950    }
951
952    #[test]
953    fn test_mint_method_settings_visitor_reports_expected_type() {
954        let err = from_str::<MintMethodSettings>("[]").unwrap_err();
955
956        assert!(err.to_string().contains("a MintMethodSettings structure"));
957    }
958
959    #[test]
960    fn test_get_settings_requires_exact_method_and_unit_match() {
961        let bolt11_msat = MintMethodSettings {
962            method: PaymentMethod::Known(KnownMethod::Bolt11),
963            unit: CurrencyUnit::Msat,
964            min_amount: Some(Amount::from(1)),
965            max_amount: None,
966            options: None,
967        };
968        let bolt12_sat = MintMethodSettings {
969            method: PaymentMethod::Known(KnownMethod::Bolt12),
970            unit: CurrencyUnit::Sat,
971            min_amount: Some(Amount::from(2)),
972            max_amount: None,
973            options: None,
974        };
975        let bolt11_sat = MintMethodSettings {
976            method: PaymentMethod::Known(KnownMethod::Bolt11),
977            unit: CurrencyUnit::Sat,
978            min_amount: Some(Amount::from(3)),
979            max_amount: None,
980            options: None,
981        };
982        let settings = Settings::new(
983            vec![bolt11_msat.clone(), bolt12_sat.clone(), bolt11_sat.clone()],
984            false,
985        );
986
987        assert_eq!(
988            settings.get_settings(&CurrencyUnit::Sat, &PaymentMethod::BOLT11),
989            Some(bolt11_sat)
990        );
991        assert_eq!(
992            settings.get_settings(
993                &CurrencyUnit::Msat,
994                &PaymentMethod::Known(KnownMethod::Bolt12)
995            ),
996            None
997        );
998    }
999
1000    #[test]
1001    fn test_supported_methods_and_units_preserve_configured_values() {
1002        let settings = Settings::new(
1003            vec![
1004                MintMethodSettings {
1005                    method: PaymentMethod::Known(KnownMethod::Bolt11),
1006                    unit: CurrencyUnit::Msat,
1007                    min_amount: Some(Amount::from(1)),
1008                    max_amount: None,
1009                    options: None,
1010                },
1011                MintMethodSettings {
1012                    method: PaymentMethod::Known(KnownMethod::Onchain),
1013                    unit: CurrencyUnit::Eur,
1014                    min_amount: None,
1015                    max_amount: Some(Amount::from(100)),
1016                    options: Some(MintMethodOptions::Onchain { confirmations: 3 }),
1017                },
1018            ],
1019            false,
1020        );
1021
1022        let methods = settings.supported_methods();
1023        assert_eq!(methods.len(), 2);
1024        assert_eq!(methods[0], &PaymentMethod::Known(KnownMethod::Bolt11));
1025        assert_eq!(methods[1], &PaymentMethod::Known(KnownMethod::Onchain));
1026
1027        let units = settings.supported_units();
1028        assert_eq!(units.len(), 2);
1029        assert_eq!(units[0], &CurrencyUnit::Msat);
1030        assert_eq!(units[1], &CurrencyUnit::Eur);
1031    }
1032
1033    #[test]
1034    fn test_remove_settings_requires_exact_method_and_unit_match() {
1035        let bolt11_msat = MintMethodSettings {
1036            method: PaymentMethod::Known(KnownMethod::Bolt11),
1037            unit: CurrencyUnit::Msat,
1038            min_amount: Some(Amount::from(1)),
1039            max_amount: None,
1040            options: None,
1041        };
1042        let bolt12_sat = MintMethodSettings {
1043            method: PaymentMethod::Known(KnownMethod::Bolt12),
1044            unit: CurrencyUnit::Sat,
1045            min_amount: Some(Amount::from(2)),
1046            max_amount: None,
1047            options: None,
1048        };
1049        let bolt11_sat = MintMethodSettings {
1050            method: PaymentMethod::Known(KnownMethod::Bolt11),
1051            unit: CurrencyUnit::Sat,
1052            min_amount: Some(Amount::from(3)),
1053            max_amount: None,
1054            options: None,
1055        };
1056        let mut settings = Settings::new(
1057            vec![bolt11_msat.clone(), bolt12_sat.clone(), bolt11_sat.clone()],
1058            false,
1059        );
1060
1061        assert_eq!(
1062            settings.remove_settings(&CurrencyUnit::Sat, &PaymentMethod::BOLT11),
1063            Some(bolt11_sat.clone())
1064        );
1065        assert_eq!(settings.methods, vec![bolt11_msat, bolt12_sat]);
1066        assert_eq!(
1067            settings.remove_settings(&CurrencyUnit::Sat, &PaymentMethod::BOLT11),
1068            None
1069        );
1070    }
1071}