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    /// Human-readable name for the payment method.
87    ///
88    /// If null or omitted on the wire, wallets should derive it from `method`
89    /// by replacing `_` and `-` with spaces and title-casing each word.
90    pub method_name: Option<String>,
91    /// Min Amount
92    pub min_amount: Option<Amount>,
93    /// Max Amount
94    pub max_amount: Option<Amount>,
95    /// Options
96    pub options: Option<MintMethodOptions>,
97}
98
99impl MintMethodSettings {
100    /// Human-readable payment method name.
101    ///
102    /// Returns the explicit `method_name` when present. If it is null or omitted,
103    /// derives the name from `method` by replacing `_` and `-` with spaces and
104    /// title-casing each word.
105    pub fn method_name(&self) -> String {
106        self.method_name
107            .clone()
108            .unwrap_or_else(|| self.method.derived_method_name())
109    }
110}
111
112impl Serialize for MintMethodSettings {
113    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
114    where
115        S: Serializer,
116    {
117        let mut num_fields = 2; // method and unit are always present
118        if self.min_amount.is_some() {
119            num_fields += 1;
120        }
121        if self.max_amount.is_some() {
122            num_fields += 1;
123        }
124        if self.method_name.is_some() {
125            num_fields += 1;
126        }
127
128        let mut nested_description: Option<bool> = None;
129        let mut onchain_confirmations: Option<u32> = None;
130
131        match &self.options {
132            // NUT-23 (bolt11) and NUT-25 (bolt12) advertise description support
133            // in a nested options object.
134            Some(MintMethodOptions::Bolt11 { description })
135            | Some(MintMethodOptions::Bolt12 { description }) => {
136                nested_description = Some(*description);
137                num_fields += 1; // for the "options" field
138            }
139            Some(MintMethodOptions::Onchain { confirmations }) => {
140                onchain_confirmations = Some(*confirmations);
141                num_fields += 1; // for the "options" field
142            }
143            _ => {}
144        }
145
146        let mut state = serializer.serialize_struct("MintMethodSettings", num_fields)?;
147
148        state.serialize_field("method", &self.method)?;
149        state.serialize_field("unit", &self.unit)?;
150
151        if let Some(method_name) = &self.method_name {
152            state.serialize_field("method_name", method_name)?;
153        }
154
155        if let Some(min_amount) = &self.min_amount {
156            state.serialize_field("min_amount", min_amount)?;
157        }
158
159        if let Some(max_amount) = &self.max_amount {
160            state.serialize_field("max_amount", max_amount)?;
161        }
162
163        // Serialize onchain options as a nested "options" object
164        if let Some(confirmations) = onchain_confirmations {
165            #[derive(Serialize)]
166            struct OnchainOptions {
167                confirmations: u32,
168            }
169            state.serialize_field("options", &OnchainOptions { confirmations })?;
170        }
171
172        // Serialize bolt11/bolt12 description as a nested "options" object
173        if let Some(description) = nested_description {
174            #[derive(Serialize)]
175            struct DescriptionOptions {
176                description: bool,
177            }
178            state.serialize_field("options", &DescriptionOptions { description })?;
179        }
180
181        state.end()
182    }
183}
184
185struct MintMethodSettingsVisitor;
186
187impl<'de> Visitor<'de> for MintMethodSettingsVisitor {
188    type Value = MintMethodSettings;
189
190    fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
191        formatter.write_str("a MintMethodSettings structure")
192    }
193
194    fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
195    where
196        M: MapAccess<'de>,
197    {
198        let mut method: Option<PaymentMethod> = None;
199        let mut unit: Option<CurrencyUnit> = None;
200        let mut method_name: Option<String> = None;
201        let mut min_amount: Option<Amount> = None;
202        let mut max_amount: Option<Amount> = None;
203        let mut description: Option<bool> = None;
204        let mut confirmations: Option<u32> = None;
205
206        while let Some(key) = map.next_key::<String>()? {
207            match key.as_str() {
208                "method" => {
209                    if method.is_some() {
210                        return Err(de::Error::duplicate_field("method"));
211                    }
212                    method = Some(map.next_value()?);
213                }
214                "unit" => {
215                    if unit.is_some() {
216                        return Err(de::Error::duplicate_field("unit"));
217                    }
218                    unit = Some(map.next_value()?);
219                }
220                "method_name" => {
221                    if method_name.is_some() {
222                        return Err(de::Error::duplicate_field("method_name"));
223                    }
224                    method_name = map.next_value()?;
225                }
226                "min_amount" => {
227                    if min_amount.is_some() {
228                        return Err(de::Error::duplicate_field("min_amount"));
229                    }
230                    min_amount = Some(map.next_value()?);
231                }
232                "max_amount" => {
233                    if max_amount.is_some() {
234                        return Err(de::Error::duplicate_field("max_amount"));
235                    }
236                    max_amount = Some(map.next_value()?);
237                }
238                "description" => {
239                    if description.is_some() {
240                        return Err(de::Error::duplicate_field("description"));
241                    }
242                    description = Some(map.next_value()?);
243                }
244                "confirmations" => {
245                    return Err(de::Error::unknown_field("confirmations", &["options"]));
246                }
247                "options" => {
248                    // If there are explicit options, they take precedence, except the description
249                    // field which we will handle specially
250                    let options: Option<MintMethodOptions> = map.next_value()?;
251
252                    if let Some(MintMethodOptions::Bolt11 {
253                        description: desc_from_options,
254                    }) = options
255                    {
256                        // If we already found a top-level description, use that instead
257                        if description.is_none() {
258                            description = Some(desc_from_options);
259                        }
260                    }
261
262                    if let Some(MintMethodOptions::Onchain {
263                        confirmations: conf_from_options,
264                    }) = options
265                    {
266                        confirmations = Some(conf_from_options);
267                    }
268                }
269                _ => {
270                    // Skip unknown fields
271                    let _: serde::de::IgnoredAny = map.next_value()?;
272                }
273            }
274        }
275
276        let method = method.ok_or_else(|| de::Error::missing_field("method"))?;
277        let unit = unit.ok_or_else(|| de::Error::missing_field("unit"))?;
278
279        // Create options based on the method and the description flag
280        // Note: the wire format of both bolt11 and bolt12 description options is
281        // {"description": bool}, which untagged deserialization maps to the
282        // Bolt11 variant. It is remapped here based on the method.
283        let options = if method == PaymentMethod::Known(KnownMethod::Bolt11) {
284            description.map(|desc| MintMethodOptions::Bolt11 { description: desc })
285        } else if method == PaymentMethod::Known(KnownMethod::Bolt12) {
286            description.map(|desc| MintMethodOptions::Bolt12 { description: desc })
287        } else if method == PaymentMethod::Known(KnownMethod::Onchain) {
288            confirmations.map(|conf| MintMethodOptions::Onchain {
289                confirmations: conf,
290            })
291        } else {
292            None
293        };
294
295        Ok(MintMethodSettings {
296            method,
297            unit,
298            method_name,
299            min_amount,
300            max_amount,
301            options,
302        })
303    }
304}
305
306impl<'de> Deserialize<'de> for MintMethodSettings {
307    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
308    where
309        D: Deserializer<'de>,
310    {
311        deserializer.deserialize_map(MintMethodSettingsVisitor)
312    }
313}
314
315/// Mint Method settings options
316#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
317#[serde(untagged)]
318pub enum MintMethodOptions {
319    /// Bolt11 Options
320    Bolt11 {
321        /// Mint supports setting bolt11 description
322        description: bool,
323    },
324    /// Bolt12 Options
325    Bolt12 {
326        /// Mint supports setting bolt12 description
327        description: bool,
328    },
329    /// Onchain Options
330    Onchain {
331        /// Minimum number of confirmations required
332        confirmations: u32,
333    },
334    /// Custom Options
335    Custom {},
336}
337
338/// Mint Settings
339#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
340pub struct Settings {
341    /// Methods to mint
342    pub methods: Vec<MintMethodSettings>,
343    /// Minting disabled
344    pub disabled: bool,
345}
346
347impl Settings {
348    /// Create new [`Settings`]
349    pub fn new(methods: Vec<MintMethodSettings>, disabled: bool) -> Self {
350        Self { methods, disabled }
351    }
352
353    /// Get [`MintMethodSettings`] for unit method pair
354    pub fn get_settings(
355        &self,
356        unit: &CurrencyUnit,
357        method: &PaymentMethod,
358    ) -> Option<MintMethodSettings> {
359        for method_settings in self.methods.iter() {
360            if method_settings.method.eq(method) && method_settings.unit.eq(unit) {
361                return Some(method_settings.clone());
362            }
363        }
364
365        None
366    }
367
368    /// Remove [`MintMethodSettings`] for unit method pair
369    pub fn remove_settings(
370        &mut self,
371        unit: &CurrencyUnit,
372        method: &PaymentMethod,
373    ) -> Option<MintMethodSettings> {
374        self.methods
375            .iter()
376            .position(|settings| &settings.method == method && &settings.unit == unit)
377            .map(|index| self.methods.remove(index))
378    }
379
380    /// Supported nut04 methods
381    pub fn supported_methods(&self) -> Vec<&PaymentMethod> {
382        self.methods.iter().map(|a| &a.method).collect()
383    }
384
385    /// Supported nut04 units
386    pub fn supported_units(&self) -> Vec<&CurrencyUnit> {
387        self.methods.iter().map(|s| &s.unit).collect()
388    }
389}
390
391/// Custom payment method mint quote request
392///
393/// This is a generic request type that works for any custom payment method.
394/// The method name is provided in the URL path, not in the request body.
395///
396/// The `extra` field allows payment-method-specific fields to be included
397/// without being nested. When serialized, extra fields merge into the parent JSON.
398#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
399pub struct MintQuoteCustomRequest {
400    /// Amount to mint
401    ///
402    /// Optional common field. Method-specific NUTs make it required or ignore
403    /// it as needed (e.g. NUT-23 requires `amount`).
404    #[serde(default, skip_serializing_if = "Option::is_none")]
405    pub amount: Option<Amount>,
406    /// Currency unit
407    pub unit: CurrencyUnit,
408    /// Optional description
409    #[serde(skip_serializing_if = "Option::is_none")]
410    pub description: Option<String>,
411    /// NUT-19 Pubkey
412    #[serde(skip_serializing_if = "Option::is_none")]
413    pub pubkey: Option<PublicKey>,
414    /// Extra payment-method-specific fields
415    ///
416    /// These fields are flattened into the JSON representation, allowing
417    /// custom payment methods to include additional data (e.g., ehash share).
418    /// This enables proper validation layering: the mint verifies well-defined
419    /// fields while passing extra through to the payment processor.
420    #[serde(flatten, default, skip_serializing_if = "serde_json::Value::is_null")]
421    pub extra: serde_json::Value,
422}
423
424/// Custom payment method mint quote response
425///
426/// This is a generic response type for custom payment methods.
427///
428/// The `extra` field allows payment-method-specific fields to be included
429/// without being nested. When serialized, extra fields merge into the parent JSON:
430/// ```json
431/// {
432///   "quote": "abc123",
433///   "method": "paypal",
434///   "amount": 1000,
435///   "amount_paid": 0,
436///   "amount_issued": 0,
437///   "paypal_link": "https://paypal.me/merchant",
438///   "paypal_email": "merchant@example.com"
439/// }
440/// ```
441///
442/// This separation enables proper validation layering: the mint verifies
443/// well-defined fields (amount, unit, etc.) while passing extra through
444/// to the gRPC payment processor for method-specific validation.
445///
446/// It also provides a clean upgrade path: when a payment method becomes speced,
447/// its fields can be promoted from `extra` to well-defined struct fields without
448/// breaking existing clients (e.g., bolt12's `amount_paid` and `amount_issued`).
449#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
450#[serde(bound = "Q: Serialize + for<'a> Deserialize<'a>")]
451pub struct MintQuoteCustomResponse<Q> {
452    /// Quote ID
453    pub quote: Q,
454    /// Payment request string (method-specific format)
455    pub request: String,
456    /// Payment method
457    pub method: PaymentMethod,
458    /// Amount
459    pub amount: Option<Amount>,
460    /// Amount that has been paid
461    pub amount_paid: Amount,
462    /// Amount that has been issued
463    pub amount_issued: Amount,
464    /// Unix timestamp indicating when the quote was last updated
465    #[serde(default)]
466    pub updated_at: u64,
467    /// Currency unit
468    pub unit: Option<CurrencyUnit>,
469    /// Unix timestamp until the quote is valid
470    pub expiry: Option<u64>,
471    /// NUT-19 Pubkey
472    #[serde(
473        default,
474        skip_serializing_if = "Option::is_none",
475        deserialize_with = "deserialize_empty_string_as_none"
476    )]
477    pub pubkey: Option<PublicKey>,
478    /// Extra payment-method-specific fields
479    ///
480    /// These fields are flattened into the JSON representation, allowing
481    /// custom payment methods to include additional data without nesting.
482    #[serde(flatten, default, skip_serializing_if = "serde_json::Value::is_null")]
483    pub extra: serde_json::Value,
484}
485
486#[cfg(feature = "mint")]
487impl<Q: ToString> MintQuoteCustomResponse<Q> {
488    /// Convert the MintQuoteCustomResponse with a quote type Q to a String
489    pub fn to_string_id(&self) -> MintQuoteCustomResponse<String> {
490        MintQuoteCustomResponse {
491            quote: self.quote.to_string(),
492            request: self.request.clone(),
493            method: self.method.clone(),
494            amount: self.amount,
495            amount_paid: self.amount_paid,
496            amount_issued: self.amount_issued,
497            updated_at: self.updated_at,
498            unit: self.unit.clone(),
499            expiry: self.expiry,
500            pubkey: self.pubkey,
501            extra: self.extra.clone(),
502        }
503    }
504}
505
506#[cfg(feature = "mint")]
507impl From<MintQuoteCustomResponse<QuoteId>> for MintQuoteCustomResponse<String> {
508    fn from(value: MintQuoteCustomResponse<QuoteId>) -> Self {
509        Self {
510            quote: value.quote.to_string(),
511            request: value.request,
512            method: value.method,
513            amount: value.amount,
514            amount_paid: value.amount_paid,
515            amount_issued: value.amount_issued,
516            updated_at: value.updated_at,
517            unit: value.unit,
518            expiry: value.expiry,
519            pubkey: value.pubkey,
520            extra: value.extra,
521        }
522    }
523}
524#[cfg(test)]
525mod tests {
526    use std::fmt;
527
528    use serde::ser::{Impossible, SerializeStruct, Serializer};
529    use serde_json::{from_str, json, to_string};
530
531    use super::*;
532    use crate::nut00::KnownMethod;
533
534    #[derive(Debug)]
535    struct FieldCountError(String);
536
537    impl fmt::Display for FieldCountError {
538        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
539            f.write_str(&self.0)
540        }
541    }
542
543    impl std::error::Error for FieldCountError {}
544
545    impl serde::ser::Error for FieldCountError {
546        fn custom<T>(msg: T) -> Self
547        where
548            T: fmt::Display,
549        {
550            Self(msg.to_string())
551        }
552    }
553
554    struct FieldCountSerializer;
555
556    struct FieldCountStruct {
557        declared: usize,
558        actual: usize,
559    }
560
561    impl Serializer for FieldCountSerializer {
562        type Ok = ();
563        type Error = FieldCountError;
564        type SerializeSeq = Impossible<Self::Ok, Self::Error>;
565        type SerializeTuple = Impossible<Self::Ok, Self::Error>;
566        type SerializeTupleStruct = Impossible<Self::Ok, Self::Error>;
567        type SerializeTupleVariant = Impossible<Self::Ok, Self::Error>;
568        type SerializeMap = Impossible<Self::Ok, Self::Error>;
569        type SerializeStruct = FieldCountStruct;
570        type SerializeStructVariant = Impossible<Self::Ok, Self::Error>;
571
572        fn serialize_bool(self, _v: bool) -> Result<Self::Ok, Self::Error> {
573            Err(FieldCountError("unsupported bool".to_string()))
574        }
575
576        fn serialize_i8(self, _v: i8) -> Result<Self::Ok, Self::Error> {
577            Err(FieldCountError("unsupported i8".to_string()))
578        }
579
580        fn serialize_i16(self, _v: i16) -> Result<Self::Ok, Self::Error> {
581            Err(FieldCountError("unsupported i16".to_string()))
582        }
583
584        fn serialize_i32(self, _v: i32) -> Result<Self::Ok, Self::Error> {
585            Err(FieldCountError("unsupported i32".to_string()))
586        }
587
588        fn serialize_i64(self, _v: i64) -> Result<Self::Ok, Self::Error> {
589            Err(FieldCountError("unsupported i64".to_string()))
590        }
591
592        fn serialize_u8(self, _v: u8) -> Result<Self::Ok, Self::Error> {
593            Err(FieldCountError("unsupported u8".to_string()))
594        }
595
596        fn serialize_u16(self, _v: u16) -> Result<Self::Ok, Self::Error> {
597            Err(FieldCountError("unsupported u16".to_string()))
598        }
599
600        fn serialize_u32(self, _v: u32) -> Result<Self::Ok, Self::Error> {
601            Err(FieldCountError("unsupported u32".to_string()))
602        }
603
604        fn serialize_u64(self, _v: u64) -> Result<Self::Ok, Self::Error> {
605            Err(FieldCountError("unsupported u64".to_string()))
606        }
607
608        fn serialize_f32(self, _v: f32) -> Result<Self::Ok, Self::Error> {
609            Err(FieldCountError("unsupported f32".to_string()))
610        }
611
612        fn serialize_f64(self, _v: f64) -> Result<Self::Ok, Self::Error> {
613            Err(FieldCountError("unsupported f64".to_string()))
614        }
615
616        fn serialize_char(self, _v: char) -> Result<Self::Ok, Self::Error> {
617            Err(FieldCountError("unsupported char".to_string()))
618        }
619
620        fn serialize_str(self, _v: &str) -> Result<Self::Ok, Self::Error> {
621            Err(FieldCountError("unsupported str".to_string()))
622        }
623
624        fn serialize_bytes(self, _v: &[u8]) -> Result<Self::Ok, Self::Error> {
625            Err(FieldCountError("unsupported bytes".to_string()))
626        }
627
628        fn serialize_none(self) -> Result<Self::Ok, Self::Error> {
629            Err(FieldCountError("unsupported none".to_string()))
630        }
631
632        fn serialize_some<T>(self, _value: &T) -> Result<Self::Ok, Self::Error>
633        where
634            T: ?Sized + Serialize,
635        {
636            Err(FieldCountError("unsupported some".to_string()))
637        }
638
639        fn serialize_unit(self) -> Result<Self::Ok, Self::Error> {
640            Err(FieldCountError("unsupported unit".to_string()))
641        }
642
643        fn serialize_unit_struct(self, _name: &'static str) -> Result<Self::Ok, Self::Error> {
644            Err(FieldCountError("unsupported unit struct".to_string()))
645        }
646
647        fn serialize_unit_variant(
648            self,
649            _name: &'static str,
650            _variant_index: u32,
651            _variant: &'static str,
652        ) -> Result<Self::Ok, Self::Error> {
653            Err(FieldCountError("unsupported unit variant".to_string()))
654        }
655
656        fn serialize_newtype_struct<T>(
657            self,
658            _name: &'static str,
659            _value: &T,
660        ) -> Result<Self::Ok, Self::Error>
661        where
662            T: ?Sized + Serialize,
663        {
664            Err(FieldCountError("unsupported newtype struct".to_string()))
665        }
666
667        fn serialize_newtype_variant<T>(
668            self,
669            _name: &'static str,
670            _variant_index: u32,
671            _variant: &'static str,
672            _value: &T,
673        ) -> Result<Self::Ok, Self::Error>
674        where
675            T: ?Sized + Serialize,
676        {
677            Err(FieldCountError("unsupported newtype variant".to_string()))
678        }
679
680        fn serialize_seq(self, _len: Option<usize>) -> Result<Self::SerializeSeq, Self::Error> {
681            Err(FieldCountError("unsupported seq".to_string()))
682        }
683
684        fn serialize_tuple(self, _len: usize) -> Result<Self::SerializeTuple, Self::Error> {
685            Err(FieldCountError("unsupported tuple".to_string()))
686        }
687
688        fn serialize_tuple_struct(
689            self,
690            _name: &'static str,
691            _len: usize,
692        ) -> Result<Self::SerializeTupleStruct, Self::Error> {
693            Err(FieldCountError("unsupported tuple struct".to_string()))
694        }
695
696        fn serialize_tuple_variant(
697            self,
698            _name: &'static str,
699            _variant_index: u32,
700            _variant: &'static str,
701            _len: usize,
702        ) -> Result<Self::SerializeTupleVariant, Self::Error> {
703            Err(FieldCountError("unsupported tuple variant".to_string()))
704        }
705
706        fn serialize_map(self, _len: Option<usize>) -> Result<Self::SerializeMap, Self::Error> {
707            Err(FieldCountError("unsupported map".to_string()))
708        }
709
710        fn serialize_struct(
711            self,
712            _name: &'static str,
713            len: usize,
714        ) -> Result<Self::SerializeStruct, Self::Error> {
715            Ok(FieldCountStruct {
716                declared: len,
717                actual: 0,
718            })
719        }
720
721        fn serialize_struct_variant(
722            self,
723            _name: &'static str,
724            _variant_index: u32,
725            _variant: &'static str,
726            _len: usize,
727        ) -> Result<Self::SerializeStructVariant, Self::Error> {
728            Err(FieldCountError("unsupported struct variant".to_string()))
729        }
730    }
731
732    impl SerializeStruct for FieldCountStruct {
733        type Ok = ();
734        type Error = FieldCountError;
735
736        fn serialize_field<T>(&mut self, _key: &'static str, _value: &T) -> Result<(), Self::Error>
737        where
738            T: ?Sized + Serialize,
739        {
740            self.actual += 1;
741            Ok(())
742        }
743
744        fn end(self) -> Result<Self::Ok, Self::Error> {
745            if self.actual == self.declared {
746                Ok(())
747            } else {
748                Err(FieldCountError(format!(
749                    "declared {} fields but serialized {}",
750                    self.declared, self.actual
751                )))
752            }
753        }
754    }
755
756    fn assert_mint_method_settings_field_count(settings: &MintMethodSettings) {
757        settings.serialize(FieldCountSerializer).unwrap();
758    }
759
760    #[test]
761    fn test_mint_request_total_amount() {
762        let request: MintRequest<String> = from_str(
763            r#"{
764                "quote": "quote-id",
765                "outputs": [
766                    {
767                        "amount": 2,
768                        "id": "00bfa73302d12ffd",
769                        "B_": "038ec853d65ae1b79b5cdbc2774150b2cb288d6d26e12958a16fb33c32d9a86c39"
770                    },
771                    {
772                        "amount": 4,
773                        "id": "00bfa73302d12ffd",
774                        "B_": "02c97ee3d1db41cf0a3ddb601724be8711a032950811bf326f8219c50c4808d3cd"
775                    }
776                ]
777            }"#,
778        )
779        .unwrap();
780
781        assert_eq!(request.total_amount().unwrap(), Amount::from(6));
782    }
783
784    #[test]
785    fn test_mint_method_settings_serialize_field_count() {
786        assert_mint_method_settings_field_count(&MintMethodSettings {
787            method: PaymentMethod::Known(KnownMethod::Bolt11),
788            unit: CurrencyUnit::Sat,
789            method_name: None,
790            min_amount: Some(Amount::from(1)),
791            max_amount: Some(Amount::from(1000)),
792            options: Some(MintMethodOptions::Bolt11 { description: true }),
793        });
794
795        assert_mint_method_settings_field_count(&MintMethodSettings {
796            method: PaymentMethod::Known(KnownMethod::Bolt11),
797            unit: CurrencyUnit::Sat,
798            method_name: None,
799            min_amount: Some(Amount::from(1)),
800            max_amount: None,
801            options: None,
802        });
803
804        assert_mint_method_settings_field_count(&MintMethodSettings {
805            method: PaymentMethod::Known(KnownMethod::Bolt11),
806            unit: CurrencyUnit::Sat,
807            method_name: None,
808            min_amount: None,
809            max_amount: Some(Amount::from(1000)),
810            options: None,
811        });
812
813        assert_mint_method_settings_field_count(&MintMethodSettings {
814            method: PaymentMethod::Known(KnownMethod::Bolt11),
815            unit: CurrencyUnit::Sat,
816            method_name: None,
817            min_amount: None,
818            max_amount: None,
819            options: Some(MintMethodOptions::Bolt11 { description: true }),
820        });
821
822        assert_mint_method_settings_field_count(&MintMethodSettings {
823            method: PaymentMethod::Known(KnownMethod::Onchain),
824            unit: CurrencyUnit::Sat,
825            method_name: None,
826            min_amount: None,
827            max_amount: None,
828            options: Some(MintMethodOptions::Onchain { confirmations: 3 }),
829        });
830
831        assert_mint_method_settings_field_count(&MintMethodSettings {
832            method: PaymentMethod::Known(KnownMethod::Bolt11),
833            unit: CurrencyUnit::Sat,
834            method_name: Some("Lightning".to_string()),
835            min_amount: None,
836            max_amount: None,
837            options: None,
838        });
839    }
840
841    #[test]
842    fn test_mint_method_settings_top_level_description() {
843        // Create JSON with top-level description
844        let json_str = r#"{
845            "method": "bolt11",
846            "unit": "sat",
847            "min_amount": 0,
848            "max_amount": 10000,
849            "description": true
850        }"#;
851
852        // Deserialize it
853        let settings: MintMethodSettings = from_str(json_str).unwrap();
854
855        // Check that description was correctly moved to options
856        assert_eq!(settings.method, PaymentMethod::Known(KnownMethod::Bolt11));
857        assert_eq!(settings.unit, CurrencyUnit::Sat);
858        assert_eq!(settings.method_name, None);
859        assert_eq!(settings.min_amount, Some(Amount::from(0)));
860        assert_eq!(settings.max_amount, Some(Amount::from(10000)));
861
862        match settings.options {
863            Some(MintMethodOptions::Bolt11 { description }) => {
864                assert!(description);
865            }
866            _ => panic!("Expected Bolt11 options with description = true"),
867        }
868
869        // Serialize it back as nested options per NUT-23
870        let serialized = to_string(&settings).unwrap();
871        let parsed: serde_json::Value = from_str(&serialized).unwrap();
872
873        assert_eq!(parsed["options"]["description"], json!(true));
874        assert!(parsed.get("description").is_none());
875    }
876
877    #[test]
878    fn test_mint_method_settings_serializes_false_description_in_options() {
879        let settings = MintMethodSettings {
880            method: PaymentMethod::Known(KnownMethod::Bolt11),
881            unit: CurrencyUnit::Sat,
882            method_name: None,
883            min_amount: None,
884            max_amount: None,
885            options: Some(MintMethodOptions::Bolt11 { description: false }),
886        };
887
888        let serialized = to_string(&settings).unwrap();
889        let parsed: serde_json::Value = from_str(&serialized).unwrap();
890
891        assert_eq!(parsed["method"], json!("bolt11"));
892        assert!(parsed.get("description").is_none());
893        assert_eq!(parsed["options"]["description"], json!(false));
894    }
895
896    #[test]
897    fn test_bolt11_settings_nested_options_round_trip() {
898        let json_str = r#"{
899            "method": "bolt11",
900            "unit": "sat",
901            "min_amount": 0,
902            "max_amount": 10000,
903            "options": {
904                "description": true
905            }
906        }"#;
907
908        let settings: MintMethodSettings = from_str(json_str).unwrap();
909
910        match settings.options {
911            Some(MintMethodOptions::Bolt11 { description }) => {
912                assert!(description);
913            }
914            _ => panic!("Expected Bolt11 options with description = true"),
915        }
916
917        let serialized = to_string(&settings).unwrap();
918        let parsed: serde_json::Value = from_str(&serialized).unwrap();
919
920        assert_eq!(parsed["options"]["description"], json!(true));
921        assert!(parsed.get("description").is_none());
922    }
923
924    #[test]
925    fn test_both_description_locations() {
926        // Create JSON with description in both places (top level and in options)
927        let json_str = r#"{
928            "method": "bolt11",
929            "unit": "sat",
930            "min_amount": 0,
931            "max_amount": 10000,
932            "description": true,
933            "options": {
934                "description": false
935            }
936        }"#;
937
938        // Deserialize it - top level should take precedence
939        let settings: MintMethodSettings = from_str(json_str).unwrap();
940
941        match settings.options {
942            Some(MintMethodOptions::Bolt11 { description }) => {
943                assert!(description, "Top-level description should take precedence");
944            }
945            _ => panic!("Expected Bolt11 options with description = true"),
946        }
947    }
948
949    #[test]
950    fn test_mint_method_settings_method_name_round_trip() {
951        let json_str = r#"{
952            "method": "bolt11",
953            "unit": "sat",
954            "method_name": "Lightning",
955            "min_amount": 0,
956            "max_amount": 10000
957        }"#;
958
959        let settings: MintMethodSettings = from_str(json_str).unwrap();
960
961        assert_eq!(settings.method_name, Some("Lightning".to_string()));
962        assert_eq!(settings.method_name(), "Lightning");
963
964        let serialized = to_string(&settings).unwrap();
965        let parsed: serde_json::Value = from_str(&serialized).unwrap();
966
967        assert_eq!(parsed["method_name"], json!("Lightning"));
968    }
969
970    #[test]
971    fn test_mint_method_settings_null_method_name_deserializes_as_none() {
972        let json_str = r#"{
973            "method": "bolt11",
974            "unit": "sat",
975            "method_name": null
976        }"#;
977
978        let settings: MintMethodSettings = from_str(json_str).unwrap();
979
980        assert_eq!(settings.method_name, None);
981        assert_eq!(settings.method_name(), "Bolt11");
982
983        let serialized = to_string(&settings).unwrap();
984        let parsed: serde_json::Value = from_str(&serialized).unwrap();
985
986        assert!(parsed.get("method_name").is_none());
987    }
988
989    #[test]
990    fn test_mint_method_settings_omitted_method_name_uses_derived_name() {
991        let json_str = r#"{
992            "method": "apple-pay",
993            "unit": "usd"
994        }"#;
995
996        let settings: MintMethodSettings = from_str(json_str).unwrap();
997
998        assert_eq!(settings.method_name, None);
999        assert_eq!(settings.method_name(), "Apple Pay");
1000    }
1001
1002    #[test]
1003    fn custom_mint_quote_response_has_no_typed_state() {
1004        let response = MintQuoteCustomResponse {
1005            quote: "abc123".to_string(),
1006            request: "paypal://pay?id=123".to_string(),
1007            method: PaymentMethod::Custom("paypal".to_string()),
1008            amount: Some(Amount::from(1000)),
1009            amount_paid: Amount::ZERO,
1010            amount_issued: Amount::ZERO,
1011            updated_at: 0,
1012            unit: Some(CurrencyUnit::Sat),
1013            expiry: Some(9999999),
1014            pubkey: None,
1015            extra: serde_json::Value::Null,
1016        };
1017
1018        let serialized = to_string(&response).unwrap();
1019        let parsed: serde_json::Value = from_str(&serialized).unwrap();
1020
1021        assert!(parsed.get("state").is_none());
1022        assert_eq!(parsed["method"], json!("paypal"));
1023        assert_eq!(parsed["amount_paid"], json!(0));
1024        assert_eq!(parsed["amount_issued"], json!(0));
1025    }
1026
1027    #[test]
1028    fn custom_mint_quote_response_flattens_extra_on_wire() {
1029        let response = MintQuoteCustomResponse {
1030            quote: "q1".to_string(),
1031            request: "custom://pay".to_string(),
1032            method: PaymentMethod::Custom("custom".to_string()),
1033            amount: Some(Amount::from(100)),
1034            amount_paid: Amount::ZERO,
1035            amount_issued: Amount::ZERO,
1036            updated_at: 0,
1037            unit: Some(CurrencyUnit::Sat),
1038            expiry: Some(9999),
1039            pubkey: None,
1040            extra: json!({"payment_url": "https://example.com", "ref": 42}),
1041        };
1042
1043        let serialized = to_string(&response).expect("serializes");
1044        let parsed: serde_json::Value = from_str(&serialized).expect("parses");
1045
1046        assert_eq!(parsed["payment_url"], json!("https://example.com"));
1047        assert_eq!(parsed["ref"], json!(42));
1048        assert!(
1049            parsed.get("extra").is_none(),
1050            "extra must be flattened, not nested under an 'extra' key"
1051        );
1052    }
1053
1054    #[test]
1055    fn test_onchain_settings_nested_options_round_trip() {
1056        // NUT-26 spec format: confirmations nested inside "options"
1057        let json_str = r#"{
1058            "method": "onchain",
1059            "unit": "sat",
1060            "min_amount": 1000,
1061            "max_amount": 1000000,
1062            "options": {
1063                "confirmations": 3
1064            }
1065        }"#;
1066
1067        let settings: MintMethodSettings = from_str(json_str).unwrap();
1068
1069        assert_eq!(settings.method, PaymentMethod::Known(KnownMethod::Onchain));
1070        assert_eq!(settings.unit, CurrencyUnit::Sat);
1071        assert_eq!(settings.min_amount, Some(Amount::from(1000)));
1072        assert_eq!(settings.max_amount, Some(Amount::from(1000000)));
1073
1074        match settings.options {
1075            Some(MintMethodOptions::Onchain { confirmations }) => {
1076                assert_eq!(confirmations, 3);
1077            }
1078            _ => panic!("Expected Onchain options with confirmations = 3"),
1079        }
1080
1081        // Serialize it back and verify the nested "options" structure
1082        let serialized = to_string(&settings).unwrap();
1083        let parsed: serde_json::Value = from_str(&serialized).unwrap();
1084
1085        assert_eq!(parsed["method"], json!("onchain"));
1086        assert_eq!(parsed["options"]["confirmations"], json!(3));
1087        // Verify confirmations is NOT at top level
1088        assert!(parsed.get("confirmations").is_none());
1089    }
1090
1091    #[test]
1092    fn test_onchain_settings_top_level_confirmations_rejected() {
1093        let json_str = r#"{
1094            "method": "onchain",
1095            "unit": "sat",
1096            "confirmations": 6
1097        }"#;
1098
1099        let err = from_str::<MintMethodSettings>(json_str).unwrap_err();
1100        assert!(err.to_string().contains("unknown field"));
1101    }
1102
1103    #[test]
1104    fn test_onchain_settings_top_level_and_nested_rejected() {
1105        let json_str = r#"{
1106            "method": "onchain",
1107            "unit": "sat",
1108            "confirmations": 6,
1109            "options": {
1110                "confirmations": 3
1111            }
1112        }"#;
1113
1114        let err = from_str::<MintMethodSettings>(json_str).unwrap_err();
1115        assert!(err.to_string().contains("unknown field"));
1116    }
1117
1118    #[test]
1119    fn test_bolt12_settings_nested_options_round_trip() {
1120        // NUT-25 spec format: description nested inside "options"
1121        let json_str = r#"{
1122            "method": "bolt12",
1123            "unit": "sat",
1124            "min_amount": 0,
1125            "max_amount": 10000,
1126            "options": {
1127                "description": true
1128            }
1129        }"#;
1130
1131        let settings: MintMethodSettings = from_str(json_str).unwrap();
1132
1133        assert_eq!(settings.method, PaymentMethod::Known(KnownMethod::Bolt12));
1134        assert_eq!(settings.unit, CurrencyUnit::Sat);
1135
1136        match settings.options {
1137            Some(MintMethodOptions::Bolt12 { description }) => {
1138                assert!(description);
1139            }
1140            _ => panic!("Expected Bolt12 options with description = true"),
1141        }
1142
1143        // Serialize it back and verify the nested "options" structure
1144        let serialized = to_string(&settings).unwrap();
1145        let parsed: serde_json::Value = from_str(&serialized).unwrap();
1146
1147        assert_eq!(parsed["method"], json!("bolt12"));
1148        assert_eq!(parsed["options"]["description"], json!(true));
1149        // Verify description is NOT at top level
1150        assert!(parsed.get("description").is_none());
1151    }
1152
1153    #[test]
1154    fn test_bolt12_settings_top_level_description_accepted() {
1155        // Some mints place the description flag at the top level; it should be
1156        // mapped to the Bolt12 options based on the method
1157        let json_str = r#"{
1158            "method": "bolt12",
1159            "unit": "sat",
1160            "description": true
1161        }"#;
1162
1163        let settings: MintMethodSettings = from_str(json_str).unwrap();
1164
1165        assert_eq!(settings.method, PaymentMethod::Known(KnownMethod::Bolt12));
1166        match settings.options {
1167            Some(MintMethodOptions::Bolt12 { description }) => {
1168                assert!(description);
1169            }
1170            _ => panic!("Expected Bolt12 options with description = true"),
1171        }
1172    }
1173
1174    #[test]
1175    fn test_bolt12_settings_no_options_when_description_absent() {
1176        let json_str = r#"{
1177            "method": "bolt12",
1178            "unit": "sat"
1179        }"#;
1180
1181        let settings: MintMethodSettings = from_str(json_str).unwrap();
1182
1183        assert_eq!(settings.method, PaymentMethod::Known(KnownMethod::Bolt12));
1184        assert_eq!(settings.options, None);
1185    }
1186
1187    #[test]
1188    fn test_mint_method_settings_visitor_reports_expected_type() {
1189        let err = from_str::<MintMethodSettings>("[]").unwrap_err();
1190
1191        assert!(err.to_string().contains("a MintMethodSettings structure"));
1192    }
1193
1194    #[test]
1195    fn test_get_settings_requires_exact_method_and_unit_match() {
1196        let bolt11_msat = MintMethodSettings {
1197            method: PaymentMethod::Known(KnownMethod::Bolt11),
1198            unit: CurrencyUnit::Msat,
1199            method_name: None,
1200            min_amount: Some(Amount::from(1)),
1201            max_amount: None,
1202            options: None,
1203        };
1204        let bolt12_sat = MintMethodSettings {
1205            method: PaymentMethod::Known(KnownMethod::Bolt12),
1206            unit: CurrencyUnit::Sat,
1207            method_name: None,
1208            min_amount: Some(Amount::from(2)),
1209            max_amount: None,
1210            options: None,
1211        };
1212        let bolt11_sat = MintMethodSettings {
1213            method: PaymentMethod::Known(KnownMethod::Bolt11),
1214            unit: CurrencyUnit::Sat,
1215            method_name: None,
1216            min_amount: Some(Amount::from(3)),
1217            max_amount: None,
1218            options: None,
1219        };
1220        let settings = Settings::new(
1221            vec![bolt11_msat.clone(), bolt12_sat.clone(), bolt11_sat.clone()],
1222            false,
1223        );
1224
1225        assert_eq!(
1226            settings.get_settings(&CurrencyUnit::Sat, &PaymentMethod::BOLT11),
1227            Some(bolt11_sat)
1228        );
1229        assert_eq!(
1230            settings.get_settings(
1231                &CurrencyUnit::Msat,
1232                &PaymentMethod::Known(KnownMethod::Bolt12)
1233            ),
1234            None
1235        );
1236    }
1237
1238    #[test]
1239    fn test_supported_methods_and_units_preserve_configured_values() {
1240        let settings = Settings::new(
1241            vec![
1242                MintMethodSettings {
1243                    method: PaymentMethod::Known(KnownMethod::Bolt11),
1244                    unit: CurrencyUnit::Msat,
1245                    method_name: None,
1246                    min_amount: Some(Amount::from(1)),
1247                    max_amount: None,
1248                    options: None,
1249                },
1250                MintMethodSettings {
1251                    method: PaymentMethod::Known(KnownMethod::Onchain),
1252                    unit: CurrencyUnit::Eur,
1253                    method_name: None,
1254                    min_amount: None,
1255                    max_amount: Some(Amount::from(100)),
1256                    options: Some(MintMethodOptions::Onchain { confirmations: 3 }),
1257                },
1258            ],
1259            false,
1260        );
1261
1262        let methods = settings.supported_methods();
1263        assert_eq!(methods.len(), 2);
1264        assert_eq!(methods[0], &PaymentMethod::Known(KnownMethod::Bolt11));
1265        assert_eq!(methods[1], &PaymentMethod::Known(KnownMethod::Onchain));
1266
1267        let units = settings.supported_units();
1268        assert_eq!(units.len(), 2);
1269        assert_eq!(units[0], &CurrencyUnit::Msat);
1270        assert_eq!(units[1], &CurrencyUnit::Eur);
1271    }
1272
1273    #[test]
1274    fn test_remove_settings_requires_exact_method_and_unit_match() {
1275        let bolt11_msat = MintMethodSettings {
1276            method: PaymentMethod::Known(KnownMethod::Bolt11),
1277            unit: CurrencyUnit::Msat,
1278            method_name: None,
1279            min_amount: Some(Amount::from(1)),
1280            max_amount: None,
1281            options: None,
1282        };
1283        let bolt12_sat = MintMethodSettings {
1284            method: PaymentMethod::Known(KnownMethod::Bolt12),
1285            unit: CurrencyUnit::Sat,
1286            method_name: None,
1287            min_amount: Some(Amount::from(2)),
1288            max_amount: None,
1289            options: None,
1290        };
1291        let bolt11_sat = MintMethodSettings {
1292            method: PaymentMethod::Known(KnownMethod::Bolt11),
1293            unit: CurrencyUnit::Sat,
1294            method_name: None,
1295            min_amount: Some(Amount::from(3)),
1296            max_amount: None,
1297            options: None,
1298        };
1299        let mut settings = Settings::new(
1300            vec![bolt11_msat.clone(), bolt12_sat.clone(), bolt11_sat.clone()],
1301            false,
1302        );
1303
1304        assert_eq!(
1305            settings.remove_settings(&CurrencyUnit::Sat, &PaymentMethod::BOLT11),
1306            Some(bolt11_sat.clone())
1307        );
1308        assert_eq!(settings.methods, vec![bolt11_msat, bolt12_sat]);
1309        assert_eq!(
1310            settings.remove_settings(&CurrencyUnit::Sat, &PaymentMethod::BOLT11),
1311            None
1312        );
1313    }
1314}