Skip to main content

cdk_common/
melt.rs

1//! Unified Melt Quote types for melt use-cases.
2
3use serde::de::DeserializeOwned;
4use serde::{Deserialize, Serialize};
5
6use crate::nuts::nut00::KnownMethod;
7use crate::nuts::nut05::{MeltQuoteCustomRequest, MeltQuoteCustomResponse};
8use crate::nuts::nut23::{MeltQuoteBolt11Request, MeltQuoteBolt11Response};
9use crate::nuts::nut25::{MeltQuoteBolt12Request, MeltQuoteBolt12Response};
10use crate::nuts::nut30::{MeltQuoteOnchainRequest, MeltQuoteOnchainResponse};
11use crate::{Amount, CurrencyUnit, MeltQuoteState, PaymentMethod};
12
13/// Melt quote request enum for different types of quotes
14///
15/// This enum represents the different types of melt quote requests
16/// that can be made, either BOLT11, BOLT12, or Custom.
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub enum MeltQuoteRequest {
19    /// Lightning Network BOLT11 invoice request
20    Bolt11(MeltQuoteBolt11Request),
21    /// Lightning Network BOLT12 offer request
22    Bolt12(MeltQuoteBolt12Request),
23    /// Onchain request
24    Onchain(MeltQuoteOnchainRequest),
25    /// Custom payment method request
26    Custom(MeltQuoteCustomRequest),
27}
28
29impl From<MeltQuoteBolt11Request> for MeltQuoteRequest {
30    fn from(request: MeltQuoteBolt11Request) -> Self {
31        MeltQuoteRequest::Bolt11(request)
32    }
33}
34
35impl From<MeltQuoteBolt12Request> for MeltQuoteRequest {
36    fn from(request: MeltQuoteBolt12Request) -> Self {
37        MeltQuoteRequest::Bolt12(request)
38    }
39}
40
41impl From<MeltQuoteOnchainRequest> for MeltQuoteRequest {
42    fn from(request: MeltQuoteOnchainRequest) -> Self {
43        MeltQuoteRequest::Onchain(request)
44    }
45}
46
47impl From<MeltQuoteCustomRequest> for MeltQuoteRequest {
48    fn from(request: MeltQuoteCustomRequest) -> Self {
49        MeltQuoteRequest::Custom(request)
50    }
51}
52
53impl MeltQuoteRequest {
54    /// Returns the payment method for this request.
55    pub fn method(&self) -> PaymentMethod {
56        match self {
57            Self::Bolt11(_) => PaymentMethod::Known(KnownMethod::Bolt11),
58            Self::Bolt12(_) => PaymentMethod::Known(KnownMethod::Bolt12),
59            Self::Onchain(_) => PaymentMethod::Known(KnownMethod::Onchain),
60            Self::Custom(request) => PaymentMethod::from(request.method.as_str()),
61        }
62    }
63}
64
65/// Unified melt quote response for all payment methods
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(bound = "Q: Serialize + DeserializeOwned")]
68pub enum MeltQuoteResponse<Q> {
69    /// Bolt11 (Lightning invoice)
70    Bolt11(MeltQuoteBolt11Response<Q>),
71    /// Bolt12 (Offers)
72    Bolt12(MeltQuoteBolt12Response<Q>),
73    /// Onchain
74    Onchain(MeltQuoteOnchainResponse<Q>),
75    /// Custom payment method
76    Custom((PaymentMethod, MeltQuoteCustomResponse<Q>)),
77}
78
79/// Melt quote creation response for all payment methods.
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(bound = "Q: Serialize + DeserializeOwned")]
82pub enum MeltQuoteCreateResponse<Q> {
83    /// Bolt11 (Lightning invoice)
84    Bolt11(MeltQuoteBolt11Response<Q>),
85    /// Bolt12 (Offers)
86    Bolt12(MeltQuoteBolt12Response<Q>),
87    /// Onchain
88    Onchain(MeltQuoteOnchainResponse<Q>),
89    /// Custom payment method
90    Custom((PaymentMethod, MeltQuoteCustomResponse<Q>)),
91}
92
93impl<Q> MeltQuoteResponse<Q> {
94    /// Returns the payment method for this response.
95    pub fn method(&self) -> PaymentMethod {
96        match self {
97            Self::Bolt11(_) => PaymentMethod::Known(KnownMethod::Bolt11),
98            Self::Bolt12(_) => PaymentMethod::Known(KnownMethod::Bolt12),
99            Self::Onchain(_) => PaymentMethod::Known(KnownMethod::Onchain),
100            Self::Custom((method, _)) => method.clone(),
101        }
102    }
103}
104
105impl<Q: ToString> MeltQuoteResponse<Q> {
106    /// Convert the MeltQuoteResponse with a quote type Q to a String
107    pub fn to_string_id(self) -> MeltQuoteResponse<String> {
108        match self {
109            Self::Bolt11(r) => MeltQuoteResponse::Bolt11(r.to_string_id()),
110            Self::Bolt12(r) => MeltQuoteResponse::Bolt12(r.to_string_id()),
111            Self::Onchain(r) => MeltQuoteResponse::Onchain(r.to_string_id()),
112            Self::Custom((method, r)) => MeltQuoteResponse::Custom((method, r.to_string_id())),
113        }
114    }
115
116    /// Returns the quote ID.
117    pub fn quote(&self) -> &Q {
118        match self {
119            Self::Bolt11(r) => &r.quote,
120            Self::Bolt12(r) => &r.quote,
121            Self::Onchain(r) => &r.quote,
122            Self::Custom((_, r)) => &r.quote,
123        }
124    }
125
126    /// Returns the quoted amount.
127    pub fn amount(&self) -> Amount {
128        match self {
129            Self::Bolt11(r) => r.amount,
130            Self::Bolt12(r) => r.amount,
131            Self::Onchain(r) => r.amount,
132            Self::Custom((_, r)) => r.amount,
133        }
134    }
135
136    /// Returns the fee reserve.
137    pub fn fee_reserve(&self) -> Amount {
138        match self {
139            Self::Bolt11(r) => r.fee_reserve,
140            Self::Bolt12(r) => r.fee_reserve,
141            Self::Onchain(r) => r
142                .selected_fee_index
143                .and_then(|selected| {
144                    r.fee_options
145                        .iter()
146                        .find(|option| option.fee_index == selected)
147                })
148                .or_else(|| r.fee_options.first())
149                .map(|option| option.fee_reserve)
150                .unwrap_or(Amount::ZERO),
151            Self::Custom((_, r)) => r.fee_reserve.unwrap_or_default(),
152        }
153    }
154
155    /// Returns the quote state.
156    pub fn state(&self) -> MeltQuoteState {
157        match self {
158            Self::Bolt11(r) => r.state,
159            Self::Bolt12(r) => r.state,
160            Self::Onchain(r) => r.state,
161            Self::Custom((_, r)) => r.state,
162        }
163    }
164
165    /// Returns the quote expiry timestamp.
166    pub fn expiry(&self) -> u64 {
167        match self {
168            Self::Bolt11(r) => r.expiry,
169            Self::Bolt12(r) => r.expiry,
170            Self::Onchain(r) => r.expiry,
171            Self::Custom((_, r)) => r.expiry,
172        }
173    }
174
175    /// Returns the payment proof.
176    ///
177    /// For Bolt11/Bolt12/Custom methods this is the Lightning payment
178    /// preimage. For Onchain, the "proof" is the broadcast outpoint
179    /// (`txid:vout`) — it plays the same role of a canonical,
180    /// method-specific artifact proving the mint executed the payment.
181    /// Callers inspecting `payment_proof` to decide whether an irreversible
182    /// settlement has occurred can treat Onchain uniformly with the other
183    /// methods.
184    pub fn payment_proof(&self) -> Option<&str> {
185        match self {
186            Self::Bolt11(r) => r.payment_preimage.as_deref(),
187            Self::Bolt12(r) => r.payment_preimage.as_deref(),
188            Self::Onchain(r) => r.outpoint.as_deref(),
189            Self::Custom((_, r)) => r.payment_preimage.as_deref(),
190        }
191    }
192
193    /// Returns the change signatures when present.
194    pub fn change(&self) -> Option<&Vec<crate::BlindSignature>> {
195        match self {
196            Self::Bolt11(r) => r.change.as_ref(),
197            Self::Bolt12(r) => r.change.as_ref(),
198            Self::Onchain(r) => r.change.as_ref(),
199            Self::Custom((_, r)) => r.change.as_ref(),
200        }
201    }
202
203    /// Returns the payment request string when present.
204    pub fn request(&self) -> Option<&str> {
205        match self {
206            Self::Bolt11(r) => r.request.as_deref(),
207            Self::Bolt12(r) => r.request.as_deref(),
208            Self::Onchain(r) => Some(r.request.as_str()),
209            Self::Custom((_, r)) => r.request.as_deref(),
210        }
211    }
212
213    /// Returns the unit when present.
214    pub fn unit(&self) -> Option<CurrencyUnit> {
215        match self {
216            Self::Bolt11(r) => r.unit.clone(),
217            Self::Bolt12(r) => r.unit.clone(),
218            Self::Onchain(r) => Some(r.unit.clone()),
219            Self::Custom((_, r)) => r.unit.clone(),
220        }
221    }
222}
223
224#[cfg(feature = "mint")]
225impl From<MeltQuoteResponse<crate::QuoteId>> for MeltQuoteResponse<String> {
226    fn from(value: MeltQuoteResponse<crate::QuoteId>) -> Self {
227        value.to_string_id()
228    }
229}
230
231impl<Q: ToString> MeltQuoteCreateResponse<Q> {
232    /// Convert the MeltQuoteCreateResponse with a quote type Q to a String
233    pub fn to_string_id(self) -> MeltQuoteCreateResponse<String> {
234        match self {
235            Self::Bolt11(r) => MeltQuoteCreateResponse::Bolt11(r.to_string_id()),
236            Self::Bolt12(r) => MeltQuoteCreateResponse::Bolt12(r.to_string_id()),
237            Self::Onchain(r) => MeltQuoteCreateResponse::Onchain(r.to_string_id()),
238            Self::Custom((method, r)) => {
239                MeltQuoteCreateResponse::Custom((method, r.to_string_id()))
240            }
241        }
242    }
243
244    /// Returns the payment method for this response.
245    pub fn method(&self) -> PaymentMethod {
246        match self {
247            Self::Bolt11(_) => PaymentMethod::Known(KnownMethod::Bolt11),
248            Self::Bolt12(_) => PaymentMethod::Known(KnownMethod::Bolt12),
249            Self::Onchain(_) => PaymentMethod::Known(KnownMethod::Onchain),
250            Self::Custom((method, _)) => method.clone(),
251        }
252    }
253
254    /// Returns the quote ID for single-quote methods.
255    pub fn quote(&self) -> Option<&Q> {
256        match self {
257            Self::Bolt11(r) => Some(&r.quote),
258            Self::Bolt12(r) => Some(&r.quote),
259            Self::Onchain(r) => Some(&r.quote),
260            Self::Custom((_, r)) => Some(&r.quote),
261        }
262    }
263}
264
265#[cfg(feature = "mint")]
266impl From<MeltQuoteCreateResponse<crate::QuoteId>> for MeltQuoteCreateResponse<String> {
267    fn from(value: MeltQuoteCreateResponse<crate::QuoteId>) -> Self {
268        value.to_string_id()
269    }
270}
271
272#[cfg(feature = "mint")]
273impl<Q> From<crate::mint::MeltQuote> for MeltQuoteResponse<Q>
274where
275    Q: From<crate::QuoteId>,
276{
277    fn from(value: crate::mint::MeltQuote) -> Self {
278        match value.payment_method {
279            PaymentMethod::Known(KnownMethod::Bolt11) => {
280                Self::Bolt11(crate::nuts::nut23::MeltQuoteBolt11Response {
281                    quote: value.id.clone().into(),
282                    amount: value.amount().into(),
283                    fee_reserve: value.fee_reserve().into(),
284                    state: value.state,
285                    expiry: value.expiry,
286                    payment_preimage: value.payment_proof.clone(),
287                    change: None,
288                    request: Some(value.request.to_string()),
289                    unit: Some(value.unit.clone()),
290                })
291            }
292            PaymentMethod::Known(KnownMethod::Bolt12) => {
293                Self::Bolt12(crate::nuts::nut25::MeltQuoteBolt12Response {
294                    quote: value.id.clone().into(),
295                    amount: value.amount().into(),
296                    fee_reserve: value.fee_reserve().into(),
297                    state: value.state,
298                    expiry: value.expiry,
299                    payment_preimage: value.payment_proof.clone(),
300                    change: None,
301                    request: Some(value.request.to_string()),
302                    unit: Some(value.unit.clone()),
303                })
304            }
305            PaymentMethod::Known(KnownMethod::Onchain) => {
306                Self::Onchain(crate::nuts::nut30::MeltQuoteOnchainResponse {
307                    quote: value.id.clone().into(),
308                    amount: value.amount().into(),
309                    unit: value.unit.clone(),
310                    state: value.state,
311                    expiry: value.expiry,
312                    request: value.request.to_string(),
313                    fee_options: value.fee_options().to_vec(),
314                    selected_fee_index: value.selected_fee_index,
315                    outpoint: value.payment_proof.clone(),
316                    change: None,
317                })
318            }
319            ref method => Self::Custom((
320                method.clone(),
321                crate::nuts::nut05::MeltQuoteCustomResponse {
322                    quote: value.id.clone().into(),
323                    amount: value.amount().into(),
324                    fee_reserve: Some(value.fee_reserve().into()),
325                    state: value.state,
326                    expiry: value.expiry,
327                    payment_preimage: value.payment_proof.clone(),
328                    change: None,
329                    request: Some(value.request.to_string()),
330                    unit: Some(value.unit.clone()),
331                    extra: serde_json::Value::Null,
332                },
333            )),
334        }
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341    use crate::nuts::nut05::MeltQuoteCustomResponse;
342    use crate::nuts::nut23::MeltQuoteBolt11Response;
343    use crate::nuts::nut30::{MeltQuoteOnchainFeeOption, MeltQuoteOnchainResponse};
344    use crate::{Amount, CurrencyUnit, MeltQuoteState};
345
346    fn bolt11_response(quote: &str) -> MeltQuoteBolt11Response<String> {
347        MeltQuoteBolt11Response {
348            quote: quote.to_string(),
349            amount: Amount::from(100),
350            fee_reserve: Amount::from(1),
351            state: MeltQuoteState::Unpaid,
352            expiry: 1000,
353            payment_preimage: Some("preimage-11".to_string()),
354            change: None,
355            request: Some("lnbc100".to_string()),
356            unit: Some(CurrencyUnit::Sat),
357        }
358    }
359
360    fn bolt12_response(quote: &str) -> MeltQuoteBolt12Response<String> {
361        MeltQuoteBolt12Response {
362            quote: quote.to_string(),
363            amount: Amount::from(200),
364            fee_reserve: Amount::from(2),
365            state: MeltQuoteState::Pending,
366            expiry: 2000,
367            payment_preimage: Some("preimage-12".to_string()),
368            change: None,
369            request: Some("lno200".to_string()),
370            unit: Some(CurrencyUnit::Sat),
371        }
372    }
373
374    fn onchain_response(quote: &str) -> MeltQuoteOnchainResponse<String> {
375        MeltQuoteOnchainResponse {
376            quote: quote.to_string(),
377            amount: Amount::from(400),
378            unit: CurrencyUnit::Sat,
379            state: MeltQuoteState::Paid,
380            expiry: 4000,
381            request: "bc1qonchainaddress".to_string(),
382            fee_options: vec![MeltQuoteOnchainFeeOption {
383                fee_index: 0,
384                fee_reserve: Amount::from(4),
385                estimated_blocks: 6,
386            }],
387            selected_fee_index: Some(0),
388            outpoint: Some("abcd...ef:0".to_string()),
389            change: None,
390        }
391    }
392
393    fn custom_response(quote: &str) -> MeltQuoteCustomResponse<String> {
394        MeltQuoteCustomResponse {
395            quote: quote.to_string(),
396            amount: Amount::from(300),
397            fee_reserve: Some(Amount::from(3)),
398            state: MeltQuoteState::Paid,
399            expiry: 3000,
400            payment_preimage: Some("outpoint-abc".to_string()),
401            change: None,
402            request: Some("bc1qaddress".to_string()),
403            unit: Some(CurrencyUnit::Sat),
404            extra: serde_json::Value::Null,
405        }
406    }
407
408    #[test]
409    fn melt_quote_response_accessors_bolt11() {
410        let r = MeltQuoteResponse::Bolt11(bolt11_response("q11"));
411        assert_eq!(r.method(), PaymentMethod::Known(KnownMethod::Bolt11));
412        assert_eq!(r.quote(), "q11");
413        assert_eq!(r.amount(), Amount::from(100));
414        assert_eq!(r.fee_reserve(), Amount::from(1));
415        assert_eq!(r.state(), MeltQuoteState::Unpaid);
416        assert_eq!(r.expiry(), 1000);
417        assert_eq!(r.payment_proof(), Some("preimage-11"));
418        assert!(r.change().is_none());
419        assert_eq!(r.request(), Some("lnbc100"));
420        assert_eq!(r.unit(), Some(CurrencyUnit::Sat));
421    }
422
423    #[test]
424    fn melt_quote_response_accessors_bolt12() {
425        let r = MeltQuoteResponse::Bolt12(bolt12_response("q12"));
426        assert_eq!(r.method(), PaymentMethod::Known(KnownMethod::Bolt12));
427        assert_eq!(r.quote(), "q12");
428        assert_eq!(r.amount(), Amount::from(200));
429        assert_eq!(r.fee_reserve(), Amount::from(2));
430        assert_eq!(r.state(), MeltQuoteState::Pending);
431        assert_eq!(r.expiry(), 2000);
432        assert_eq!(r.payment_proof(), Some("preimage-12"));
433        assert_eq!(r.request(), Some("lno200"));
434    }
435
436    #[test]
437    fn melt_quote_response_accessors_onchain() {
438        let r = MeltQuoteResponse::Onchain(onchain_response("qoc"));
439        assert_eq!(r.method(), PaymentMethod::Known(KnownMethod::Onchain));
440        assert_eq!(r.quote(), "qoc");
441        assert_eq!(r.amount(), Amount::from(400));
442        assert_eq!(r.fee_reserve(), Amount::from(4));
443        assert_eq!(r.state(), MeltQuoteState::Paid);
444        assert_eq!(r.expiry(), 4000);
445        // payment_proof() is the outpoint, not a Lightning preimage
446        assert_eq!(r.payment_proof(), Some("abcd...ef:0"));
447        assert!(r.change().is_none());
448        // `request` is non-Option on onchain; accessor wraps in Some
449        assert_eq!(r.request(), Some("bc1qonchainaddress"));
450        // `unit` is non-Option on onchain; accessor wraps in Some
451        assert_eq!(r.unit(), Some(CurrencyUnit::Sat));
452    }
453
454    #[test]
455    fn melt_quote_response_accessors_custom() {
456        let method = PaymentMethod::from("paypal");
457        let r = MeltQuoteResponse::Custom((method.clone(), custom_response("qc")));
458        assert_eq!(r.method(), method);
459        assert_eq!(r.quote(), "qc");
460        assert_eq!(r.amount(), Amount::from(300));
461        assert_eq!(r.fee_reserve(), Amount::from(3));
462        assert_eq!(r.state(), MeltQuoteState::Paid);
463        assert_eq!(r.expiry(), 3000);
464        assert_eq!(r.payment_proof(), Some("outpoint-abc"));
465        assert_eq!(r.request(), Some("bc1qaddress"));
466    }
467
468    #[test]
469    fn melt_quote_create_response_accessors() {
470        let r11 = MeltQuoteCreateResponse::Bolt11(bolt11_response("c11"));
471        assert_eq!(r11.method(), PaymentMethod::Known(KnownMethod::Bolt11));
472        assert_eq!(r11.quote().map(String::as_str), Some("c11"));
473
474        let r12 = MeltQuoteCreateResponse::Bolt12(bolt12_response("c12"));
475        assert_eq!(r12.method(), PaymentMethod::Known(KnownMethod::Bolt12));
476        assert_eq!(r12.quote().map(String::as_str), Some("c12"));
477
478        let method = PaymentMethod::from("venmo");
479        let rc = MeltQuoteCreateResponse::Custom((method.clone(), custom_response("cc")));
480        assert_eq!(rc.method(), method);
481        assert_eq!(rc.quote().map(String::as_str), Some("cc"));
482    }
483
484    #[test]
485    fn melt_quote_request_method_dispatch() {
486        use crate::nuts::nut05::MeltQuoteCustomRequest;
487
488        let custom_req = MeltQuoteCustomRequest {
489            method: "cashapp".to_string(),
490            unit: CurrencyUnit::Sat,
491            request: "$tag".to_string(),
492            extra: serde_json::Value::Null,
493        };
494        let req: MeltQuoteRequest = custom_req.into();
495        assert_eq!(req.method(), PaymentMethod::from("cashapp"));
496    }
497}