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;
6use std::str::FromStr;
7
8use serde::de::DeserializeOwned;
9use serde::{Deserialize, Serialize};
10use thiserror::Error;
11#[cfg(feature = "mint")]
12use uuid::Uuid;
13
14use super::nut00::{BlindSignature, BlindedMessage, CurrencyUnit, PaymentMethod};
15use super::{MintQuoteState, PublicKey};
16use crate::Amount;
17
18/// NUT04 Error
19#[derive(Debug, Error)]
20pub enum Error {
21    /// Unknown Quote State
22    #[error("Unknown Quote State")]
23    UnknownState,
24    /// Amount overflow
25    #[error("Amount overflow")]
26    AmountOverflow,
27}
28
29/// Mint quote request [NUT-04]
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
32pub struct MintQuoteBolt11Request {
33    /// Amount
34    pub amount: Amount,
35    /// Unit wallet would like to pay with
36    pub unit: CurrencyUnit,
37    /// Memo to create the invoice with
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub description: Option<String>,
40    /// NUT-19 Pubkey
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub pubkey: Option<PublicKey>,
43}
44
45/// Possible states of a quote
46#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Default, Serialize, Deserialize)]
47#[serde(rename_all = "UPPERCASE")]
48#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema), schema(as = MintQuoteState))]
49pub enum QuoteState {
50    /// Quote has not been paid
51    #[default]
52    Unpaid,
53    /// Quote has been paid and wallet can mint
54    Paid,
55    /// Minting is in progress
56    /// **Note:** This state is to be used internally but is not part of the
57    /// nut.
58    Pending,
59    /// ecash issued for quote
60    Issued,
61}
62
63impl fmt::Display for QuoteState {
64    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
65        match self {
66            Self::Unpaid => write!(f, "UNPAID"),
67            Self::Paid => write!(f, "PAID"),
68            Self::Pending => write!(f, "PENDING"),
69            Self::Issued => write!(f, "ISSUED"),
70        }
71    }
72}
73
74impl FromStr for QuoteState {
75    type Err = Error;
76
77    fn from_str(state: &str) -> Result<Self, Self::Err> {
78        match state {
79            "PENDING" => Ok(Self::Pending),
80            "PAID" => Ok(Self::Paid),
81            "UNPAID" => Ok(Self::Unpaid),
82            "ISSUED" => Ok(Self::Issued),
83            _ => Err(Error::UnknownState),
84        }
85    }
86}
87
88/// Mint quote response [NUT-04]
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
91#[serde(bound = "Q: Serialize + DeserializeOwned")]
92pub struct MintQuoteBolt11Response<Q> {
93    /// Quote Id
94    pub quote: Q,
95    /// Payment request to fulfil
96    pub request: String,
97    /// Amount
98    // REVIEW: This is now required in the spec, we should remove the option once all mints update
99    pub amount: Option<Amount>,
100    /// Unit
101    // REVIEW: This is now required in the spec, we should remove the option once all mints update
102    pub unit: Option<CurrencyUnit>,
103    /// Quote State
104    pub state: MintQuoteState,
105    /// Unix timestamp until the quote is valid
106    pub expiry: Option<u64>,
107    /// NUT-19 Pubkey
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub pubkey: Option<PublicKey>,
110}
111
112impl<Q: ToString> MintQuoteBolt11Response<Q> {
113    /// Convert the MintQuote with a quote type Q to a String
114    pub fn to_string_id(&self) -> MintQuoteBolt11Response<String> {
115        MintQuoteBolt11Response {
116            quote: self.quote.to_string(),
117            request: self.request.clone(),
118            state: self.state,
119            expiry: self.expiry,
120            pubkey: self.pubkey,
121            amount: self.amount,
122            unit: self.unit.clone(),
123        }
124    }
125}
126
127#[cfg(feature = "mint")]
128impl From<MintQuoteBolt11Response<Uuid>> for MintQuoteBolt11Response<String> {
129    fn from(value: MintQuoteBolt11Response<Uuid>) -> Self {
130        Self {
131            quote: value.quote.to_string(),
132            request: value.request,
133            state: value.state,
134            expiry: value.expiry,
135            pubkey: value.pubkey,
136            amount: value.amount,
137            unit: value.unit.clone(),
138        }
139    }
140}
141
142/// Mint request [NUT-04]
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
145#[serde(bound = "Q: Serialize + DeserializeOwned")]
146pub struct MintBolt11Request<Q> {
147    /// Quote id
148    #[cfg_attr(feature = "swagger", schema(max_length = 1_000))]
149    pub quote: Q,
150    /// Outputs
151    #[cfg_attr(feature = "swagger", schema(max_items = 1_000))]
152    pub outputs: Vec<BlindedMessage>,
153    /// Signature
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub signature: Option<String>,
156}
157
158#[cfg(feature = "mint")]
159impl TryFrom<MintBolt11Request<String>> for MintBolt11Request<Uuid> {
160    type Error = uuid::Error;
161
162    fn try_from(value: MintBolt11Request<String>) -> Result<Self, Self::Error> {
163        Ok(Self {
164            quote: Uuid::from_str(&value.quote)?,
165            outputs: value.outputs,
166            signature: value.signature,
167        })
168    }
169}
170
171impl<Q> MintBolt11Request<Q> {
172    /// Total [`Amount`] of outputs
173    pub fn total_amount(&self) -> Result<Amount, Error> {
174        Amount::try_sum(
175            self.outputs
176                .iter()
177                .map(|BlindedMessage { amount, .. }| *amount),
178        )
179        .map_err(|_| Error::AmountOverflow)
180    }
181}
182
183/// Mint response [NUT-04]
184#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
185#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
186pub struct MintBolt11Response {
187    /// Blinded Signatures
188    pub signatures: Vec<BlindSignature>,
189}
190
191/// Mint Method Settings
192#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
193#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
194pub struct MintMethodSettings {
195    /// Payment Method e.g. bolt11
196    pub method: PaymentMethod,
197    /// Currency Unit e.g. sat
198    pub unit: CurrencyUnit,
199    /// Min Amount
200    #[serde(skip_serializing_if = "Option::is_none")]
201    pub min_amount: Option<Amount>,
202    /// Max Amount
203    #[serde(skip_serializing_if = "Option::is_none")]
204    pub max_amount: Option<Amount>,
205    /// Quote Description
206    #[serde(default)]
207    pub description: bool,
208}
209
210/// Mint Settings
211#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
212#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema), schema(as = nut04::Settings))]
213pub struct Settings {
214    /// Methods to mint
215    pub methods: Vec<MintMethodSettings>,
216    /// Minting disabled
217    pub disabled: bool,
218}
219
220impl Settings {
221    /// Create new [`Settings`]
222    pub fn new(methods: Vec<MintMethodSettings>, disabled: bool) -> Self {
223        Self { methods, disabled }
224    }
225
226    /// Get [`MintMethodSettings`] for unit method pair
227    pub fn get_settings(
228        &self,
229        unit: &CurrencyUnit,
230        method: &PaymentMethod,
231    ) -> Option<MintMethodSettings> {
232        for method_settings in self.methods.iter() {
233            if method_settings.method.eq(method) && method_settings.unit.eq(unit) {
234                return Some(method_settings.clone());
235            }
236        }
237
238        None
239    }
240
241    /// Remove [`MintMethodSettings`] for unit method pair
242    pub fn remove_settings(
243        &mut self,
244        unit: &CurrencyUnit,
245        method: &PaymentMethod,
246    ) -> Option<MintMethodSettings> {
247        self.methods
248            .iter()
249            .position(|settings| &settings.method == method && &settings.unit == unit)
250            .map(|index| self.methods.remove(index))
251    }
252}