Skip to main content

cashu/nuts/
nut30.rs

1//! NUT-30 onchain payment method
2
3use serde::de::DeserializeOwned;
4use serde::{Deserialize, Serialize};
5
6use super::nut00::{BlindSignature, BlindedMessage, CurrencyUnit, KnownMethod, PaymentMethod};
7use super::nut01::PublicKey;
8use super::nut05::MeltRequest;
9use super::MeltQuoteState;
10#[cfg(feature = "mint")]
11use crate::quote_id::QuoteId;
12use crate::util::serde_helpers::deserialize_empty_string_as_none;
13use crate::{Amount, Proofs};
14
15fn default_onchain_method() -> PaymentMethod {
16    PaymentMethod::Known(KnownMethod::Onchain)
17}
18
19/// Mint quote onchain request
20///
21/// Request for an onchain mint quote. Requires a pubkey (NUT-20).
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23pub struct MintQuoteOnchainRequest {
24    /// Unit wallet would like to mint
25    pub unit: CurrencyUnit,
26    /// NUT-20 Pubkey (required)
27    pub pubkey: PublicKey,
28}
29
30/// Mint quote onchain response
31///
32/// Response containing the onchain quote details.
33///
34/// Unknown fields are accepted to preserve forward compatibility when mints
35/// add optional onchain extensions.
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(bound = "Q: Serialize + DeserializeOwned")]
38pub struct MintQuoteOnchainResponse<Q> {
39    /// Quote Id
40    pub quote: Q,
41    /// Bitcoin address to send funds to
42    pub request: String,
43    /// Unit
44    pub unit: CurrencyUnit,
45    /// Payment method
46    #[serde(default = "default_onchain_method")]
47    pub method: PaymentMethod,
48    /// Unix timestamp until the quote is valid
49    pub expiry: Option<u64>,
50    /// NUT-20 Pubkey from the request
51    pub pubkey: PublicKey,
52    /// Total confirmed amount paid to the request
53    #[serde(default)]
54    pub amount_paid: Amount,
55    /// Amount of ecash that has been issued for the given mint quote
56    #[serde(default)]
57    pub amount_issued: Amount,
58    /// Unix timestamp indicating when the quote was last updated
59    #[serde(default)]
60    pub updated_at: u64,
61}
62
63impl<Q: ToString> MintQuoteOnchainResponse<Q> {
64    /// Convert the MintQuoteOnchainResponse with a quote type Q to a String
65    pub fn to_string_id(&self) -> MintQuoteOnchainResponse<String> {
66        MintQuoteOnchainResponse {
67            quote: self.quote.to_string(),
68            request: self.request.clone(),
69            unit: self.unit.clone(),
70            method: self.method.clone(),
71            expiry: self.expiry,
72            pubkey: self.pubkey,
73            amount_paid: self.amount_paid,
74            amount_issued: self.amount_issued,
75            updated_at: self.updated_at,
76        }
77    }
78}
79
80#[cfg(feature = "mint")]
81impl From<MintQuoteOnchainResponse<QuoteId>> for MintQuoteOnchainResponse<String> {
82    fn from(value: MintQuoteOnchainResponse<QuoteId>) -> Self {
83        Self {
84            quote: value.quote.to_string(),
85            request: value.request,
86            unit: value.unit,
87            method: value.method,
88            expiry: value.expiry,
89            pubkey: value.pubkey,
90            amount_paid: value.amount_paid,
91            amount_issued: value.amount_issued,
92            updated_at: value.updated_at,
93        }
94    }
95}
96
97/// Melt quote onchain request
98///
99/// Request for an onchain melt quote.
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101pub struct MeltQuoteOnchainRequest {
102    /// Bitcoin address to send to
103    pub request: String,
104    /// Unit wallet would like to pay with
105    pub unit: CurrencyUnit,
106    /// Amount to send in the specified unit
107    pub amount: Amount,
108}
109
110/// Melt onchain request
111///
112/// Request to execute an onchain melt quote. The wallet selects one of the
113/// quote's fee options by including that option's `fee_index` value.
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115#[serde(bound = "Q: Serialize + DeserializeOwned")]
116pub struct MeltOnchainRequest<Q> {
117    /// Quote ID
118    pub quote: Q,
119    /// Selected fee option index from the quote's `fee_options`
120    pub fee_index: u32,
121    /// Proofs
122    pub inputs: Proofs,
123    /// Blinded messages that can be used to return overpaid onchain fee reserve
124    pub outputs: Option<Vec<BlindedMessage>>,
125}
126
127impl<Q> From<MeltOnchainRequest<Q>> for MeltRequest<Q>
128where
129    Q: Serialize + DeserializeOwned,
130{
131    fn from(request: MeltOnchainRequest<Q>) -> Self {
132        MeltRequest::new(request.quote, request.inputs, request.outputs)
133            .fee_index(request.fee_index)
134    }
135}
136
137/// Fee option for an onchain melt quote.
138///
139/// Each item in an onchain melt quote's `fee_options` represents one
140/// available fee reserve and confirmation estimate for the same payment. The wallet
141/// selects one option when executing the quote by echoing its
142/// `fee_index` value in the melt request.
143///
144/// The mint enforces these NUT rules on the `fee_options` list as a whole:
145///
146/// - MUST return at least one item.
147/// - MUST NOT contain two items with the same `fee_index`.
148/// - The list is fixed for the lifetime of the quote.
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
150pub struct MeltQuoteOnchainFeeOption {
151    /// Server-assigned identifier the wallet echoes back to select this option
152    pub fee_index: u32,
153    /// Maximum onchain transaction fee the mint may charge for this option
154    pub fee_reserve: Amount,
155    /// Estimated number of blocks until confirmation
156    pub estimated_blocks: u32,
157}
158
159/// Melt quote onchain response
160///
161/// Response containing the onchain melt quote details.
162/// The `POST /v1/melt/quote/onchain` endpoint returns one quote with one or
163/// more `fee_options`. The wallet chooses one option when executing the quote.
164///
165/// Unknown fields are accepted to preserve forward compatibility when mints
166/// add optional onchain extensions.
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
168#[serde(bound = "Q: Serialize + DeserializeOwned")]
169pub struct MeltQuoteOnchainResponse<Q> {
170    /// Quote Id
171    pub quote: Q,
172    /// Amount to be melted
173    pub amount: Amount,
174    /// Unit
175    pub unit: CurrencyUnit,
176    /// Payment method
177    #[serde(default = "default_onchain_method")]
178    pub method: PaymentMethod,
179    /// Quote state
180    pub state: MeltQuoteState,
181    /// Unix timestamp until the quote is valid
182    pub expiry: u64,
183    /// Bitcoin address to send to
184    pub request: String,
185    /// Fee options for the transaction.
186    ///
187    /// Each entry represents one fee-reserve/confirmation-target pair the mint is
188    /// willing to honor for this quote. Per NUT the mint MUST return at
189    /// least one entry; MUST NOT return multiple entries with the same
190    /// `fee_index`; and the list is fixed for the lifetime of the quote.
191    pub fee_options: Vec<MeltQuoteOnchainFeeOption>,
192    /// Selected fee option index once the quote is executed
193    pub selected_fee_index: Option<u32>,
194    /// Transaction outpoint (txid:vout) once broadcast
195    #[serde(default, deserialize_with = "deserialize_empty_string_as_none")]
196    pub outpoint: Option<String>,
197    /// Blind signatures for overpaid onchain fee reserve
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub change: Option<Vec<BlindSignature>>,
200}
201
202impl<Q: ToString> MeltQuoteOnchainResponse<Q> {
203    /// Convert the MeltQuoteOnchainResponse with a quote type Q to a String
204    pub fn to_string_id(&self) -> MeltQuoteOnchainResponse<String> {
205        MeltQuoteOnchainResponse {
206            quote: self.quote.to_string(),
207            amount: self.amount,
208            unit: self.unit.clone(),
209            method: self.method.clone(),
210            state: self.state,
211            expiry: self.expiry,
212            request: self.request.clone(),
213            fee_options: self.fee_options.clone(),
214            selected_fee_index: self.selected_fee_index,
215            outpoint: self.outpoint.clone(),
216            change: self.change.clone(),
217        }
218    }
219}
220
221#[cfg(feature = "mint")]
222impl From<MeltQuoteOnchainResponse<QuoteId>> for MeltQuoteOnchainResponse<String> {
223    fn from(value: MeltQuoteOnchainResponse<QuoteId>) -> Self {
224        Self {
225            quote: value.quote.to_string(),
226            amount: value.amount,
227            unit: value.unit,
228            method: value.method,
229            state: value.state,
230            expiry: value.expiry,
231            request: value.request,
232            fee_options: value.fee_options,
233            selected_fee_index: value.selected_fee_index,
234            outpoint: value.outpoint,
235            change: value.change,
236        }
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn test_mint_quote_onchain_request_serialization() {
246        let request = MintQuoteOnchainRequest {
247            unit: CurrencyUnit::Sat,
248            pubkey: PublicKey::from_hex(
249                "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac",
250            )
251            .unwrap(),
252        };
253
254        let serialized = serde_json::to_string(&request).unwrap();
255        let deserialized: MintQuoteOnchainRequest = serde_json::from_str(&serialized).unwrap();
256
257        assert_eq!(request.unit, deserialized.unit);
258        assert_eq!(request.pubkey, deserialized.pubkey);
259    }
260
261    #[test]
262    fn test_melt_quote_onchain_request_serialization() {
263        let request = MeltQuoteOnchainRequest {
264            request: "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh".to_string(),
265            unit: CurrencyUnit::Sat,
266            amount: Amount::from(1000),
267        };
268
269        let serialized = serde_json::to_string(&request).unwrap();
270        let deserialized: MeltQuoteOnchainRequest = serde_json::from_str(&serialized).unwrap();
271
272        assert_eq!(request.request, deserialized.request);
273        assert_eq!(request.unit, deserialized.unit);
274        assert_eq!(request.amount, deserialized.amount);
275    }
276
277    #[test]
278    fn test_melt_quote_onchain_response_serialization() {
279        let response: MeltQuoteOnchainResponse<String> = MeltQuoteOnchainResponse {
280            quote: "TRmjduhIsPxd...".to_string(),
281            amount: Amount::from(100000),
282            unit: CurrencyUnit::Sat,
283            method: PaymentMethod::Known(crate::nuts::nut00::KnownMethod::Onchain),
284            state: MeltQuoteState::Pending,
285            expiry: 1701704757,
286            request: "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh".to_string(),
287            fee_options: vec![MeltQuoteOnchainFeeOption {
288                fee_index: 0,
289                fee_reserve: Amount::from(5000),
290                estimated_blocks: 1,
291            }],
292            selected_fee_index: Some(0),
293            outpoint: Some(
294                "3b7f3b85c5f1a3c4d2b8e9f6a7c5d8e9f1a2b3c4d5e6f7a8b9c1d2e3f4a5b6c7:2".to_string(),
295            ),
296            change: None,
297        };
298
299        let serialized = serde_json::to_string(&response).unwrap();
300        assert!(serialized.contains("\"fee_reserve\""));
301        assert!(serialized.contains("\"fee_index\""));
302        assert!(!serialized.contains("\"fee\":"));
303
304        let deserialized: MeltQuoteOnchainResponse<String> =
305            serde_json::from_str(&serialized).unwrap();
306
307        assert_eq!(response.quote, deserialized.quote);
308        assert_eq!(response.request, deserialized.request);
309        assert_eq!(response.amount, deserialized.amount);
310        assert_eq!(response.method, deserialized.method);
311        assert_eq!(response.fee_options, deserialized.fee_options);
312        assert_eq!(response.selected_fee_index, deserialized.selected_fee_index);
313        assert_eq!(response.state, deserialized.state);
314        assert_eq!(response.outpoint, deserialized.outpoint);
315        assert_eq!(response.change, deserialized.change);
316    }
317
318    #[test]
319    fn test_melt_quote_onchain_response_defaults_method() {
320        let value = serde_json::json!({
321            "quote": "TRmjduhIsPxd...",
322            "amount": 100000,
323            "unit": "sat",
324            "state": "PENDING",
325            "expiry": 1701704757,
326            "request": "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh",
327            "fee_options": [
328                {
329                    "fee_index": 0,
330                    "fee_reserve": 5000,
331                    "estimated_blocks": 1
332                }
333            ],
334            "selected_fee_index": 0
335        });
336
337        let decoded: MeltQuoteOnchainResponse<String> =
338            serde_json::from_value(value).expect("deserialize response");
339        assert_eq!(decoded.method, PaymentMethod::Known(KnownMethod::Onchain));
340    }
341
342    #[test]
343    fn test_melt_quote_onchain_response_serializes_null_outpoint() {
344        let response: MeltQuoteOnchainResponse<String> = MeltQuoteOnchainResponse {
345            quote: "TRmjduhIsPxd...".to_string(),
346            amount: Amount::from(100000),
347            unit: CurrencyUnit::Sat,
348            method: PaymentMethod::Known(crate::nuts::nut00::KnownMethod::Onchain),
349            state: MeltQuoteState::Pending,
350            expiry: 1701704757,
351            request: "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh".to_string(),
352            fee_options: vec![MeltQuoteOnchainFeeOption {
353                fee_index: 0,
354                fee_reserve: Amount::from(5000),
355                estimated_blocks: 1,
356            }],
357            selected_fee_index: None,
358            outpoint: None,
359            change: None,
360        };
361
362        let serialized = serde_json::to_string(&response).unwrap();
363        assert!(serialized.contains("\"outpoint\":null"));
364
365        let deserialized: MeltQuoteOnchainResponse<String> =
366            serde_json::from_str(&serialized).unwrap();
367        assert_eq!(deserialized.outpoint, None);
368    }
369
370    #[test]
371    fn test_mint_quote_onchain_response_tolerates_unknown_fields() {
372        // Responses from newer mints may carry additional optional fields
373        // (e.g. payjoin instructions); released wallets must not reject them.
374        let encoded = r#"{
375            "quote": "DSGLX9kevM...",
376            "request": "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh",
377            "unit": "sat",
378            "expiry": 1701704757,
379            "pubkey": "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac",
380            "amount_paid": 100000,
381            "amount_issued": 0,
382            "payjoin": {"endpoint": "https://payjoin.example/pj"}
383        }"#;
384
385        let deserialized: MintQuoteOnchainResponse<String> = serde_json::from_str(encoded).unwrap();
386        assert_eq!(deserialized.quote, "DSGLX9kevM...");
387        assert_eq!(deserialized.amount_paid, Amount::from(100000));
388    }
389
390    #[test]
391    fn test_melt_quote_onchain_response_tolerates_unknown_fields() {
392        let encoded = r#"{
393            "quote": "TRmjduhIsPxd...",
394            "amount": 100000,
395            "unit": "sat",
396            "state": "PENDING",
397            "expiry": 1701704757,
398            "request": "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh",
399            "fee_options": [{"fee_index": 0, "fee_reserve": 5000, "estimated_blocks": 1}],
400            "selected_fee_index": null,
401            "outpoint": null,
402            "payjoin": {"endpoint": "https://payjoin.example/pj"}
403        }"#;
404
405        let deserialized: MeltQuoteOnchainResponse<String> = serde_json::from_str(encoded).unwrap();
406        assert_eq!(deserialized.quote, "TRmjduhIsPxd...");
407        assert_eq!(deserialized.state, MeltQuoteState::Pending);
408    }
409
410    #[test]
411    fn test_mint_quote_onchain_response_to_string_id() {
412        use crate::nuts::nut00::CurrencyUnit;
413        use crate::nuts::nut01::PublicKey;
414        use crate::Amount;
415
416        let response: MintQuoteOnchainResponse<String> = MintQuoteOnchainResponse {
417            quote: "DSGLX9kevM...".to_string(),
418            request: "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh".to_string(),
419            unit: CurrencyUnit::Sat,
420            method: PaymentMethod::Known(crate::nuts::nut00::KnownMethod::Onchain),
421            expiry: Some(1701704757),
422            pubkey: PublicKey::from_hex(
423                "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac",
424            )
425            .unwrap(),
426            amount_paid: Amount::from(100000),
427            amount_issued: Amount::from(0),
428            updated_at: 0,
429        };
430
431        let string_id_response = response.to_string_id();
432        assert_eq!(string_id_response.quote, "DSGLX9kevM...");
433    }
434
435    #[test]
436    fn test_mint_quote_onchain_response_defaults_method() {
437        let value = serde_json::json!({
438            "quote": "DSGLX9kevM...",
439            "request": "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh",
440            "unit": "sat",
441            "expiry": 1701704757,
442            "pubkey": "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac",
443            "amount_paid": 100000,
444            "amount_issued": 0
445        });
446
447        let decoded: MintQuoteOnchainResponse<String> =
448            serde_json::from_value(value).expect("deserialize response");
449        assert_eq!(decoded.method, PaymentMethod::Known(KnownMethod::Onchain));
450    }
451
452    #[test]
453    fn test_melt_quote_onchain_response_to_string_id() {
454        use crate::nuts::nut00::CurrencyUnit;
455        use crate::Amount;
456
457        let response: MeltQuoteOnchainResponse<String> = MeltQuoteOnchainResponse {
458            quote: "TRmjduhIsPxd...".to_string(),
459            amount: Amount::from(100000),
460            unit: CurrencyUnit::Sat,
461            method: PaymentMethod::Known(crate::nuts::nut00::KnownMethod::Onchain),
462            state: MeltQuoteState::Pending,
463            expiry: 1701704757,
464            request: "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh".to_string(),
465            fee_options: vec![MeltQuoteOnchainFeeOption {
466                fee_index: 0,
467                fee_reserve: Amount::from(5000),
468                estimated_blocks: 1,
469            }],
470            selected_fee_index: Some(0),
471            outpoint: Some(
472                "3b7f3b85c5f1a3c4d2b8e9f6a7c5d8e9f1a2b3c4d5e6f7a8b9c1d2e3f4a5b6c7:2".to_string(),
473            ),
474            change: None,
475        };
476
477        let string_id_response = response.to_string_id();
478        assert_eq!(string_id_response.quote, "TRmjduhIsPxd...");
479    }
480}