Skip to main content

cdk_common/
mint_quote.rs

1//! Unified Mint Quote types for mint use-cases.
2
3use serde::de::DeserializeOwned;
4use serde::{Deserialize, Serialize};
5
6use crate::nuts::nut00::KnownMethod;
7use crate::nuts::nut04::{MintQuoteCustomRequest, MintQuoteCustomResponse};
8use crate::nuts::nut23::{MintQuoteBolt11Request, MintQuoteBolt11Response, QuoteState};
9use crate::nuts::nut25::{MintQuoteBolt12Request, MintQuoteBolt12Response};
10use crate::nuts::nut30::{MintQuoteOnchainRequest, MintQuoteOnchainResponse};
11use crate::{Amount, CurrencyUnit, PaymentMethod, PublicKey};
12
13/// Unified mint quote request for all payment methods
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub enum MintQuoteRequest {
16    /// Bolt11 (Lightning invoice)
17    Bolt11(MintQuoteBolt11Request),
18    /// Bolt12 (Offers)
19    Bolt12(MintQuoteBolt12Request),
20    /// Onchain
21    Onchain(MintQuoteOnchainRequest),
22    /// Custom payment method
23    Custom {
24        /// Payment method identifier
25        method: PaymentMethod,
26        /// Payment method specific request
27        request: MintQuoteCustomRequest,
28    },
29}
30
31impl From<MintQuoteBolt11Request> for MintQuoteRequest {
32    fn from(request: MintQuoteBolt11Request) -> Self {
33        MintQuoteRequest::Bolt11(request)
34    }
35}
36
37impl From<MintQuoteBolt12Request> for MintQuoteRequest {
38    fn from(request: MintQuoteBolt12Request) -> Self {
39        MintQuoteRequest::Bolt12(request)
40    }
41}
42
43impl From<MintQuoteOnchainRequest> for MintQuoteRequest {
44    fn from(request: MintQuoteOnchainRequest) -> Self {
45        MintQuoteRequest::Onchain(request)
46    }
47}
48
49impl MintQuoteRequest {
50    /// Returns the payment method for this request.
51    pub fn method(&self) -> PaymentMethod {
52        match self {
53            Self::Bolt11(_) => PaymentMethod::Known(KnownMethod::Bolt11),
54            Self::Bolt12(_) => PaymentMethod::Known(KnownMethod::Bolt12),
55            Self::Onchain(_) => PaymentMethod::Known(KnownMethod::Onchain),
56            Self::Custom { method, .. } => method.clone(),
57        }
58    }
59
60    /// Returns the amount for this request when present.
61    pub fn amount(&self) -> Option<Amount> {
62        match self {
63            Self::Bolt11(request) => Some(request.amount),
64            Self::Bolt12(request) => request.amount,
65            Self::Onchain(_) => None,
66            Self::Custom { request, .. } => request.amount,
67        }
68    }
69
70    /// Returns the unit for this request.
71    pub fn unit(&self) -> CurrencyUnit {
72        match self {
73            Self::Bolt11(request) => request.unit.clone(),
74            Self::Bolt12(request) => request.unit.clone(),
75            Self::Onchain(request) => request.unit.clone(),
76            Self::Custom { request, .. } => request.unit.clone(),
77        }
78    }
79
80    /// Returns the payment method for this request.
81    pub fn payment_method(&self) -> PaymentMethod {
82        self.method()
83    }
84
85    /// Returns the pubkey for this request when present.
86    pub fn pubkey(&self) -> Option<PublicKey> {
87        match self {
88            Self::Bolt11(request) => request.pubkey,
89            Self::Bolt12(request) => Some(request.pubkey),
90            Self::Onchain(request) => Some(request.pubkey),
91            Self::Custom { request, .. } => request.pubkey,
92        }
93    }
94}
95
96/// Unified mint quote response for all payment methods
97#[derive(Debug, Clone, Serialize, Deserialize)]
98#[serde(bound = "Q: Serialize + DeserializeOwned")]
99pub enum MintQuoteResponse<Q> {
100    /// Bolt11 (Lightning invoice)
101    Bolt11(MintQuoteBolt11Response<Q>),
102    /// Bolt12 (Offers)
103    Bolt12(MintQuoteBolt12Response<Q>),
104    /// Onchain
105    Onchain(MintQuoteOnchainResponse<Q>),
106    /// Custom payment method
107    Custom {
108        /// Payment method identifier
109        method: PaymentMethod,
110        /// Payment method specific response
111        response: MintQuoteCustomResponse<Q>,
112    },
113}
114
115/// Errors from mint quote accounting validation.
116#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
117pub enum MintQuoteAccountingError {
118    /// The response reports more issued ecash than paid amount.
119    #[error("mint quote amount_issued ({amount_issued}) exceeds amount_paid ({amount_paid})")]
120    AmountIssuedExceedsAmountPaid {
121        /// Amount paid to the mint.
122        amount_paid: Amount,
123        /// Amount of ecash issued by the mint.
124        amount_issued: Amount,
125    },
126}
127
128impl<Q> MintQuoteResponse<Q> {
129    /// Returns the payment method for this response.
130    pub fn method(&self) -> PaymentMethod {
131        match self {
132            Self::Bolt11(_) => PaymentMethod::Known(KnownMethod::Bolt11),
133            Self::Bolt12(_) => PaymentMethod::Known(KnownMethod::Bolt12),
134            Self::Onchain(_) => PaymentMethod::Known(KnownMethod::Onchain),
135            Self::Custom { method, .. } => method.clone(),
136        }
137    }
138
139    /// Returns the quote ID.
140    pub fn quote(&self) -> &Q {
141        match self {
142            Self::Bolt11(r) => &r.quote,
143            Self::Bolt12(r) => &r.quote,
144            Self::Onchain(r) => &r.quote,
145            Self::Custom { response: r, .. } => &r.quote,
146        }
147    }
148
149    /// Returns the payment request string.
150    pub fn request(&self) -> &str {
151        match self {
152            Self::Bolt11(r) => &r.request,
153            Self::Bolt12(r) => &r.request,
154            Self::Onchain(r) => &r.request,
155            Self::Custom { response: r, .. } => &r.request,
156        }
157    }
158
159    /// Returns the quote state derived from the response data.
160    pub fn state(&self) -> Option<QuoteState> {
161        self.try_state().ok()
162    }
163
164    /// Returns the quote state derived from the response data, validating quote accounting.
165    pub fn try_state(&self) -> Result<QuoteState, MintQuoteAccountingError> {
166        match self {
167            Self::Bolt11(r) => {
168                if r.amount_paid > Amount::ZERO || r.amount_issued > Amount::ZERO {
169                    quote_state_from_amounts(r.amount_paid, r.amount_issued)
170                } else {
171                    Ok(r.state)
172                }
173            }
174            Self::Bolt12(r) => quote_state_from_amounts(r.amount_paid, r.amount_issued),
175            Self::Onchain(r) => quote_state_from_amounts(r.amount_paid, r.amount_issued),
176            Self::Custom { response, .. } => {
177                quote_state_from_amounts(response.amount_paid, response.amount_issued)
178            }
179        }
180    }
181
182    /// Returns the quote expiry timestamp.
183    pub fn expiry(&self) -> Option<u64> {
184        match self {
185            Self::Bolt11(r) => r.expiry,
186            Self::Bolt12(r) => r.expiry,
187            Self::Onchain(r) => r.expiry,
188            Self::Custom { response: r, .. } => r.expiry,
189        }
190    }
191}
192
193/// Derive the deprecated single-use mint quote state from canonical quote counters.
194pub fn quote_state_from_amounts(
195    amount_paid: Amount,
196    amount_issued: Amount,
197) -> Result<QuoteState, MintQuoteAccountingError> {
198    if amount_issued > amount_paid {
199        return Err(MintQuoteAccountingError::AmountIssuedExceedsAmountPaid {
200            amount_paid,
201            amount_issued,
202        });
203    }
204
205    if amount_paid == Amount::ZERO && amount_issued == Amount::ZERO {
206        return Ok(QuoteState::Unpaid);
207    }
208
209    if amount_paid == amount_issued {
210        return Ok(QuoteState::Issued);
211    }
212
213    Ok(QuoteState::Paid)
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    fn custom_response(amount_paid: Amount, amount_issued: Amount) -> MintQuoteResponse<String> {
221        MintQuoteResponse::Custom {
222            method: PaymentMethod::Custom("custom".to_string()),
223            response: MintQuoteCustomResponse {
224                quote: "quote".to_string(),
225                request: "custom-request".to_string(),
226                method: PaymentMethod::Custom("custom".to_string()),
227                amount: Some(Amount::from(100)),
228                amount_paid,
229                amount_issued,
230                updated_at: 0,
231                unit: Some(CurrencyUnit::Sat),
232                expiry: None,
233                pubkey: None,
234                extra: serde_json::Value::Null,
235            },
236        }
237    }
238
239    #[test]
240    fn custom_state_is_derived_from_amount_counters() {
241        assert_eq!(
242            custom_response(Amount::ZERO, Amount::ZERO).state(),
243            Some(QuoteState::Unpaid)
244        );
245        assert_eq!(
246            custom_response(Amount::from(100), Amount::ZERO).state(),
247            Some(QuoteState::Paid)
248        );
249        assert_eq!(
250            custom_response(Amount::from(100), Amount::from(100)).state(),
251            Some(QuoteState::Issued)
252        );
253        assert_eq!(
254            custom_response(Amount::from(50), Amount::from(100)).state(),
255            None
256        );
257        assert!(matches!(
258            custom_response(Amount::from(50), Amount::from(100)).try_state(),
259            Err(MintQuoteAccountingError::AmountIssuedExceedsAmountPaid { .. })
260        ));
261    }
262
263    #[test]
264    fn bolt12_state_uses_unissued_amount() {
265        let response = MintQuoteResponse::Bolt12(MintQuoteBolt12Response {
266            quote: "quote".to_string(),
267            request: "bolt12-request".to_string(),
268            amount: Some(Amount::from(100)),
269            unit: CurrencyUnit::Sat,
270            method: PaymentMethod::Known(KnownMethod::Bolt12),
271            expiry: None,
272            pubkey: PublicKey::from_hex(
273                "02a8cda4cf448bfce9a9e46e588c06ea1780fcb94e3bbdf3277f42995d403a8b0c",
274            )
275            .expect("valid public key"),
276            amount_paid: Amount::from(100),
277            amount_issued: Amount::from(40),
278            updated_at: 0,
279        });
280
281        assert_eq!(response.state(), Some(QuoteState::Paid));
282    }
283}