Skip to main content

cdk_common/
mint.rs

1//! Mint types
2
3use std::fmt;
4use std::ops::Deref;
5use std::str::FromStr;
6
7use bitcoin::bip32::DerivationPath;
8use cashu::nuts::nut30::MeltQuoteOnchainFeeOption;
9use cashu::quote_id::QuoteId;
10use cashu::util::unix_time;
11use cashu::{
12    Bolt11Invoice, MeltOptions, MeltQuoteBolt11Response, MeltQuoteBolt12Response,
13    MeltQuoteCustomResponse, MeltQuoteOnchainResponse, MintQuoteBolt11Response,
14    MintQuoteBolt12Response, MintQuoteCustomResponse, MintQuoteOnchainResponse, PaymentMethod,
15    Proofs, State,
16};
17use lightning::offers::offer::Offer;
18use serde::{Deserialize, Serialize};
19use tracing::instrument;
20use uuid::Uuid;
21
22use crate::common::IssuerVersion;
23use crate::mint_quote::MintQuoteResponse;
24use crate::nuts::{MeltQuoteState, MintQuoteState};
25use crate::payment::PaymentIdentifier;
26use crate::{Amount, CurrencyUnit, Error, Id, KeySetInfo, PublicKey};
27
28/// Operation kind for saga persistence
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "lowercase")]
31pub enum OperationKind {
32    /// Swap operation
33    Swap,
34    /// Mint operation
35    Mint,
36    /// Melt operation
37    Melt,
38    /// Batch mint
39    BatchMint,
40}
41
42/// A collection of proofs that share a common state.
43///
44/// This type enforces the invariant that all proofs in the collection have the same state.
45/// The mint never needs to operate on a set of proofs with different states - proofs are
46/// always processed together as a unit (e.g., during swap, melt, or mint operations).
47///
48/// # Database Layer Responsibility
49///
50/// This design shifts the responsibility of ensuring state consistency to the database layer.
51/// When the database retrieves proofs via [`get_proofs`](crate::database::mint::ProofsTransaction::get_proofs),
52/// it must verify that all requested proofs share the same state and return an error if they don't.
53/// This prevents invalid proof sets from propagating through the system.
54///
55/// # State Transitions
56///
57/// State transitions are validated using [`check_state_transition`](crate::state::check_state_transition)
58/// before updating. The database layer then persists the new state for all proofs in a single transaction
59/// via [`update_proofs_state`](crate::database::mint::ProofsTransaction::update_proofs_state).
60///
61/// # Example
62///
63/// ```ignore
64/// // Database layer ensures all proofs have the same state
65/// let mut proofs = tx.get_proofs(&ys).await?;
66///
67/// // Validate the state transition
68/// check_state_transition(proofs.state, State::Spent)?;
69///
70/// // Persist the state change
71/// tx.update_proofs_state(&mut proofs, State::Spent).await?;
72/// ```
73#[derive(Debug)]
74pub struct ProofsWithState {
75    proofs: Proofs,
76    /// The current state of the proofs
77    pub state: State,
78}
79
80impl Deref for ProofsWithState {
81    type Target = Proofs;
82
83    fn deref(&self) -> &Self::Target {
84        &self.proofs
85    }
86}
87
88impl ProofsWithState {
89    /// Creates a new `ProofsWithState` with the given proofs and their shared state.
90    ///
91    /// # Note
92    ///
93    /// This constructor assumes all proofs share the given state. It is typically
94    /// called by the database layer after verifying state consistency.
95    pub fn new(proofs: Proofs, current_state: State) -> Self {
96        Self {
97            proofs,
98            state: current_state,
99        }
100    }
101}
102
103impl fmt::Display for OperationKind {
104    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105        match self {
106            OperationKind::Swap => write!(f, "swap"),
107            OperationKind::Mint => write!(f, "mint"),
108            OperationKind::Melt => write!(f, "melt"),
109            OperationKind::BatchMint => write!(f, "batch_mint"),
110        }
111    }
112}
113
114impl FromStr for OperationKind {
115    type Err = Error;
116    fn from_str(value: &str) -> Result<Self, Self::Err> {
117        let value = value.to_lowercase();
118        match value.as_str() {
119            "swap" => Ok(OperationKind::Swap),
120            "mint" => Ok(OperationKind::Mint),
121            "melt" => Ok(OperationKind::Melt),
122            "batch_mint" => Ok(OperationKind::BatchMint),
123            _ => Err(Error::Custom(format!("Invalid operation kind: {value}"))),
124        }
125    }
126}
127
128/// States specific to swap saga
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130#[serde(rename_all = "snake_case")]
131pub enum SwapSagaState {
132    /// Swap setup complete (proofs added, blinded messages added)
133    SetupComplete,
134    /// Outputs signed (signatures generated but not persisted)
135    Signed,
136}
137
138impl fmt::Display for SwapSagaState {
139    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
140        match self {
141            SwapSagaState::SetupComplete => write!(f, "setup_complete"),
142            SwapSagaState::Signed => write!(f, "signed"),
143        }
144    }
145}
146
147impl FromStr for SwapSagaState {
148    type Err = Error;
149    fn from_str(value: &str) -> Result<Self, Self::Err> {
150        let value = value.to_lowercase();
151        match value.as_str() {
152            "setup_complete" => Ok(SwapSagaState::SetupComplete),
153            "signed" => Ok(SwapSagaState::Signed),
154            _ => Err(Error::Custom(format!("Invalid swap saga state: {value}"))),
155        }
156    }
157}
158
159/// States specific to melt saga
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161#[serde(rename_all = "snake_case")]
162pub enum MeltSagaState {
163    /// Setup complete (proofs reserved, quote verified)
164    SetupComplete,
165    /// Write-ahead marker recorded before dispatch to the configured backend.
166    /// A terminal negative status cannot prove that the payment was not sent.
167    PaymentAttempted,
168    /// The backend acknowledged the payment attempt.
169    /// Later status checks may finalize it, but must never compensate it.
170    PaymentPending,
171    /// The backend authoritatively reported that the payment operation ended
172    /// without paying. Recovery may safely compensate this melt.
173    PaymentFailed,
174    /// TX1 committed (proofs Spent, quote Paid) - change signing + cleanup pending
175    Finalizing,
176}
177
178impl fmt::Display for MeltSagaState {
179    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
180        match self {
181            MeltSagaState::SetupComplete => write!(f, "setup_complete"),
182            MeltSagaState::PaymentAttempted => write!(f, "payment_attempted"),
183            MeltSagaState::PaymentPending => write!(f, "payment_pending"),
184            MeltSagaState::PaymentFailed => write!(f, "payment_failed"),
185            MeltSagaState::Finalizing => write!(f, "finalizing"),
186        }
187    }
188}
189
190impl FromStr for MeltSagaState {
191    type Err = Error;
192    fn from_str(value: &str) -> Result<Self, Self::Err> {
193        let value = value.to_lowercase();
194        match value.as_str() {
195            "setup_complete" => Ok(MeltSagaState::SetupComplete),
196            "payment_attempted" => Ok(MeltSagaState::PaymentAttempted),
197            "payment_pending" => Ok(MeltSagaState::PaymentPending),
198            "payment_failed" => Ok(MeltSagaState::PaymentFailed),
199            "finalizing" => Ok(MeltSagaState::Finalizing),
200            _ => Err(Error::Custom(format!("Invalid melt saga state: {}", value))),
201        }
202    }
203}
204
205/// Saga state for different operation types
206#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
207#[serde(tag = "type", rename_all = "snake_case")]
208pub enum SagaStateEnum {
209    /// Swap saga states
210    Swap(SwapSagaState),
211    /// Melt saga states
212    Melt(MeltSagaState),
213    // Future: Mint saga states
214    // Mint(MintSagaState),
215}
216
217impl SagaStateEnum {
218    /// Create from string given operation kind
219    pub fn new(operation_kind: OperationKind, s: &str) -> Result<Self, Error> {
220        match operation_kind {
221            OperationKind::Swap => Ok(SagaStateEnum::Swap(SwapSagaState::from_str(s)?)),
222            OperationKind::Melt => Ok(SagaStateEnum::Melt(MeltSagaState::from_str(s)?)),
223            OperationKind::Mint | OperationKind::BatchMint => {
224                Err(Error::Custom("Mint saga not implemented yet".to_string()))
225            }
226        }
227    }
228
229    /// Get string representation of the state
230    pub fn state(&self) -> &str {
231        match self {
232            SagaStateEnum::Swap(state) => match state {
233                SwapSagaState::SetupComplete => "setup_complete",
234                SwapSagaState::Signed => "signed",
235            },
236            SagaStateEnum::Melt(state) => match state {
237                MeltSagaState::SetupComplete => "setup_complete",
238                MeltSagaState::PaymentAttempted => "payment_attempted",
239                MeltSagaState::PaymentPending => "payment_pending",
240                MeltSagaState::PaymentFailed => "payment_failed",
241                MeltSagaState::Finalizing => "finalizing",
242            },
243        }
244    }
245}
246
247/// Persisted saga for recovery
248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
249pub struct Saga {
250    /// Operation ID (correlation key)
251    pub operation_id: Uuid,
252    /// Operation kind (swap, mint, melt)
253    pub operation_kind: OperationKind,
254    /// Current saga state (operation-specific)
255    pub state: SagaStateEnum,
256    /// Quote ID for melt operations (used for payment status lookup during recovery)
257    /// None for swap operations
258    pub quote_id: Option<String>,
259    /// Exact payment result for resuming melt finalization after TX1 commits.
260    pub finalization_data: Option<MeltFinalizationData>,
261    /// Unix timestamp when saga was created
262    pub created_at: u64,
263    /// Unix timestamp when saga was last updated
264    pub updated_at: u64,
265}
266
267/// Persisted payment result for resuming melt finalization after a crash.
268#[derive(Clone, PartialEq, Eq)]
269pub struct MeltFinalizationData {
270    /// Total amount actually spent on the payment.
271    pub total_spent: Amount<CurrencyUnit>,
272    /// Backend payment lookup identifier.
273    pub payment_lookup_id: PaymentIdentifier,
274    /// Optional payment proof / preimage.
275    pub payment_proof: Option<String>,
276}
277
278impl fmt::Debug for MeltFinalizationData {
279    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
280        f.debug_struct("MeltFinalizationData")
281            .field("total_spent", &self.total_spent)
282            .field("payment_lookup_id", &self.payment_lookup_id)
283            .field(
284                "payment_proof",
285                &self.payment_proof.as_ref().map(|_| "[REDACTED]"),
286            )
287            .finish()
288    }
289}
290
291impl Serialize for MeltFinalizationData {
292    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
293    where
294        S: serde::Serializer,
295    {
296        #[derive(Serialize)]
297        struct MeltFinalizationDataSer<'a> {
298            total_spent: Amount,
299            unit: &'a CurrencyUnit,
300            payment_lookup_id: &'a PaymentIdentifier,
301            payment_proof: &'a Option<String>,
302        }
303
304        MeltFinalizationDataSer {
305            total_spent: self.total_spent.clone().into(),
306            unit: self.total_spent.unit(),
307            payment_lookup_id: &self.payment_lookup_id,
308            payment_proof: &self.payment_proof,
309        }
310        .serialize(serializer)
311    }
312}
313
314impl<'de> Deserialize<'de> for MeltFinalizationData {
315    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
316    where
317        D: serde::Deserializer<'de>,
318    {
319        #[derive(Deserialize)]
320        struct MeltFinalizationDataDe {
321            total_spent: Amount,
322            unit: CurrencyUnit,
323            payment_lookup_id: PaymentIdentifier,
324            payment_proof: Option<String>,
325        }
326
327        let data = MeltFinalizationDataDe::deserialize(deserializer)?;
328
329        Ok(Self {
330            total_spent: data.total_spent.with_unit(data.unit),
331            payment_lookup_id: data.payment_lookup_id,
332            payment_proof: data.payment_proof,
333        })
334    }
335}
336
337impl Saga {
338    /// Create new swap saga
339    pub fn new_swap(operation_id: Uuid, state: SwapSagaState) -> Self {
340        let now = unix_time();
341        Self {
342            operation_id,
343            operation_kind: OperationKind::Swap,
344            state: SagaStateEnum::Swap(state),
345            quote_id: None,
346            finalization_data: None,
347            created_at: now,
348            updated_at: now,
349        }
350    }
351
352    /// Update swap saga state
353    pub fn update_swap_state(&mut self, new_state: SwapSagaState) {
354        self.state = SagaStateEnum::Swap(new_state);
355        self.updated_at = unix_time();
356    }
357
358    /// Create new melt saga
359    pub fn new_melt(operation_id: Uuid, state: MeltSagaState, quote_id: String) -> Self {
360        let now = unix_time();
361        Self {
362            operation_id,
363            operation_kind: OperationKind::Melt,
364            state: SagaStateEnum::Melt(state),
365            quote_id: Some(quote_id),
366            finalization_data: None,
367            created_at: now,
368            updated_at: now,
369        }
370    }
371
372    /// Update melt saga state
373    pub fn update_melt_state(&mut self, new_state: MeltSagaState) {
374        self.state = SagaStateEnum::Melt(new_state);
375        self.updated_at = unix_time();
376    }
377
378    /// Store exact payment data needed to resume melt finalization after TX1.
379    pub fn set_melt_finalization_data(&mut self, finalization_data: MeltFinalizationData) {
380        self.finalization_data = Some(finalization_data);
381        self.updated_at = unix_time();
382    }
383}
384
385/// Operation
386#[derive(Debug)]
387pub struct Operation {
388    id: Uuid,
389    kind: OperationKind,
390    total_issued: Amount,
391    total_redeemed: Amount,
392    fee_collected: Amount,
393    complete_at: Option<u64>,
394    /// Payment amount (only for melt operations)
395    payment_amount: Option<Amount>,
396    /// Payment fee (only for melt operations)
397    payment_fee: Option<Amount>,
398    /// Payment method (only for mint/melt operations)
399    payment_method: Option<PaymentMethod>,
400}
401
402impl Operation {
403    /// New
404    pub fn new(
405        id: Uuid,
406        kind: OperationKind,
407        total_issued: Amount,
408        total_redeemed: Amount,
409        fee_collected: Amount,
410        complete_at: Option<u64>,
411        payment_method: Option<PaymentMethod>,
412    ) -> Self {
413        Self {
414            id,
415            kind,
416            total_issued,
417            total_redeemed,
418            fee_collected,
419            complete_at,
420            payment_amount: None,
421            payment_fee: None,
422            payment_method,
423        }
424    }
425
426    /// Mint
427    pub fn new_mint(total_issued: Amount, payment_method: PaymentMethod) -> Self {
428        Self {
429            id: Uuid::now_v7(),
430            kind: OperationKind::Mint,
431            total_issued,
432            total_redeemed: Amount::ZERO,
433            fee_collected: Amount::ZERO,
434            complete_at: None,
435            payment_amount: None,
436            payment_fee: None,
437            payment_method: Some(payment_method),
438        }
439    }
440
441    /// Batch mint
442    pub fn new_batch_mint(total_issued: Amount, payment_method: PaymentMethod) -> Self {
443        Self {
444            id: Uuid::now_v7(),
445            kind: OperationKind::BatchMint,
446            total_issued,
447            total_redeemed: Amount::ZERO,
448            fee_collected: Amount::ZERO,
449            complete_at: None,
450            payment_amount: None,
451            payment_fee: None,
452            payment_method: Some(payment_method),
453        }
454    }
455
456    /// Melt
457    ///
458    /// In the context of a melt total_issued refrests to the change
459    pub fn new_melt(
460        total_redeemed: Amount,
461        fee_collected: Amount,
462        payment_method: PaymentMethod,
463    ) -> Self {
464        Self {
465            id: Uuid::now_v7(),
466            kind: OperationKind::Melt,
467            total_issued: Amount::ZERO,
468            total_redeemed,
469            fee_collected,
470            complete_at: None,
471            payment_amount: None,
472            payment_fee: None,
473            payment_method: Some(payment_method),
474        }
475    }
476
477    /// Swap
478    pub fn new_swap(total_issued: Amount, total_redeemed: Amount, fee_collected: Amount) -> Self {
479        Self {
480            id: Uuid::now_v7(),
481            kind: OperationKind::Swap,
482            total_issued,
483            total_redeemed,
484            fee_collected,
485            complete_at: None,
486            payment_amount: None,
487            payment_fee: None,
488            payment_method: None,
489        }
490    }
491
492    /// Operation id
493    pub fn id(&self) -> &Uuid {
494        &self.id
495    }
496
497    /// Operation kind
498    pub fn kind(&self) -> OperationKind {
499        self.kind
500    }
501
502    /// Total issued
503    pub fn total_issued(&self) -> Amount {
504        self.total_issued
505    }
506
507    /// Total redeemed
508    pub fn total_redeemed(&self) -> Amount {
509        self.total_redeemed
510    }
511
512    /// Fee collected
513    pub fn fee_collected(&self) -> Amount {
514        self.fee_collected
515    }
516
517    /// Completed time
518    pub fn completed_at(&self) -> &Option<u64> {
519        &self.complete_at
520    }
521
522    /// Add change
523    pub fn add_change(&mut self, change: Amount) {
524        self.total_issued = change;
525    }
526
527    /// Payment amount (only for melt operations)
528    pub fn payment_amount(&self) -> Option<Amount> {
529        self.payment_amount
530    }
531
532    /// Payment fee (only for melt operations)
533    pub fn payment_fee(&self) -> Option<Amount> {
534        self.payment_fee
535    }
536
537    /// Set payment details for melt operations
538    pub fn set_payment_details(&mut self, payment_amount: Amount, payment_fee: Amount) {
539        self.payment_amount = Some(payment_amount);
540        self.payment_fee = Some(payment_fee);
541    }
542
543    /// Payment method (only for mint/melt operations)
544    pub fn payment_method(&self) -> Option<PaymentMethod> {
545        self.payment_method.clone()
546    }
547}
548
549/// Tracks pending changes made to a [`MintQuote`] that need to be persisted.
550///
551/// This struct implements a change-tracking pattern that separates domain logic from
552/// persistence concerns. When modifications are made to a `MintQuote` via methods like
553/// [`MintQuote::add_payment`] or [`MintQuote::add_issuance`], the changes are recorded
554/// here rather than being immediately persisted. The database layer can then call
555/// [`MintQuote::take_changes`] to retrieve and persist only the modifications.
556///
557/// This approach allows business rule validation to happen in the domain model while
558/// keeping the database layer focused purely on persistence.
559#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
560pub struct MintQuoteChange {
561    /// New payments added since the quote was loaded or last persisted.
562    pub payments: Option<Vec<IncomingPayment>>,
563    /// New issuance amounts recorded since the quote was loaded or last persisted.
564    pub issuances: Option<Vec<Amount>>,
565}
566
567/// Mint Quote Info
568#[derive(Debug, Clone, Hash, PartialEq, Eq)]
569pub struct MintQuote {
570    /// Quote id
571    pub id: QuoteId,
572    /// Amount of quote
573    pub amount: Option<Amount<CurrencyUnit>>,
574    /// Unit of quote
575    pub unit: CurrencyUnit,
576    /// Quote payment request e.g. bolt11
577    pub request: String,
578    /// Expiration time of quote
579    pub expiry: u64,
580    /// Value used by the payment backend to look up state of request
581    pub request_lookup_id: PaymentIdentifier,
582    /// Pubkey
583    pub pubkey: Option<PublicKey>,
584    /// Unix time quote was created
585    pub created_time: u64,
586    /// Amount paid (typed for type safety)
587    amount_paid: Amount<CurrencyUnit>,
588    /// Amount issued (typed for type safety)
589    amount_issued: Amount<CurrencyUnit>,
590    /// Unix timestamp indicating when the quote accounting last changed.
591    updated_at: u64,
592    /// Unix timestamp of the most recent payment backend status check.
593    last_checked: u64,
594    /// Payment of payment(s) that filled quote
595    pub payments: Vec<IncomingPayment>,
596    /// Payment Method
597    pub payment_method: PaymentMethod,
598    /// Payment of payment(s) that filled quote
599    pub issuance: Vec<Issuance>,
600    /// Extra payment-method-specific fields
601    pub extra_json: Option<serde_json::Value>,
602    /// Accumulated changes since this quote was loaded or created.
603    ///
604    /// This field is not serialized and is used internally to track modifications
605    /// that need to be persisted. Use [`Self::take_changes`] to extract pending
606    /// changes for persistence.
607    changes: Option<MintQuoteChange>,
608}
609
610impl MintQuote {
611    /// Create new [`MintQuote`]
612    #[allow(clippy::too_many_arguments)]
613    pub fn new(
614        id: Option<QuoteId>,
615        request: String,
616        unit: CurrencyUnit,
617        amount: Option<Amount<CurrencyUnit>>,
618        expiry: u64,
619        request_lookup_id: PaymentIdentifier,
620        pubkey: Option<PublicKey>,
621        amount_paid: Amount<CurrencyUnit>,
622        amount_issued: Amount<CurrencyUnit>,
623        payment_method: PaymentMethod,
624        created_time: u64,
625        updated_at: u64,
626        payments: Vec<IncomingPayment>,
627        issuance: Vec<Issuance>,
628        extra_json: Option<serde_json::Value>,
629    ) -> Self {
630        let id = id.unwrap_or_default();
631
632        Self {
633            id,
634            amount,
635            unit: unit.clone(),
636            request,
637            expiry,
638            request_lookup_id,
639            pubkey,
640            created_time,
641            amount_paid,
642            amount_issued,
643            updated_at,
644            last_checked: 0,
645            payment_method,
646            payments,
647            issuance,
648            extra_json,
649            changes: None,
650        }
651    }
652
653    /// Amount paid
654    pub fn amount_paid(&self) -> Amount<CurrencyUnit> {
655        self.amount_paid.clone()
656    }
657
658    /// Records tokens being issued against this mint quote.
659    ///
660    /// This method validates that the issuance doesn't exceed the amount paid, updates
661    /// the quote's internal state, and records the change for later persistence. The
662    /// `amount_issued` counter is incremented and the issuance is added to the change
663    /// tracker for the database layer to persist.
664    ///
665    /// # Arguments
666    ///
667    /// * `additional_amount` - The amount of tokens being issued.
668    ///
669    /// # Returns
670    ///
671    /// Returns the new total `amount_issued` after this issuance is recorded.
672    ///
673    /// # Errors
674    ///
675    /// Returns [`crate::Error::OverIssue`] if the new issued amount would exceed the
676    /// amount paid (cannot issue more tokens than have been paid for).
677    ///
678    /// Returns [`crate::Error::AmountOverflow`] if adding the issuance amount would
679    /// cause an arithmetic overflow.
680    #[instrument(skip(self))]
681    pub fn add_issuance(
682        &mut self,
683        additional_amount: Amount<CurrencyUnit>,
684    ) -> Result<Amount<CurrencyUnit>, crate::Error> {
685        let new_amount_issued = self
686            .amount_issued
687            .checked_add(&additional_amount)
688            .map_err(|_| crate::Error::AmountOverflow)?;
689
690        // Can't issue more than what's been paid
691        if new_amount_issued > self.amount_paid {
692            return Err(crate::Error::OverIssue);
693        }
694
695        self.changes
696            .get_or_insert_default()
697            .issuances
698            .get_or_insert_default()
699            .push(additional_amount.into());
700
701        self.amount_issued = new_amount_issued;
702
703        Ok(self.amount_issued.clone())
704    }
705
706    /// Amount issued
707    pub fn amount_issued(&self) -> Amount<CurrencyUnit> {
708        self.amount_issued.clone()
709    }
710
711    /// Unix timestamp indicating when this quote was last updated.
712    pub fn updated_at(&self) -> u64 {
713        self.updated_at
714    }
715
716    /// Replaces `updated_at` with the value persisted by the database.
717    pub fn set_updated_at(&mut self, updated_at: u64) {
718        self.updated_at = updated_at;
719    }
720
721    /// Unix timestamp of the most recent payment backend status check.
722    pub fn last_checked(&self) -> u64 {
723        self.last_checked
724    }
725
726    /// Replaces `last_checked` with the value persisted by the database.
727    pub fn set_last_checked(&mut self, last_checked: u64) {
728        self.last_checked = last_checked;
729    }
730
731    /// Get state of mint quote
732    pub fn state(&self) -> MintQuoteState {
733        self.compute_quote_state()
734    }
735
736    /// Existing payment ids of a mint quote
737    pub fn payment_ids(&self) -> Vec<&String> {
738        self.payments.iter().map(|a| &a.payment_id).collect()
739    }
740
741    /// Amount mintable
742    /// Returns the amount that is still available for minting.
743    ///
744    /// The value is computed as the difference between the total amount that
745    /// has been paid for this issuance (`self.amount_paid`) and the amount
746    /// that has already been issued (`self.amount_issued`). In other words,
747    pub fn amount_mintable(&self) -> Amount<CurrencyUnit> {
748        self.amount_paid
749            .checked_sub(&self.amount_issued)
750            .unwrap_or_else(|_| Amount::new(0, self.unit.clone()))
751    }
752
753    /// Extracts and returns all pending changes, leaving the internal change tracker empty.
754    ///
755    /// This method is typically called by the database layer after loading or modifying a quote. It
756    /// returns any accumulated changes (new payments, issuances) that need to be persisted, and
757    /// clears the internal change buffer so that subsequent calls return `None` until new
758    /// modifications are made.
759    ///
760    /// Returns `None` if no changes have been made since the last call to this method or since the
761    /// quote was created/loaded.
762    pub fn take_changes(&mut self) -> Option<MintQuoteChange> {
763        self.changes.take()
764    }
765
766    /// Records a new payment received for this mint quote.
767    ///
768    /// This method validates the payment, updates the quote's internal state, and records the
769    /// change for later persistence. The `amount_paid` counter is incremented and the payment is
770    /// added to the change tracker for the database layer to persist.
771    ///
772    /// # Arguments
773    ///
774    /// * `amount` - The amount of the payment in the quote's currency unit. * `payment_id` - A
775    /// unique identifier for this payment (e.g., lightning payment hash). * `time` - Optional Unix
776    /// timestamp of when the payment was received. If `None`, the current time is used.
777    ///
778    /// # Errors
779    ///
780    /// Returns [`crate::Error::DuplicatePaymentId`] if a payment with the same ID has already been
781    /// recorded for this quote.
782    ///
783    /// Returns [`crate::Error::AmountOverflow`] if adding the payment amount would cause an
784    /// arithmetic overflow.
785    #[instrument(skip(self))]
786    pub fn add_payment(
787        &mut self,
788        amount: Amount<CurrencyUnit>,
789        payment_id: String,
790        time: Option<u64>,
791    ) -> Result<(), crate::Error> {
792        let time = time.unwrap_or_else(unix_time);
793
794        let payment_ids = self.payment_ids();
795        if payment_ids.contains(&&payment_id) {
796            return Err(crate::Error::DuplicatePaymentId);
797        }
798
799        self.amount_paid = self
800            .amount_paid
801            .checked_add(&amount)
802            .map_err(|_| crate::Error::AmountOverflow)?;
803
804        let payment = IncomingPayment::new(amount, payment_id, time);
805
806        self.payments.push(payment.clone());
807
808        self.changes
809            .get_or_insert_default()
810            .payments
811            .get_or_insert_default()
812            .push(payment);
813
814        Ok(())
815    }
816
817    /// Compute quote state
818    fn compute_quote_state(&self) -> MintQuoteState {
819        let zero_amount = Amount::new(0, self.unit.clone());
820
821        if self.amount_paid == zero_amount && self.amount_issued == zero_amount {
822            return MintQuoteState::Unpaid;
823        }
824
825        match self.amount_paid.value().cmp(&self.amount_issued.value()) {
826            std::cmp::Ordering::Less => {
827                tracing::error!("We should not have issued more then has been paid");
828                MintQuoteState::Issued
829            }
830            std::cmp::Ordering::Equal => MintQuoteState::Issued,
831            std::cmp::Ordering::Greater => MintQuoteState::Paid,
832        }
833    }
834}
835
836/// Mint Payments
837#[derive(Debug, Clone, Hash, PartialEq, Eq)]
838pub struct IncomingPayment {
839    /// Amount
840    pub amount: Amount<CurrencyUnit>,
841    /// Pyament unix time
842    pub time: u64,
843    /// Payment id
844    pub payment_id: String,
845}
846
847impl IncomingPayment {
848    /// New [`IncomingPayment`]
849    pub fn new(amount: Amount<CurrencyUnit>, payment_id: String, time: u64) -> Self {
850        Self {
851            payment_id,
852            time,
853            amount,
854        }
855    }
856}
857
858/// Information about issued quote
859#[derive(Debug, Clone, Hash, PartialEq, Eq)]
860pub struct Issuance {
861    /// Amount
862    pub amount: Amount<CurrencyUnit>,
863    /// Time
864    pub time: u64,
865}
866
867impl Issuance {
868    /// Create new [`Issuance`]
869    pub fn new(amount: Amount<CurrencyUnit>, time: u64) -> Self {
870        Self { amount, time }
871    }
872}
873
874/// Melt Quote Info
875#[derive(Clone, Hash, PartialEq, Eq)]
876pub struct MeltQuote {
877    /// Quote id
878    pub id: QuoteId,
879    /// Quote unit
880    pub unit: CurrencyUnit,
881    /// Quote Payment request e.g. bolt11
882    pub request: MeltPaymentRequest,
883    /// Quote amount (typed for type safety)
884    amount: Amount<CurrencyUnit>,
885    /// Quote fee reserve (typed for type safety)
886    fee_reserve: Amount<CurrencyUnit>,
887    /// Quote state
888    pub state: MeltQuoteState,
889    /// Expiration time of quote
890    pub expiry: u64,
891    /// Payment proof (e.g. Lightning preimage or onchain outpoint)
892    pub payment_proof: Option<String>,
893    /// Value used by the payment backend to look up state of request
894    pub request_lookup_id: Option<PaymentIdentifier>,
895    /// Payment options
896    ///
897    /// Used for amountless invoices and MPP payments
898    pub options: Option<MeltOptions>,
899    /// Unix time quote was created
900    pub created_time: u64,
901    /// Unix time quote was paid
902    pub paid_time: Option<u64>,
903    /// Payment method
904    pub payment_method: PaymentMethod,
905    /// Extra payment-method-specific response fields
906    pub extra_json: Option<serde_json::Value>,
907    /// Estimated confirmation target in blocks for onchain quotes
908    pub estimated_blocks: Option<u32>,
909    /// Onchain fee options fixed for the lifetime of the quote.
910    ///
911    /// Intentionally private: callers read via [`MeltQuote::fee_options`].
912    /// This makes the "fixed for the lifetime of the quote" NUT invariant
913    /// enforceable at the type level — external code cannot replace or push
914    /// into the vec after construction. Mutations that do happen (via
915    /// [`MeltQuote::select_onchain_fee_option`]) only touch
916    /// `fee_reserve`/`estimated_blocks`/`selected_fee_index`, never
917    /// this list.
918    fee_options: Vec<MeltQuoteOnchainFeeOption>,
919    /// Selected fee option index once an onchain quote is executed
920    pub selected_fee_index: Option<u32>,
921}
922
923impl fmt::Debug for MeltQuote {
924    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
925        f.debug_struct("MeltQuote")
926            .field("id", &self.id)
927            .field("unit", &self.unit)
928            .field("request", &self.request)
929            .field("amount", &self.amount)
930            .field("fee_reserve", &self.fee_reserve)
931            .field("state", &self.state)
932            .field("expiry", &self.expiry)
933            .field(
934                "payment_proof",
935                &self.payment_proof.as_ref().map(|_| "[REDACTED]"),
936            )
937            .field("request_lookup_id", &self.request_lookup_id)
938            .field("options", &self.options)
939            .field("created_time", &self.created_time)
940            .field("paid_time", &self.paid_time)
941            .field("payment_method", &self.payment_method)
942            .field("extra_json", &self.extra_json)
943            .field("estimated_blocks", &self.estimated_blocks)
944            .field("fee_options", &self.fee_options)
945            .field("selected_fee_index", &self.selected_fee_index)
946            .finish()
947    }
948}
949
950impl MeltQuote {
951    /// Create new [`MeltQuote`]
952    #[allow(clippy::too_many_arguments)]
953    pub fn new(
954        id: Option<QuoteId>,
955        request: MeltPaymentRequest,
956        unit: CurrencyUnit,
957        amount: Amount<CurrencyUnit>,
958        fee_reserve: Amount<CurrencyUnit>,
959        expiry: u64,
960        request_lookup_id: Option<PaymentIdentifier>,
961        options: Option<MeltOptions>,
962        payment_method: PaymentMethod,
963        extra_json: Option<serde_json::Value>,
964        estimated_blocks: Option<u32>,
965    ) -> Self {
966        let id = id.unwrap_or_default();
967
968        let fee_options = estimated_blocks
969            .map(|estimated_blocks| {
970                vec![MeltQuoteOnchainFeeOption {
971                    fee_index: 0,
972                    fee_reserve: fee_reserve.clone().into(),
973                    estimated_blocks,
974                }]
975            })
976            .unwrap_or_default();
977
978        Self {
979            id,
980            unit: unit.clone(),
981            request,
982            amount,
983            fee_reserve,
984            state: MeltQuoteState::Unpaid,
985            expiry,
986            payment_proof: None,
987            request_lookup_id,
988            options,
989            created_time: unix_time(),
990            paid_time: None,
991            payment_method,
992            extra_json,
993            estimated_blocks,
994            fee_options,
995            selected_fee_index: None,
996        }
997    }
998
999    /// Create a new onchain [`MeltQuote`] with explicit `fee_options`.
1000    ///
1001    /// Preserves backend-provided `fee_index` values and validates that the
1002    /// quote contains at least one option (`OnchainFeeOptionsEmpty`).
1003    ///
1004    /// `fee_reserve` is initialized to the lowest-fee option so the quote has
1005    /// a definite reserve before the wallet selects a tier. Once the wallet
1006    /// calls [`MeltQuote::select_onchain_fee_option`] the reserve is updated
1007    /// to match the selected option. `fee_options` itself is never mutated
1008    /// after this call; that invariant is enforced by making the field
1009    /// private.
1010    #[allow(clippy::too_many_arguments)]
1011    pub fn new_onchain(
1012        id: Option<QuoteId>,
1013        request: MeltPaymentRequest,
1014        unit: CurrencyUnit,
1015        amount: Amount<CurrencyUnit>,
1016        expiry: u64,
1017        request_lookup_id: Option<PaymentIdentifier>,
1018        extra_json: Option<serde_json::Value>,
1019        fee_options: Vec<MeltQuoteOnchainFeeOption>,
1020    ) -> Result<Self, crate::Error> {
1021        if fee_options.is_empty() {
1022            return Err(crate::Error::OnchainFeeOptionsEmpty);
1023        }
1024
1025        validate_onchain_fee_options(&fee_options)?;
1026
1027        let id = id.unwrap_or_default();
1028
1029        // Pick the lowest-reserve option as the initial reserve. The `ok_or` is
1030        // unreachable — we checked for empty above — but we use it instead of
1031        // `expect` to avoid a needless panic path.
1032        let initial = fee_options
1033            .iter()
1034            .min_by_key(|option| u64::from(option.fee_reserve))
1035            .copied()
1036            .ok_or(crate::Error::OnchainFeeOptionsEmpty)?;
1037
1038        let fee_reserve = initial.fee_reserve.with_unit(unit.clone());
1039        let estimated_blocks = Some(initial.estimated_blocks);
1040
1041        Ok(Self {
1042            id,
1043            unit: unit.clone(),
1044            request,
1045            amount,
1046            fee_reserve,
1047            state: MeltQuoteState::Unpaid,
1048            expiry,
1049            payment_proof: None,
1050            request_lookup_id,
1051            options: None,
1052            created_time: unix_time(),
1053            paid_time: None,
1054            payment_method: PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain),
1055            extra_json,
1056            estimated_blocks,
1057            fee_options,
1058            selected_fee_index: None,
1059        })
1060    }
1061
1062    /// Onchain fee options for this quote.
1063    ///
1064    /// For non-onchain quotes this returns an empty slice. For onchain quotes
1065    /// this is guaranteed non-empty (enforced at construction in
1066    /// [`MeltQuote::new_onchain`] and on reload in [`MeltQuote::from_db`]).
1067    #[inline]
1068    pub fn fee_options(&self) -> &[MeltQuoteOnchainFeeOption] {
1069        &self.fee_options
1070    }
1071
1072    /// Quote amount
1073    #[inline]
1074    pub fn amount(&self) -> Amount<CurrencyUnit> {
1075        self.amount.clone()
1076    }
1077
1078    /// Fee reserve
1079    #[inline]
1080    pub fn fee_reserve(&self) -> Amount<CurrencyUnit> {
1081        self.fee_reserve.clone()
1082    }
1083
1084    /// Select an onchain fee option by its `fee_index`.
1085    pub fn select_onchain_fee_option(&mut self, fee_index: u32) -> Result<(), crate::Error> {
1086        let option = self
1087            .fee_options
1088            .iter()
1089            .find(|option| option.fee_index == fee_index)
1090            .copied()
1091            .ok_or(crate::Error::OnchainFeeIndexNotFound { index: fee_index })?;
1092
1093        if self
1094            .selected_fee_index
1095            .is_some_and(|selected| selected != fee_index)
1096        {
1097            return Err(crate::Error::InvalidPaymentRequest);
1098        }
1099
1100        self.fee_reserve = option.fee_reserve.with_unit(self.unit.clone());
1101        self.estimated_blocks = Some(option.estimated_blocks);
1102        self.selected_fee_index = Some(fee_index);
1103
1104        Ok(())
1105    }
1106
1107    /// Convert into `MeltQuoteResponse`, overriding `change` on the inner
1108    /// response with the provided signatures.
1109    ///
1110    /// Dispatches to the per-variant `From<MeltQuote>` conversions so that
1111    /// field mapping stays centralized.
1112    pub fn into_response(
1113        self,
1114        change: Option<Vec<cashu::nuts::BlindSignature>>,
1115    ) -> crate::MeltQuoteResponse<QuoteId> {
1116        match self.payment_method {
1117            PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Bolt11) => {
1118                let mut response: MeltQuoteBolt11Response<QuoteId> = self.into();
1119                response.change = change;
1120                crate::MeltQuoteResponse::Bolt11(response)
1121            }
1122            PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Bolt12) => {
1123                let mut response: MeltQuoteBolt12Response<QuoteId> = self.into();
1124                response.change = change;
1125                crate::MeltQuoteResponse::Bolt12(response)
1126            }
1127            PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain) => {
1128                let mut response: MeltQuoteOnchainResponse<QuoteId> = self.into();
1129                response.change = change;
1130                crate::MeltQuoteResponse::Onchain(response)
1131            }
1132            _ => {
1133                let method = self.payment_method.clone();
1134                let mut response: MeltQuoteCustomResponse<QuoteId> = self.into();
1135                response.change = change;
1136                crate::MeltQuoteResponse::Custom((method, response))
1137            }
1138        }
1139    }
1140
1141    /// Total amount needed (amount + fee_reserve)
1142    pub fn total_needed(&self) -> Result<Amount, crate::Error> {
1143        let total = self
1144            .amount
1145            .checked_add(&self.fee_reserve)
1146            .map_err(|_| crate::Error::AmountOverflow)?;
1147        Ok(Amount::from(total.value()))
1148    }
1149
1150    /// Create MeltQuote from database fields (for deserialization)
1151    #[allow(clippy::too_many_arguments)]
1152    pub fn from_db(
1153        id: QuoteId,
1154        unit: CurrencyUnit,
1155        request: MeltPaymentRequest,
1156        amount: u64,
1157        fee_reserve: u64,
1158        state: MeltQuoteState,
1159        expiry: u64,
1160        payment_proof: Option<String>,
1161        request_lookup_id: Option<PaymentIdentifier>,
1162        options: Option<MeltOptions>,
1163        created_time: u64,
1164        paid_time: Option<u64>,
1165        payment_method: PaymentMethod,
1166        extra_json: Option<serde_json::Value>,
1167        estimated_blocks: Option<u32>,
1168        fee_options: Vec<MeltQuoteOnchainFeeOption>,
1169        selected_fee_index: Option<u32>,
1170    ) -> Result<Self, crate::Error> {
1171        // For onchain quotes, re-validate the persisted `fee_options` so a
1172        // corrupted or hand-edited row cannot silently be served as a valid
1173        // quote. Non-onchain quotes legitimately carry an empty vec and are
1174        // skipped.
1175        if payment_method == PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain) {
1176            validate_onchain_fee_options(&fee_options)?;
1177        }
1178
1179        Ok(Self {
1180            id,
1181            unit: unit.clone(),
1182            request,
1183            amount: Amount::new(amount, unit.clone()),
1184            fee_reserve: Amount::new(fee_reserve, unit),
1185            state,
1186            expiry,
1187            payment_proof,
1188            request_lookup_id,
1189            options,
1190            created_time,
1191            paid_time,
1192            payment_method,
1193            extra_json,
1194            estimated_blocks,
1195            fee_options,
1196            selected_fee_index,
1197        })
1198    }
1199}
1200
1201/// Validate the NUT `fee_options` rules for an onchain melt quote.
1202///
1203/// Per spec, for every onchain melt quote the mint MUST return at least one
1204/// `fee_options` item.
1205///
1206/// Returns:
1207/// - [`Error::OnchainFeeOptionsEmpty`]
1208///   when the slice is empty.
1209pub fn validate_onchain_fee_options(
1210    fee_options: &[MeltQuoteOnchainFeeOption],
1211) -> Result<(), crate::Error> {
1212    if fee_options.is_empty() {
1213        return Err(crate::Error::OnchainFeeOptionsEmpty);
1214    }
1215
1216    Ok(())
1217}
1218
1219impl From<MeltQuote> for MeltQuoteOnchainResponse<QuoteId> {
1220    fn from(quote: MeltQuote) -> Self {
1221        Self {
1222            quote: quote.id.clone(),
1223            amount: quote.amount().into(),
1224            unit: quote.unit.clone(),
1225            method: PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain),
1226            state: quote.state,
1227            expiry: quote.expiry,
1228            request: quote.request.to_string(),
1229            fee_options: quote.fee_options().to_vec(),
1230            selected_fee_index: quote.selected_fee_index,
1231            outpoint: quote.payment_proof.clone(),
1232            change: None,
1233        }
1234    }
1235}
1236
1237impl TryFrom<MintQuote> for MintQuoteOnchainResponse<QuoteId> {
1238    type Error = crate::error::Error;
1239    fn try_from(quote: MintQuote) -> Result<Self, Self::Error> {
1240        Ok(Self {
1241            quote: quote.id.clone(),
1242            request: quote.request.clone(),
1243            unit: quote.unit.clone(),
1244            method: PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain),
1245            expiry: (quote.expiry != 0).then_some(quote.expiry),
1246            pubkey: quote.pubkey.ok_or(crate::error::Error::MissingPubkey)?,
1247            amount_paid: quote.amount_paid().into(),
1248            amount_issued: quote.amount_issued().into(),
1249            updated_at: quote.updated_at(),
1250        })
1251    }
1252}
1253
1254/// Mint Keyset Info
1255#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
1256pub struct MintKeySetInfo {
1257    /// Keyset [`Id`]
1258    pub id: Id,
1259    /// Keyset [`CurrencyUnit`]
1260    pub unit: CurrencyUnit,
1261    /// Keyset active or inactive
1262    /// Mint will only issue new signatures on active keysets
1263    pub active: bool,
1264    /// Starting unix time Keyset is valid from
1265    pub valid_from: u64,
1266    /// [`DerivationPath`] keyset
1267    pub derivation_path: DerivationPath,
1268    /// DerivationPath index of Keyset
1269    pub derivation_path_index: Option<u32>,
1270    /// Supported amounts
1271    pub amounts: Vec<u64>,
1272    /// Input Fee ppk
1273    #[serde(default = "default_fee")]
1274    pub input_fee_ppk: u64,
1275    /// Final expiry
1276    pub final_expiry: Option<u64>,
1277    /// Issuer Version
1278    pub issuer_version: Option<IssuerVersion>,
1279}
1280
1281impl MintKeySetInfo {
1282    /// Returns true if `final_expiry` is set and strictly in the past.
1283    pub fn is_expired(&self) -> bool {
1284        self.final_expiry.is_some_and(|expiry| expiry < unix_time())
1285    }
1286}
1287
1288/// Default fee
1289pub fn default_fee() -> u64 {
1290    0
1291}
1292
1293impl From<MintKeySetInfo> for KeySetInfo {
1294    fn from(keyset_info: MintKeySetInfo) -> Self {
1295        Self {
1296            id: keyset_info.id,
1297            unit: keyset_info.unit,
1298            active: keyset_info.active,
1299            input_fee_ppk: keyset_info.input_fee_ppk,
1300            final_expiry: keyset_info.final_expiry,
1301        }
1302    }
1303}
1304
1305impl From<MintQuote> for MintQuoteBolt11Response<QuoteId> {
1306    fn from(mint_quote: MintQuote) -> MintQuoteBolt11Response<QuoteId> {
1307        let amount_paid = mint_quote.amount_paid().into();
1308        let amount_issued = mint_quote.amount_issued().into();
1309        let updated_at = mint_quote.updated_at();
1310
1311        MintQuoteBolt11Response {
1312            quote: mint_quote.id.clone(),
1313            state: mint_quote.state(),
1314            request: mint_quote.request,
1315            expiry: Some(mint_quote.expiry),
1316            pubkey: mint_quote.pubkey,
1317            amount: mint_quote.amount.map(Into::into),
1318            unit: Some(mint_quote.unit),
1319            method: PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Bolt11),
1320            amount_paid,
1321            amount_issued,
1322            updated_at,
1323        }
1324    }
1325}
1326
1327impl From<MintQuote> for MintQuoteBolt11Response<String> {
1328    fn from(quote: MintQuote) -> Self {
1329        let quote: MintQuoteBolt11Response<QuoteId> = quote.into();
1330        quote.into()
1331    }
1332}
1333
1334impl TryFrom<MintQuote> for MintQuoteBolt12Response<QuoteId> {
1335    type Error = Error;
1336
1337    fn try_from(mint_quote: MintQuote) -> Result<Self, Self::Error> {
1338        let amount_paid = mint_quote.amount_paid().into();
1339        let amount_issued = mint_quote.amount_issued().into();
1340        let updated_at = mint_quote.updated_at();
1341
1342        Ok(MintQuoteBolt12Response {
1343            quote: mint_quote.id.clone(),
1344            request: mint_quote.request,
1345            expiry: (mint_quote.expiry != 0).then_some(mint_quote.expiry),
1346            amount_paid,
1347            amount_issued,
1348            pubkey: mint_quote.pubkey.ok_or(Error::PubkeyRequired)?,
1349            amount: mint_quote.amount.map(Into::into),
1350            unit: mint_quote.unit,
1351            method: PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Bolt12),
1352            updated_at,
1353        })
1354    }
1355}
1356
1357impl TryFrom<MintQuote> for MintQuoteBolt12Response<String> {
1358    type Error = Error;
1359
1360    fn try_from(quote: MintQuote) -> Result<Self, Self::Error> {
1361        let quote: MintQuoteBolt12Response<QuoteId> = quote.try_into()?;
1362        Ok(quote.into())
1363    }
1364}
1365
1366impl TryFrom<MintQuote> for MintQuoteCustomResponse<QuoteId> {
1367    type Error = Error;
1368
1369    fn try_from(quote: MintQuote) -> Result<Self, Self::Error> {
1370        let amount_paid = quote.amount_paid().into();
1371        let amount_issued = quote.amount_issued().into();
1372        let updated_at = quote.updated_at();
1373
1374        Ok(MintQuoteCustomResponse {
1375            quote: quote.id,
1376            request: quote.request,
1377            method: quote.payment_method,
1378            unit: Some(quote.unit),
1379            expiry: Some(quote.expiry),
1380            pubkey: quote.pubkey,
1381            amount: quote.amount.map(Into::into),
1382            amount_paid,
1383            amount_issued,
1384            updated_at,
1385            extra: quote.extra_json.unwrap_or_default(),
1386        })
1387    }
1388}
1389
1390impl TryFrom<MintQuote> for MintQuoteCustomResponse<String> {
1391    type Error = Error;
1392
1393    fn try_from(quote: MintQuote) -> Result<Self, Self::Error> {
1394        let quote: MintQuoteCustomResponse<QuoteId> = quote.try_into()?;
1395        Ok(quote.into())
1396    }
1397}
1398
1399impl From<MeltQuote> for crate::nuts::MeltQuoteCustomResponse<QuoteId> {
1400    fn from(melt_quote: MeltQuote) -> Self {
1401        let method = melt_quote.payment_method.clone();
1402        let request = match melt_quote.request {
1403            MeltPaymentRequest::Custom { request, .. } => Some(request),
1404            _ => None,
1405        };
1406
1407        Self {
1408            quote: melt_quote.id,
1409            method,
1410            amount: melt_quote.amount.into(),
1411            fee_reserve: Some(melt_quote.fee_reserve.into()),
1412            state: melt_quote.state,
1413            expiry: melt_quote.expiry,
1414            payment_preimage: melt_quote.payment_proof,
1415            change: None,
1416            request,
1417            unit: Some(melt_quote.unit),
1418            extra: melt_quote.extra_json.unwrap_or_default(),
1419        }
1420    }
1421}
1422
1423impl From<&MeltQuote> for MeltQuoteBolt12Response<QuoteId> {
1424    fn from(melt_quote: &MeltQuote) -> MeltQuoteBolt12Response<QuoteId> {
1425        MeltQuoteBolt12Response {
1426            quote: melt_quote.id.clone(),
1427            payment_preimage: None,
1428            change: None,
1429            state: melt_quote.state,
1430            expiry: melt_quote.expiry,
1431            amount: melt_quote.amount().into(),
1432            fee_reserve: melt_quote.fee_reserve().into(),
1433            request: None,
1434            unit: Some(melt_quote.unit.clone()),
1435            method: PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Bolt12),
1436        }
1437    }
1438}
1439
1440impl From<MeltQuote> for MeltQuoteBolt12Response<QuoteId> {
1441    fn from(melt_quote: MeltQuote) -> MeltQuoteBolt12Response<QuoteId> {
1442        MeltQuoteBolt12Response {
1443            quote: melt_quote.id.clone(),
1444            amount: melt_quote.amount().into(),
1445            fee_reserve: melt_quote.fee_reserve().into(),
1446            state: melt_quote.state,
1447            expiry: melt_quote.expiry,
1448            payment_preimage: melt_quote.payment_proof,
1449            change: None,
1450            request: Some(melt_quote.request.to_string()),
1451            unit: Some(melt_quote.unit.clone()),
1452            method: PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Bolt12),
1453        }
1454    }
1455}
1456
1457impl TryFrom<MintQuote> for MintQuoteResponse<QuoteId> {
1458    type Error = Error;
1459
1460    fn try_from(quote: MintQuote) -> Result<Self, Self::Error> {
1461        if quote.payment_method.is_bolt11() {
1462            Ok(Self::Bolt11(crate::nuts::nut23::MintQuoteBolt11Response {
1463                quote: quote.id.clone(),
1464                request: quote.request.clone(),
1465                state: quote.state(),
1466                expiry: Some(quote.expiry),
1467                amount: quote.amount.as_ref().map(|a| a.clone().into()),
1468                unit: Some(quote.unit.clone()),
1469                method: PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Bolt11),
1470                pubkey: quote.pubkey,
1471                amount_paid: quote.amount_paid().into(),
1472                amount_issued: quote.amount_issued().into(),
1473                updated_at: quote.updated_at(),
1474            }))
1475        } else if quote.payment_method.is_bolt12() {
1476            Ok(Self::Bolt12(crate::nuts::nut25::MintQuoteBolt12Response {
1477                quote: quote.id.clone(),
1478                request: quote.request.clone(),
1479                amount: quote.amount.as_ref().map(|a| a.clone().into()),
1480                unit: quote.unit.clone(),
1481                method: PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Bolt12),
1482                expiry: (quote.expiry != 0).then_some(quote.expiry),
1483                pubkey: quote.pubkey.ok_or(Error::PubkeyRequired)?,
1484                amount_paid: quote.amount_paid().into(),
1485                amount_issued: quote.amount_issued().into(),
1486                updated_at: quote.updated_at(),
1487            }))
1488        } else if quote.payment_method.is_onchain() {
1489            let onchain_response = MintQuoteOnchainResponse::try_from(quote)?;
1490            Ok(MintQuoteResponse::Onchain(onchain_response))
1491        } else {
1492            let method = quote.payment_method.clone();
1493            Ok(MintQuoteResponse::Custom {
1494                method: method.clone(),
1495                response: crate::nuts::nut04::MintQuoteCustomResponse {
1496                    quote: quote.id.clone(),
1497                    request: quote.request.clone(),
1498                    method: method.clone(),
1499                    expiry: Some(quote.expiry),
1500                    amount: quote.amount.as_ref().map(|a| a.clone().into()),
1501                    amount_paid: quote.amount_paid().into(),
1502                    amount_issued: quote.amount_issued().into(),
1503                    updated_at: quote.updated_at(),
1504                    unit: Some(quote.unit.clone()),
1505                    pubkey: quote.pubkey,
1506                    extra: quote.extra_json.clone().unwrap_or_default(),
1507                },
1508            })
1509        }
1510    }
1511}
1512
1513impl From<MintQuoteResponse<QuoteId>> for MintQuoteResponse<String> {
1514    fn from(response: MintQuoteResponse<QuoteId>) -> Self {
1515        match response {
1516            MintQuoteResponse::Bolt11(response) => MintQuoteResponse::Bolt11(response.into()),
1517            MintQuoteResponse::Bolt12(response) => MintQuoteResponse::Bolt12(response.into()),
1518            MintQuoteResponse::Onchain(response) => MintQuoteResponse::Onchain(response.into()),
1519            MintQuoteResponse::Custom { method, response } => MintQuoteResponse::Custom {
1520                method,
1521                response: response.into(),
1522            },
1523        }
1524    }
1525}
1526
1527impl From<MintQuoteResponse<QuoteId>> for MintQuoteBolt11Response<String> {
1528    fn from(response: MintQuoteResponse<QuoteId>) -> Self {
1529        match response {
1530            MintQuoteResponse::Bolt11(bolt11_response) => MintQuoteBolt11Response {
1531                quote: bolt11_response.quote.to_string(),
1532                state: bolt11_response.state,
1533                request: bolt11_response.request,
1534                expiry: bolt11_response.expiry,
1535                pubkey: bolt11_response.pubkey,
1536                amount: bolt11_response.amount,
1537                unit: bolt11_response.unit,
1538                method: bolt11_response.method,
1539                amount_paid: bolt11_response.amount_paid,
1540                amount_issued: bolt11_response.amount_issued,
1541                updated_at: bolt11_response.updated_at,
1542            },
1543            _ => panic!("Expected Bolt11 response"),
1544        }
1545    }
1546}
1547
1548impl TryFrom<MintQuoteResponse<QuoteId>> for MintQuoteBolt11Response<QuoteId> {
1549    type Error = Error;
1550
1551    fn try_from(response: MintQuoteResponse<QuoteId>) -> Result<Self, Self::Error> {
1552        match response {
1553            MintQuoteResponse::Bolt11(r) => Ok(r),
1554            _ => Err(Error::InvalidPaymentMethod),
1555        }
1556    }
1557}
1558
1559impl TryFrom<MintQuoteResponse<QuoteId>> for MintQuoteBolt12Response<QuoteId> {
1560    type Error = Error;
1561
1562    fn try_from(response: MintQuoteResponse<QuoteId>) -> Result<Self, Self::Error> {
1563        match response {
1564            MintQuoteResponse::Bolt12(r) => Ok(r),
1565            _ => Err(Error::InvalidPaymentMethod),
1566        }
1567    }
1568}
1569
1570impl TryFrom<MintQuoteResponse<QuoteId>> for MintQuoteOnchainResponse<QuoteId> {
1571    type Error = Error;
1572
1573    fn try_from(response: MintQuoteResponse<QuoteId>) -> Result<Self, Self::Error> {
1574        match response {
1575            MintQuoteResponse::Onchain(r) => Ok(r),
1576            _ => Err(Error::InvalidPaymentMethod),
1577        }
1578    }
1579}
1580
1581impl From<&MeltQuote> for MeltQuoteBolt11Response<QuoteId> {
1582    fn from(melt_quote: &MeltQuote) -> MeltQuoteBolt11Response<QuoteId> {
1583        MeltQuoteBolt11Response {
1584            quote: melt_quote.id.clone(),
1585            payment_preimage: None,
1586            change: None,
1587            state: melt_quote.state,
1588            expiry: melt_quote.expiry,
1589            amount: melt_quote.amount().into(),
1590            fee_reserve: melt_quote.fee_reserve().into(),
1591            request: None,
1592            unit: Some(melt_quote.unit.clone()),
1593            method: melt_quote.payment_method.clone(),
1594        }
1595    }
1596}
1597
1598impl From<MeltQuote> for MeltQuoteBolt11Response<QuoteId> {
1599    fn from(melt_quote: MeltQuote) -> MeltQuoteBolt11Response<QuoteId> {
1600        MeltQuoteBolt11Response {
1601            quote: melt_quote.id.clone(),
1602            amount: melt_quote.amount().into(),
1603            fee_reserve: melt_quote.fee_reserve().into(),
1604            state: melt_quote.state,
1605            expiry: melt_quote.expiry,
1606            payment_preimage: melt_quote.payment_proof,
1607            change: None,
1608            request: Some(melt_quote.request.to_string()),
1609            unit: Some(melt_quote.unit.clone()),
1610            method: melt_quote.payment_method.clone(),
1611        }
1612    }
1613}
1614
1615/// Payment request
1616#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
1617pub enum MeltPaymentRequest {
1618    /// Bolt11 Payment
1619    Bolt11 {
1620        /// Bolt11 invoice
1621        bolt11: Bolt11Invoice,
1622    },
1623    /// Bolt12 Payment
1624    Bolt12 {
1625        /// Offer
1626        #[serde(with = "offer_serde")]
1627        offer: Box<Offer>,
1628    },
1629    /// Custom payment method
1630    Custom {
1631        /// Payment method name
1632        method: String,
1633        /// Payment request string
1634        request: String,
1635    },
1636    /// Onchain Payment
1637    Onchain {
1638        /// Onchain address
1639        address: String,
1640    },
1641}
1642
1643impl std::fmt::Display for MeltPaymentRequest {
1644    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1645        match self {
1646            MeltPaymentRequest::Bolt11 { bolt11 } => write!(f, "{bolt11}"),
1647            MeltPaymentRequest::Bolt12 { offer } => write!(f, "{offer}"),
1648            MeltPaymentRequest::Custom { request, .. } => write!(f, "{request}"),
1649            MeltPaymentRequest::Onchain { address } => write!(f, "{address}"),
1650        }
1651    }
1652}
1653
1654mod offer_serde {
1655    use std::str::FromStr;
1656
1657    use serde::{self, Deserialize, Deserializer, Serializer};
1658
1659    use super::Offer;
1660
1661    pub fn serialize<S>(offer: &Offer, serializer: S) -> Result<S::Ok, S::Error>
1662    where
1663        S: Serializer,
1664    {
1665        let s = offer.to_string();
1666        serializer.serialize_str(&s)
1667    }
1668
1669    pub fn deserialize<'de, D>(deserializer: D) -> Result<Box<Offer>, D::Error>
1670    where
1671        D: Deserializer<'de>,
1672    {
1673        let s = String::deserialize(deserializer)?;
1674        Ok(Box::new(Offer::from_str(&s).map_err(|_| {
1675            serde::de::Error::custom("Invalid Bolt12 Offer")
1676        })?))
1677    }
1678}
1679
1680#[cfg(test)]
1681mod tests {
1682    use std::str::FromStr;
1683
1684    use cashu::Bolt11Invoice;
1685
1686    use super::*;
1687
1688    #[test]
1689    fn test_operation_new_mint_uses_uuid_v7() {
1690        let operation = Operation::new_mint(Amount::from(100), PaymentMethod::BOLT11);
1691
1692        assert_eq!(operation.id.get_version(), Some(uuid::Version::SortRand));
1693    }
1694
1695    #[test]
1696    fn mint_payment_records_debug_redact_payment_proofs() {
1697        let secret = "mint-payment-preimage-secret";
1698        let lookup_id = PaymentIdentifier::CustomId("public-lookup-id".to_string());
1699        let mut quote = MeltQuote::new(
1700            Some(QuoteId::new()),
1701            MeltPaymentRequest::Custom {
1702                method: "custom".to_string(),
1703                request: "public-payment-request".to_string(),
1704            },
1705            CurrencyUnit::Sat,
1706            Amount::new(100, CurrencyUnit::Sat),
1707            Amount::new(2, CurrencyUnit::Sat),
1708            unix_time() + 3_600,
1709            Some(lookup_id.clone()),
1710            None,
1711            PaymentMethod::Custom("custom".to_string()),
1712            None,
1713            None,
1714        );
1715        quote.payment_proof = Some(secret.to_string());
1716        let finalization = MeltFinalizationData {
1717            total_spent: Amount::new(102, CurrencyUnit::Sat),
1718            payment_lookup_id: lookup_id,
1719            payment_proof: Some(secret.to_string()),
1720        };
1721
1722        for debug in [format!("{quote:?}"), format!("{finalization:?}")] {
1723            assert!(debug.contains("public-lookup-id"));
1724            assert!(debug.contains("[REDACTED]"));
1725            assert!(!debug.contains(secret));
1726        }
1727    }
1728
1729    #[test]
1730    fn test_melt_quote_to_custom_response_with_custom_request() {
1731        let melt_quote = MeltQuote::new(
1732            Some(QuoteId::new()),
1733            MeltPaymentRequest::Custom {
1734                method: "custom".to_string(),
1735                request: "custom_request_string".to_string(),
1736            },
1737            CurrencyUnit::Sat,
1738            Amount::new(100, CurrencyUnit::Sat),
1739            Amount::new(2, CurrencyUnit::Sat),
1740            unix_time() + 3600,
1741            None,
1742            None,
1743            PaymentMethod::Custom("custom".to_string()),
1744            Some(serde_json::json!({"extra_field": "value"})),
1745            None,
1746        );
1747
1748        let response: crate::nuts::MeltQuoteCustomResponse<QuoteId> = melt_quote.clone().into();
1749
1750        assert_eq!(response.quote, melt_quote.id);
1751        assert_eq!(response.amount, 100.into());
1752        assert_eq!(response.fee_reserve, Some(2.into()));
1753        assert_eq!(response.state, melt_quote.state);
1754        assert_eq!(response.expiry, melt_quote.expiry);
1755        assert_eq!(response.payment_preimage, melt_quote.payment_proof);
1756        assert_eq!(response.change, None);
1757        assert_eq!(response.request, Some("custom_request_string".to_string()));
1758        assert_eq!(response.unit, Some(CurrencyUnit::Sat));
1759        assert_eq!(response.extra, serde_json::json!({"extra_field": "value"}));
1760    }
1761
1762    #[test]
1763    fn test_melt_quote_to_custom_response_with_bolt11_request() {
1764        let bolt11_str = "lnbc100n1pnvpufspp5djn8hrq49r8cghwye9kqw752qjncwyfnrprhprpqk43mwcy4yfsqdq5g9kxy7fqd9h8vmmfvdjscqzzsxqyz5vqsp5uhpjt36rj75pl7jq2sshaukzfkt7uulj456s4mh7uy7l6vx7lvxs9qxpqysgqedwz08acmqwtk8g4vkwm2w78suwt2qyzz6jkkwcgrjm3r3hs6fskyhvud4fan3keru7emjm8ygqpcrwtlmhfjfmer3afs5hhwamgr4cqtactdq";
1765        let bolt11 = Bolt11Invoice::from_str(bolt11_str).unwrap();
1766
1767        let melt_quote = MeltQuote::new(
1768            Some(QuoteId::new()),
1769            MeltPaymentRequest::Bolt11 { bolt11 },
1770            CurrencyUnit::Sat,
1771            Amount::new(100, CurrencyUnit::Sat),
1772            Amount::new(2, CurrencyUnit::Sat),
1773            unix_time() + 3600,
1774            None,
1775            None,
1776            PaymentMethod::BOLT11,
1777            None,
1778            None,
1779        );
1780
1781        let response: crate::nuts::MeltQuoteCustomResponse<QuoteId> = melt_quote.clone().into();
1782
1783        assert_eq!(response.quote, melt_quote.id);
1784        assert_eq!(response.request, None);
1785    }
1786
1787    #[test]
1788    fn test_melt_quote_to_custom_response_with_bolt12_request() {
1789        use bitcoin::secp256k1::{PublicKey as Secp256k1PublicKey, Secp256k1, SecretKey};
1790        use lightning::offers::offer::OfferBuilder;
1791        let secp = Secp256k1::new();
1792        let secret_key = SecretKey::from_slice(&[0xcd; 32]).unwrap();
1793        let pubkey = Secp256k1PublicKey::from_secret_key(&secp, &secret_key);
1794        let offer = OfferBuilder::new(pubkey).build().unwrap();
1795
1796        let melt_quote = MeltQuote::new(
1797            Some(QuoteId::new()),
1798            MeltPaymentRequest::Bolt12 {
1799                offer: Box::new(offer),
1800            },
1801            CurrencyUnit::Sat,
1802            Amount::new(100, CurrencyUnit::Sat),
1803            Amount::new(2, CurrencyUnit::Sat),
1804            unix_time() + 3600,
1805            None,
1806            None,
1807            PaymentMethod::BOLT12,
1808            None,
1809            None,
1810        );
1811
1812        let response: crate::nuts::MeltQuoteCustomResponse<QuoteId> = melt_quote.clone().into();
1813
1814        assert_eq!(response.quote, melt_quote.id);
1815        assert_eq!(response.request, None);
1816    }
1817
1818    fn dummy_mint_keyset_info(final_expiry: Option<u64>) -> MintKeySetInfo {
1819        use std::str::FromStr;
1820        MintKeySetInfo {
1821            id: Id::from_str("009a1f293253e41e").unwrap(),
1822            unit: CurrencyUnit::Sat,
1823            active: true,
1824            valid_from: 0,
1825            derivation_path: "m/0'/0'/0'".parse().unwrap(),
1826            derivation_path_index: Some(0),
1827            amounts: vec![1, 2, 4, 8, 16, 32, 64, 128, 256, 512],
1828            input_fee_ppk: 0,
1829            final_expiry,
1830            issuer_version: None,
1831        }
1832    }
1833
1834    #[test]
1835    fn test_is_expired_none() {
1836        let info = dummy_mint_keyset_info(None);
1837        assert!(!info.is_expired());
1838    }
1839
1840    #[test]
1841    fn test_is_expired_far_future() {
1842        let info = dummy_mint_keyset_info(Some(unix_time() + 1_000_000));
1843        assert!(!info.is_expired());
1844    }
1845
1846    #[test]
1847    fn test_is_expired_exactly_now_is_not_expired() {
1848        // strict less-than: expiry == now is not yet expired
1849        let info = dummy_mint_keyset_info(Some(unix_time()));
1850        assert!(!info.is_expired());
1851    }
1852
1853    #[test]
1854    fn test_is_expired_one_second_ago() {
1855        let info = dummy_mint_keyset_info(Some(unix_time() - 1));
1856        assert!(info.is_expired());
1857    }
1858
1859    #[test]
1860    fn test_is_expired_zero() {
1861        let info = dummy_mint_keyset_info(Some(0));
1862        assert!(info.is_expired());
1863    }
1864
1865    #[test]
1866    fn test_melt_quote_into_response_onchain() {
1867        let address = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq";
1868        let mut melt_quote = MeltQuote::new(
1869            Some(QuoteId::new()),
1870            MeltPaymentRequest::Onchain {
1871                address: address.to_string(),
1872            },
1873            CurrencyUnit::Sat,
1874            Amount::new(5_000, CurrencyUnit::Sat),
1875            Amount::new(250, CurrencyUnit::Sat),
1876            unix_time() + 3600,
1877            None,
1878            None,
1879            PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain),
1880            None,
1881            Some(6),
1882        );
1883
1884        // Simulate the terminal paid path: payment_proof becomes the broadcast outpoint.
1885        melt_quote.payment_proof =
1886            Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:1".to_string());
1887        melt_quote.state = MeltQuoteState::Paid;
1888
1889        let expected_id = melt_quote.id.clone();
1890        let expected_amount: Amount = melt_quote.amount().into();
1891        let expected_fee_options = melt_quote.fee_options().to_vec();
1892        let expected_expiry = melt_quote.expiry;
1893        let expected_state = melt_quote.state;
1894        let expected_outpoint = melt_quote.payment_proof.clone();
1895
1896        let response = melt_quote.into_response(None);
1897        match response {
1898            crate::MeltQuoteResponse::Onchain(r) => {
1899                assert_eq!(r.quote, expected_id);
1900                assert_eq!(r.request, address);
1901                assert_eq!(r.amount, expected_amount);
1902                assert_eq!(r.unit, CurrencyUnit::Sat);
1903                assert_eq!(r.fee_options, expected_fee_options);
1904                assert_eq!(r.selected_fee_index, None);
1905                assert_eq!(r.state, expected_state);
1906                assert_eq!(r.expiry, expected_expiry);
1907                assert_eq!(r.outpoint, expected_outpoint);
1908                assert_eq!(r.change, None);
1909            }
1910            _ => panic!("expected MeltQuoteResponse::Onchain variant"),
1911        }
1912    }
1913
1914    #[test]
1915    fn test_mint_quote_onchain_response_converts_zero_expiry_to_none() {
1916        let pubkey = PublicKey::from_hex(
1917            "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac",
1918        )
1919        .unwrap();
1920        let quote_id = QuoteId::new();
1921        let now = unix_time();
1922        let mint_quote = MintQuote::new(
1923            Some(quote_id.clone()),
1924            "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh".to_string(),
1925            CurrencyUnit::Sat,
1926            None,
1927            0,
1928            PaymentIdentifier::QuoteId(quote_id.clone()),
1929            Some(pubkey),
1930            Amount::new(10_000, CurrencyUnit::Sat),
1931            Amount::new(1_000, CurrencyUnit::Sat),
1932            PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain),
1933            now,
1934            now,
1935            vec![],
1936            vec![],
1937            None,
1938        );
1939
1940        let response = MintQuoteOnchainResponse::try_from(mint_quote).unwrap();
1941
1942        assert_eq!(response.quote, quote_id);
1943        assert_eq!(response.expiry, None);
1944        assert_eq!(response.pubkey, pubkey);
1945        assert_eq!(response.amount_paid, Amount::from(10_000));
1946        assert_eq!(response.amount_issued, Amount::from(1_000));
1947    }
1948
1949    fn dummy_bolt12_mint_quote(expiry: u64) -> (MintQuote, QuoteId, PublicKey) {
1950        let pubkey = PublicKey::from_hex(
1951            "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac",
1952        )
1953        .expect("test pubkey must parse");
1954        let quote_id = QuoteId::new();
1955        let now = unix_time();
1956        let mint_quote = MintQuote::new(
1957            Some(quote_id.clone()),
1958            "lno1testoffer".to_string(),
1959            CurrencyUnit::Sat,
1960            Some(Amount::new(10_000, CurrencyUnit::Sat)),
1961            expiry,
1962            PaymentIdentifier::QuoteId(quote_id.clone()),
1963            Some(pubkey),
1964            Amount::new(10_000, CurrencyUnit::Sat),
1965            Amount::new(1_000, CurrencyUnit::Sat),
1966            PaymentMethod::BOLT12,
1967            now,
1968            now,
1969            vec![],
1970            vec![],
1971            None,
1972        );
1973
1974        (mint_quote, quote_id, pubkey)
1975    }
1976
1977    #[test]
1978    fn test_mint_quote_bolt12_response_converts_zero_expiry_to_none() {
1979        let (mint_quote, quote_id, pubkey) = dummy_bolt12_mint_quote(0);
1980
1981        let response: MintQuoteBolt12Response<QuoteId> =
1982            MintQuoteBolt12Response::try_from(mint_quote).unwrap();
1983
1984        assert_eq!(response.quote, quote_id);
1985        assert_eq!(response.expiry, None);
1986        assert_eq!(response.pubkey, pubkey);
1987        assert_eq!(response.amount_paid, Amount::from(10_000));
1988        assert_eq!(response.amount_issued, Amount::from(1_000));
1989    }
1990
1991    #[test]
1992    fn test_mint_quote_bolt12_response_preserves_nonzero_expiry() {
1993        let expiry = unix_time() + 3600;
1994        let (mint_quote, quote_id, _) = dummy_bolt12_mint_quote(expiry);
1995
1996        let response: MintQuoteBolt12Response<QuoteId> =
1997            MintQuoteBolt12Response::try_from(mint_quote).unwrap();
1998
1999        assert_eq!(response.quote, quote_id);
2000        assert_eq!(response.expiry, Some(expiry));
2001    }
2002
2003    #[test]
2004    fn test_mint_quote_response_bolt12_converts_zero_expiry_to_none() {
2005        let (mint_quote, quote_id, pubkey) = dummy_bolt12_mint_quote(0);
2006
2007        let response = MintQuoteResponse::try_from(mint_quote).unwrap();
2008
2009        match response {
2010            MintQuoteResponse::Bolt12(response) => {
2011                assert_eq!(response.quote, quote_id);
2012                assert_eq!(response.expiry, None);
2013                assert_eq!(response.pubkey, pubkey);
2014            }
2015            _ => panic!("expected MintQuoteResponse::Bolt12 variant"),
2016        }
2017    }
2018
2019    #[test]
2020    fn test_melt_quote_into_response_onchain_includes_change() {
2021        let address = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq";
2022        let melt_quote = MeltQuote::new(
2023            Some(QuoteId::new()),
2024            MeltPaymentRequest::Onchain {
2025                address: address.to_string(),
2026            },
2027            CurrencyUnit::Sat,
2028            Amount::new(1_000, CurrencyUnit::Sat),
2029            Amount::new(10, CurrencyUnit::Sat),
2030            unix_time() + 3600,
2031            None,
2032            None,
2033            PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain),
2034            None,
2035            Some(3),
2036        );
2037
2038        let response = melt_quote.into_response(Some(vec![]));
2039        match response {
2040            crate::MeltQuoteResponse::Onchain(r) => assert_eq!(r.change, Some(vec![])),
2041            _ => panic!("expected MeltQuoteResponse::Onchain variant"),
2042        }
2043    }
2044
2045    #[test]
2046    fn validate_onchain_fee_options_rejects_empty() {
2047        let err = validate_onchain_fee_options(&[]).expect_err("empty must be rejected");
2048        assert!(matches!(err, crate::Error::OnchainFeeOptionsEmpty));
2049    }
2050
2051    #[test]
2052    fn validate_onchain_fee_options_allows_duplicate_fee_index() {
2053        let options = [
2054            MeltQuoteOnchainFeeOption {
2055                fee_index: 10,
2056                fee_reserve: Amount::from(10),
2057                estimated_blocks: 3,
2058            },
2059            MeltQuoteOnchainFeeOption {
2060                fee_index: 10,
2061                fee_reserve: Amount::from(20),
2062                estimated_blocks: 6,
2063            },
2064        ];
2065        validate_onchain_fee_options(&options).expect("duplicate fee_index must be allowed");
2066    }
2067
2068    #[test]
2069    fn validate_onchain_fee_options_allows_duplicate_estimated_blocks() {
2070        // With selection by fee_index, duplicate estimated_blocks values are
2071        // permitted (although unusual).
2072        let options = [
2073            MeltQuoteOnchainFeeOption {
2074                fee_index: 20,
2075                fee_reserve: Amount::from(10),
2076                estimated_blocks: 3,
2077            },
2078            MeltQuoteOnchainFeeOption {
2079                fee_index: 1,
2080                fee_reserve: Amount::from(20),
2081                estimated_blocks: 3,
2082            },
2083        ];
2084        validate_onchain_fee_options(&options).expect("duplicate blocks must be allowed");
2085    }
2086
2087    #[test]
2088    fn validate_onchain_fee_options_allows_duplicate_fee_reserve() {
2089        // With selection by fee_index, duplicate fee_reserve values are
2090        // permitted (although unusual).
2091        let options = [
2092            MeltQuoteOnchainFeeOption {
2093                fee_index: 0,
2094                fee_reserve: Amount::from(42),
2095                estimated_blocks: 1,
2096            },
2097            MeltQuoteOnchainFeeOption {
2098                fee_index: 1,
2099                fee_reserve: Amount::from(42),
2100                estimated_blocks: 6,
2101            },
2102        ];
2103        validate_onchain_fee_options(&options).expect("duplicate fee must be allowed");
2104    }
2105
2106    #[test]
2107    fn validate_onchain_fee_options_accepts_well_formed() {
2108        let options = [
2109            MeltQuoteOnchainFeeOption {
2110                fee_index: 0,
2111                fee_reserve: Amount::from(500),
2112                estimated_blocks: 1,
2113            },
2114            MeltQuoteOnchainFeeOption {
2115                fee_index: 1,
2116                fee_reserve: Amount::from(200),
2117                estimated_blocks: 6,
2118            },
2119            MeltQuoteOnchainFeeOption {
2120                fee_index: 2,
2121                fee_reserve: Amount::from(50),
2122                estimated_blocks: 144,
2123            },
2124        ];
2125        validate_onchain_fee_options(&options).expect("well-formed must validate");
2126    }
2127
2128    #[test]
2129    fn new_onchain_rejects_empty_fee_options() {
2130        let err = MeltQuote::new_onchain(
2131            None,
2132            MeltPaymentRequest::Onchain {
2133                address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq".to_string(),
2134            },
2135            CurrencyUnit::Sat,
2136            Amount::new(1_000, CurrencyUnit::Sat),
2137            unix_time() + 3600,
2138            None,
2139            None,
2140            vec![],
2141        )
2142        .expect_err("empty fee_options must be rejected");
2143        assert!(matches!(err, crate::Error::OnchainFeeOptionsEmpty));
2144    }
2145
2146    #[test]
2147    fn new_onchain_initializes_reserve_to_cheapest_tier() {
2148        // Submit options in an unsorted order to ensure cheapest-by-fee_reserve
2149        // is what wins (not first-in-list).
2150        let options = vec![
2151            MeltQuoteOnchainFeeOption {
2152                fee_index: 10,
2153                fee_reserve: Amount::from(500),
2154                estimated_blocks: 1,
2155            },
2156            MeltQuoteOnchainFeeOption {
2157                fee_index: 30,
2158                fee_reserve: Amount::from(50),
2159                estimated_blocks: 144,
2160            },
2161            MeltQuoteOnchainFeeOption {
2162                fee_index: 20,
2163                fee_reserve: Amount::from(200),
2164                estimated_blocks: 6,
2165            },
2166        ];
2167        let quote = MeltQuote::new_onchain(
2168            None,
2169            MeltPaymentRequest::Onchain {
2170                address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq".to_string(),
2171            },
2172            CurrencyUnit::Sat,
2173            Amount::new(10_000, CurrencyUnit::Sat),
2174            unix_time() + 3600,
2175            None,
2176            None,
2177            options.clone(),
2178        )
2179        .expect("well-formed quote must construct");
2180
2181        assert_eq!(quote.fee_reserve().value(), 50);
2182        assert_eq!(quote.estimated_blocks, Some(144));
2183        assert_eq!(quote.selected_fee_index, None);
2184        let returned: Vec<u32> = quote.fee_options().iter().map(|o| o.fee_index).collect();
2185        assert_eq!(returned, vec![10, 30, 20]);
2186    }
2187
2188    #[test]
2189    fn new_onchain_preserves_duplicate_backend_fee_index() {
2190        let options = vec![
2191            MeltQuoteOnchainFeeOption {
2192                fee_index: 7,
2193                fee_reserve: Amount::from(500),
2194                estimated_blocks: 1,
2195            },
2196            MeltQuoteOnchainFeeOption {
2197                fee_index: 7,
2198                fee_reserve: Amount::from(200),
2199                estimated_blocks: 6,
2200            },
2201        ];
2202        let quote = MeltQuote::new_onchain(
2203            None,
2204            MeltPaymentRequest::Onchain {
2205                address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq".to_string(),
2206            },
2207            CurrencyUnit::Sat,
2208            Amount::new(10_000, CurrencyUnit::Sat),
2209            unix_time() + 3600,
2210            None,
2211            None,
2212            options,
2213        )
2214        .expect("duplicate backend fee_index must be preserved");
2215
2216        let returned: Vec<u32> = quote.fee_options().iter().map(|o| o.fee_index).collect();
2217        assert_eq!(returned, vec![7, 7]);
2218    }
2219
2220    #[test]
2221    fn select_onchain_fee_option_leaves_fee_options_untouched() {
2222        let options = vec![
2223            MeltQuoteOnchainFeeOption {
2224                fee_index: 1,
2225                fee_reserve: Amount::from(500),
2226                estimated_blocks: 1,
2227            },
2228            MeltQuoteOnchainFeeOption {
2229                fee_index: 2,
2230                fee_reserve: Amount::from(200),
2231                estimated_blocks: 6,
2232            },
2233        ];
2234        let mut quote = MeltQuote::new_onchain(
2235            None,
2236            MeltPaymentRequest::Onchain {
2237                address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq".to_string(),
2238            },
2239            CurrencyUnit::Sat,
2240            Amount::new(10_000, CurrencyUnit::Sat),
2241            unix_time() + 3600,
2242            None,
2243            None,
2244            options.clone(),
2245        )
2246        .unwrap();
2247
2248        let before = quote.fee_options().to_vec();
2249        quote
2250            .select_onchain_fee_option(1)
2251            .expect("selecting a known fee_index must succeed");
2252
2253        assert_eq!(
2254            quote.fee_options(),
2255            before.as_slice(),
2256            "fee_options is fixed for the lifetime of the quote and must not \
2257             mutate on selection"
2258        );
2259        assert_eq!(quote.selected_fee_index, Some(1));
2260        assert_eq!(quote.estimated_blocks, Some(1));
2261        assert_eq!(quote.fee_reserve().value(), 500);
2262    }
2263
2264    #[test]
2265    fn select_onchain_fee_option_unknown_index_rejected() {
2266        let options = vec![MeltQuoteOnchainFeeOption {
2267            fee_index: 0,
2268            fee_reserve: Amount::from(500),
2269            estimated_blocks: 1,
2270        }];
2271        let mut quote = MeltQuote::new_onchain(
2272            None,
2273            MeltPaymentRequest::Onchain {
2274                address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq".to_string(),
2275            },
2276            CurrencyUnit::Sat,
2277            Amount::new(10_000, CurrencyUnit::Sat),
2278            unix_time() + 3600,
2279            None,
2280            None,
2281            options,
2282        )
2283        .unwrap();
2284
2285        match quote
2286            .select_onchain_fee_option(7)
2287            .expect_err("unknown fee_index must be rejected")
2288        {
2289            crate::Error::OnchainFeeIndexNotFound { index: 7 } => {}
2290            other => panic!("unexpected error: {other:?}"),
2291        }
2292    }
2293
2294    #[test]
2295    fn from_db_preserves_duplicate_onchain_fee_options() {
2296        let options = vec![
2297            MeltQuoteOnchainFeeOption {
2298                fee_index: 0,
2299                fee_reserve: Amount::from(100),
2300                estimated_blocks: 6,
2301            },
2302            MeltQuoteOnchainFeeOption {
2303                fee_index: 0,
2304                fee_reserve: Amount::from(200),
2305                estimated_blocks: 6,
2306            },
2307        ];
2308        let quote = MeltQuote::from_db(
2309            QuoteId::new(),
2310            CurrencyUnit::Sat,
2311            MeltPaymentRequest::Onchain {
2312                address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq".to_string(),
2313            },
2314            10_000,
2315            100,
2316            MeltQuoteState::Unpaid,
2317            unix_time() + 3600,
2318            None,
2319            None,
2320            None,
2321            unix_time(),
2322            None,
2323            PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain),
2324            None,
2325            None,
2326            options,
2327            None,
2328        )
2329        .expect("duplicate onchain fee_options on reload must be preserved");
2330
2331        let returned: Vec<u32> = quote.fee_options().iter().map(|o| o.fee_index).collect();
2332        assert_eq!(returned, vec![0, 0]);
2333    }
2334
2335    #[test]
2336    fn test_custom_mint_quote_response_surfaces_extra_json() {
2337        let extra = serde_json::json!({"payment_url": "https://example.com/pay", "ref": 42});
2338        let now = unix_time();
2339        let quote = MintQuote::new(
2340            Some(QuoteId::new()),
2341            "custom://request".to_string(),
2342            CurrencyUnit::Sat,
2343            Some(Amount::new(500, CurrencyUnit::Sat)),
2344            unix_time() + 3600,
2345            PaymentIdentifier::Label("test".to_string()),
2346            None,
2347            Amount::new(0, CurrencyUnit::Sat),
2348            Amount::new(0, CurrencyUnit::Sat),
2349            PaymentMethod::Custom("custom".to_string()),
2350            now,
2351            now,
2352            Vec::new(),
2353            Vec::new(),
2354            Some(extra.clone()),
2355        );
2356
2357        let response: MintQuoteResponse<QuoteId> = quote.try_into().expect("conversion succeeds");
2358        match response {
2359            MintQuoteResponse::Custom { response, .. } => {
2360                assert_eq!(response.extra, extra);
2361            }
2362            other => panic!("expected Custom variant, got {:?}", other),
2363        }
2364    }
2365
2366    #[test]
2367    fn from_db_rejects_empty_onchain_fee_options() {
2368        let err = MeltQuote::from_db(
2369            QuoteId::new(),
2370            CurrencyUnit::Sat,
2371            MeltPaymentRequest::Onchain {
2372                address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq".to_string(),
2373            },
2374            10_000,
2375            100,
2376            MeltQuoteState::Unpaid,
2377            unix_time() + 3600,
2378            None,
2379            None,
2380            None,
2381            unix_time(),
2382            None,
2383            PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain),
2384            None,
2385            Some(6),
2386            Vec::new(),
2387            None,
2388        )
2389        .expect_err("empty onchain fee_options on reload must be rejected");
2390        assert!(matches!(err, crate::Error::OnchainFeeOptionsEmpty));
2391    }
2392}