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