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    #[instrument(skip(self))]
655    pub fn amount_paid(&self) -> Amount<CurrencyUnit> {
656        self.amount_paid.clone()
657    }
658
659    /// Records tokens being issued against this mint quote.
660    ///
661    /// This method validates that the issuance doesn't exceed the amount paid, updates
662    /// the quote's internal state, and records the change for later persistence. The
663    /// `amount_issued` counter is incremented and the issuance is added to the change
664    /// tracker for the database layer to persist.
665    ///
666    /// # Arguments
667    ///
668    /// * `additional_amount` - The amount of tokens being issued.
669    ///
670    /// # Returns
671    ///
672    /// Returns the new total `amount_issued` after this issuance is recorded.
673    ///
674    /// # Errors
675    ///
676    /// Returns [`crate::Error::OverIssue`] if the new issued amount would exceed the
677    /// amount paid (cannot issue more tokens than have been paid for).
678    ///
679    /// Returns [`crate::Error::AmountOverflow`] if adding the issuance amount would
680    /// cause an arithmetic overflow.
681    #[instrument(skip(self))]
682    pub fn add_issuance(
683        &mut self,
684        additional_amount: Amount<CurrencyUnit>,
685    ) -> Result<Amount<CurrencyUnit>, crate::Error> {
686        let new_amount_issued = self
687            .amount_issued
688            .checked_add(&additional_amount)
689            .map_err(|_| crate::Error::AmountOverflow)?;
690
691        // Can't issue more than what's been paid
692        if new_amount_issued > self.amount_paid {
693            return Err(crate::Error::OverIssue);
694        }
695
696        self.changes
697            .get_or_insert_default()
698            .issuances
699            .get_or_insert_default()
700            .push(additional_amount.into());
701
702        self.amount_issued = new_amount_issued;
703
704        Ok(self.amount_issued.clone())
705    }
706
707    /// Amount issued
708    #[instrument(skip(self))]
709    pub fn amount_issued(&self) -> Amount<CurrencyUnit> {
710        self.amount_issued.clone()
711    }
712
713    /// Unix timestamp indicating when this quote was last updated.
714    pub fn updated_at(&self) -> u64 {
715        self.updated_at
716    }
717
718    /// Replaces `updated_at` with the value persisted by the database.
719    pub fn set_updated_at(&mut self, updated_at: u64) {
720        self.updated_at = updated_at;
721    }
722
723    /// Unix timestamp of the most recent payment backend status check.
724    pub fn last_checked(&self) -> u64 {
725        self.last_checked
726    }
727
728    /// Replaces `last_checked` with the value persisted by the database.
729    pub fn set_last_checked(&mut self, last_checked: u64) {
730        self.last_checked = last_checked;
731    }
732
733    /// Get state of mint quote
734    #[instrument(skip(self))]
735    pub fn state(&self) -> MintQuoteState {
736        self.compute_quote_state()
737    }
738
739    /// Existing payment ids of a mint quote
740    pub fn payment_ids(&self) -> Vec<&String> {
741        self.payments.iter().map(|a| &a.payment_id).collect()
742    }
743
744    /// Amount mintable
745    /// Returns the amount that is still available for minting.
746    ///
747    /// The value is computed as the difference between the total amount that
748    /// has been paid for this issuance (`self.amount_paid`) and the amount
749    /// that has already been issued (`self.amount_issued`). In other words,
750    pub fn amount_mintable(&self) -> Amount<CurrencyUnit> {
751        self.amount_paid
752            .checked_sub(&self.amount_issued)
753            .unwrap_or_else(|_| Amount::new(0, self.unit.clone()))
754    }
755
756    /// Extracts and returns all pending changes, leaving the internal change tracker empty.
757    ///
758    /// This method is typically called by the database layer after loading or modifying a quote. It
759    /// returns any accumulated changes (new payments, issuances) that need to be persisted, and
760    /// clears the internal change buffer so that subsequent calls return `None` until new
761    /// modifications are made.
762    ///
763    /// Returns `None` if no changes have been made since the last call to this method or since the
764    /// quote was created/loaded.
765    pub fn take_changes(&mut self) -> Option<MintQuoteChange> {
766        self.changes.take()
767    }
768
769    /// Records a new payment received for this mint quote.
770    ///
771    /// This method validates the payment, updates the quote's internal state, and records the
772    /// change for later persistence. The `amount_paid` counter is incremented and the payment is
773    /// added to the change tracker for the database layer to persist.
774    ///
775    /// # Arguments
776    ///
777    /// * `amount` - The amount of the payment in the quote's currency unit. * `payment_id` - A
778    /// unique identifier for this payment (e.g., lightning payment hash). * `time` - Optional Unix
779    /// timestamp of when the payment was received. If `None`, the current time is used.
780    ///
781    /// # Errors
782    ///
783    /// Returns [`crate::Error::DuplicatePaymentId`] if a payment with the same ID has already been
784    /// recorded for this quote.
785    ///
786    /// Returns [`crate::Error::AmountOverflow`] if adding the payment amount would cause an
787    /// arithmetic overflow.
788    #[instrument(skip(self))]
789    pub fn add_payment(
790        &mut self,
791        amount: Amount<CurrencyUnit>,
792        payment_id: String,
793        time: Option<u64>,
794    ) -> Result<(), crate::Error> {
795        let time = time.unwrap_or_else(unix_time);
796
797        let payment_ids = self.payment_ids();
798        if payment_ids.contains(&&payment_id) {
799            return Err(crate::Error::DuplicatePaymentId);
800        }
801
802        self.amount_paid = self
803            .amount_paid
804            .checked_add(&amount)
805            .map_err(|_| crate::Error::AmountOverflow)?;
806
807        let payment = IncomingPayment::new(amount, payment_id, time);
808
809        self.payments.push(payment.clone());
810
811        self.changes
812            .get_or_insert_default()
813            .payments
814            .get_or_insert_default()
815            .push(payment);
816
817        Ok(())
818    }
819
820    /// Compute quote state
821    #[instrument(skip(self))]
822    fn compute_quote_state(&self) -> MintQuoteState {
823        let zero_amount = Amount::new(0, self.unit.clone());
824
825        if self.amount_paid == zero_amount && self.amount_issued == zero_amount {
826            return MintQuoteState::Unpaid;
827        }
828
829        match self.amount_paid.value().cmp(&self.amount_issued.value()) {
830            std::cmp::Ordering::Less => {
831                tracing::error!("We should not have issued more then has been paid");
832                MintQuoteState::Issued
833            }
834            std::cmp::Ordering::Equal => MintQuoteState::Issued,
835            std::cmp::Ordering::Greater => MintQuoteState::Paid,
836        }
837    }
838}
839
840/// Mint Payments
841#[derive(Debug, Clone, Hash, PartialEq, Eq)]
842pub struct IncomingPayment {
843    /// Amount
844    pub amount: Amount<CurrencyUnit>,
845    /// Pyament unix time
846    pub time: u64,
847    /// Payment id
848    pub payment_id: String,
849}
850
851impl IncomingPayment {
852    /// New [`IncomingPayment`]
853    pub fn new(amount: Amount<CurrencyUnit>, payment_id: String, time: u64) -> Self {
854        Self {
855            payment_id,
856            time,
857            amount,
858        }
859    }
860}
861
862/// Information about issued quote
863#[derive(Debug, Clone, Hash, PartialEq, Eq)]
864pub struct Issuance {
865    /// Amount
866    pub amount: Amount<CurrencyUnit>,
867    /// Time
868    pub time: u64,
869}
870
871impl Issuance {
872    /// Create new [`Issuance`]
873    pub fn new(amount: Amount<CurrencyUnit>, time: u64) -> Self {
874        Self { amount, time }
875    }
876}
877
878/// Melt Quote Info
879#[derive(Clone, Hash, PartialEq, Eq)]
880pub struct MeltQuote {
881    /// Quote id
882    pub id: QuoteId,
883    /// Quote unit
884    pub unit: CurrencyUnit,
885    /// Quote Payment request e.g. bolt11
886    pub request: MeltPaymentRequest,
887    /// Quote amount (typed for type safety)
888    amount: Amount<CurrencyUnit>,
889    /// Quote fee reserve (typed for type safety)
890    fee_reserve: Amount<CurrencyUnit>,
891    /// Quote state
892    pub state: MeltQuoteState,
893    /// Expiration time of quote
894    pub expiry: u64,
895    /// Payment proof (e.g. Lightning preimage or onchain outpoint)
896    pub payment_proof: Option<String>,
897    /// Value used by the payment backend to look up state of request
898    pub request_lookup_id: Option<PaymentIdentifier>,
899    /// Payment options
900    ///
901    /// Used for amountless invoices and MPP payments
902    pub options: Option<MeltOptions>,
903    /// Unix time quote was created
904    pub created_time: u64,
905    /// Unix time quote was paid
906    pub paid_time: Option<u64>,
907    /// Payment method
908    pub payment_method: PaymentMethod,
909    /// Extra payment-method-specific response fields
910    pub extra_json: Option<serde_json::Value>,
911    /// Estimated confirmation target in blocks for onchain quotes
912    pub estimated_blocks: Option<u32>,
913    /// Onchain fee options fixed for the lifetime of the quote.
914    ///
915    /// Intentionally private: callers read via [`MeltQuote::fee_options`].
916    /// This makes the "fixed for the lifetime of the quote" NUT invariant
917    /// enforceable at the type level — external code cannot replace or push
918    /// into the vec after construction. Mutations that do happen (via
919    /// [`MeltQuote::select_onchain_fee_option`]) only touch
920    /// `fee_reserve`/`estimated_blocks`/`selected_fee_index`, never
921    /// this list.
922    fee_options: Vec<MeltQuoteOnchainFeeOption>,
923    /// Selected fee option index once an onchain quote is executed
924    pub selected_fee_index: Option<u32>,
925}
926
927impl fmt::Debug for MeltQuote {
928    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
929        f.debug_struct("MeltQuote")
930            .field("id", &self.id)
931            .field("unit", &self.unit)
932            .field("request", &self.request)
933            .field("amount", &self.amount)
934            .field("fee_reserve", &self.fee_reserve)
935            .field("state", &self.state)
936            .field("expiry", &self.expiry)
937            .field(
938                "payment_proof",
939                &self.payment_proof.as_ref().map(|_| "[REDACTED]"),
940            )
941            .field("request_lookup_id", &self.request_lookup_id)
942            .field("options", &self.options)
943            .field("created_time", &self.created_time)
944            .field("paid_time", &self.paid_time)
945            .field("payment_method", &self.payment_method)
946            .field("extra_json", &self.extra_json)
947            .field("estimated_blocks", &self.estimated_blocks)
948            .field("fee_options", &self.fee_options)
949            .field("selected_fee_index", &self.selected_fee_index)
950            .finish()
951    }
952}
953
954impl MeltQuote {
955    /// Create new [`MeltQuote`]
956    #[allow(clippy::too_many_arguments)]
957    pub fn new(
958        id: Option<QuoteId>,
959        request: MeltPaymentRequest,
960        unit: CurrencyUnit,
961        amount: Amount<CurrencyUnit>,
962        fee_reserve: Amount<CurrencyUnit>,
963        expiry: u64,
964        request_lookup_id: Option<PaymentIdentifier>,
965        options: Option<MeltOptions>,
966        payment_method: PaymentMethod,
967        extra_json: Option<serde_json::Value>,
968        estimated_blocks: Option<u32>,
969    ) -> Self {
970        let id = id.unwrap_or_default();
971
972        let fee_options = estimated_blocks
973            .map(|estimated_blocks| {
974                vec![MeltQuoteOnchainFeeOption {
975                    fee_index: 0,
976                    fee_reserve: fee_reserve.clone().into(),
977                    estimated_blocks,
978                }]
979            })
980            .unwrap_or_default();
981
982        Self {
983            id,
984            unit: unit.clone(),
985            request,
986            amount,
987            fee_reserve,
988            state: MeltQuoteState::Unpaid,
989            expiry,
990            payment_proof: None,
991            request_lookup_id,
992            options,
993            created_time: unix_time(),
994            paid_time: None,
995            payment_method,
996            extra_json,
997            estimated_blocks,
998            fee_options,
999            selected_fee_index: None,
1000        }
1001    }
1002
1003    /// Create a new onchain [`MeltQuote`] with explicit `fee_options`.
1004    ///
1005    /// Preserves backend-provided `fee_index` values and validates that the
1006    /// quote contains at least one option (`OnchainFeeOptionsEmpty`).
1007    ///
1008    /// `fee_reserve` is initialized to the lowest-fee option so the quote has
1009    /// a definite reserve before the wallet selects a tier. Once the wallet
1010    /// calls [`MeltQuote::select_onchain_fee_option`] the reserve is updated
1011    /// to match the selected option. `fee_options` itself is never mutated
1012    /// after this call; that invariant is enforced by making the field
1013    /// private.
1014    #[allow(clippy::too_many_arguments)]
1015    pub fn new_onchain(
1016        id: Option<QuoteId>,
1017        request: MeltPaymentRequest,
1018        unit: CurrencyUnit,
1019        amount: Amount<CurrencyUnit>,
1020        expiry: u64,
1021        request_lookup_id: Option<PaymentIdentifier>,
1022        extra_json: Option<serde_json::Value>,
1023        fee_options: Vec<MeltQuoteOnchainFeeOption>,
1024    ) -> Result<Self, crate::Error> {
1025        if fee_options.is_empty() {
1026            return Err(crate::Error::OnchainFeeOptionsEmpty);
1027        }
1028
1029        validate_onchain_fee_options(&fee_options)?;
1030
1031        let id = id.unwrap_or_default();
1032
1033        // Pick the lowest-reserve option as the initial reserve. The `ok_or` is
1034        // unreachable — we checked for empty above — but we use it instead of
1035        // `expect` to avoid a needless panic path.
1036        let initial = fee_options
1037            .iter()
1038            .min_by_key(|option| u64::from(option.fee_reserve))
1039            .copied()
1040            .ok_or(crate::Error::OnchainFeeOptionsEmpty)?;
1041
1042        let fee_reserve = initial.fee_reserve.with_unit(unit.clone());
1043        let estimated_blocks = Some(initial.estimated_blocks);
1044
1045        Ok(Self {
1046            id,
1047            unit: unit.clone(),
1048            request,
1049            amount,
1050            fee_reserve,
1051            state: MeltQuoteState::Unpaid,
1052            expiry,
1053            payment_proof: None,
1054            request_lookup_id,
1055            options: None,
1056            created_time: unix_time(),
1057            paid_time: None,
1058            payment_method: PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain),
1059            extra_json,
1060            estimated_blocks,
1061            fee_options,
1062            selected_fee_index: None,
1063        })
1064    }
1065
1066    /// Onchain fee options for this quote.
1067    ///
1068    /// For non-onchain quotes this returns an empty slice. For onchain quotes
1069    /// this is guaranteed non-empty (enforced at construction in
1070    /// [`MeltQuote::new_onchain`] and on reload in [`MeltQuote::from_db`]).
1071    #[inline]
1072    pub fn fee_options(&self) -> &[MeltQuoteOnchainFeeOption] {
1073        &self.fee_options
1074    }
1075
1076    /// Quote amount
1077    #[inline]
1078    pub fn amount(&self) -> Amount<CurrencyUnit> {
1079        self.amount.clone()
1080    }
1081
1082    /// Fee reserve
1083    #[inline]
1084    pub fn fee_reserve(&self) -> Amount<CurrencyUnit> {
1085        self.fee_reserve.clone()
1086    }
1087
1088    /// Select an onchain fee option by its `fee_index`.
1089    pub fn select_onchain_fee_option(&mut self, fee_index: u32) -> Result<(), crate::Error> {
1090        let option = self
1091            .fee_options
1092            .iter()
1093            .find(|option| option.fee_index == fee_index)
1094            .copied()
1095            .ok_or(crate::Error::OnchainFeeIndexNotFound { index: fee_index })?;
1096
1097        if self
1098            .selected_fee_index
1099            .is_some_and(|selected| selected != fee_index)
1100        {
1101            return Err(crate::Error::InvalidPaymentRequest);
1102        }
1103
1104        self.fee_reserve = option.fee_reserve.with_unit(self.unit.clone());
1105        self.estimated_blocks = Some(option.estimated_blocks);
1106        self.selected_fee_index = Some(fee_index);
1107
1108        Ok(())
1109    }
1110
1111    /// Convert into `MeltQuoteResponse`, overriding `change` on the inner
1112    /// response with the provided signatures.
1113    ///
1114    /// Dispatches to the per-variant `From<MeltQuote>` conversions so that
1115    /// field mapping stays centralized.
1116    pub fn into_response(
1117        self,
1118        change: Option<Vec<cashu::nuts::BlindSignature>>,
1119    ) -> crate::MeltQuoteResponse<QuoteId> {
1120        match self.payment_method {
1121            PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Bolt11) => {
1122                let mut response: MeltQuoteBolt11Response<QuoteId> = self.into();
1123                response.change = change;
1124                crate::MeltQuoteResponse::Bolt11(response)
1125            }
1126            PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Bolt12) => {
1127                let mut response: MeltQuoteBolt12Response<QuoteId> = self.into();
1128                response.change = change;
1129                crate::MeltQuoteResponse::Bolt12(response)
1130            }
1131            PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain) => {
1132                let mut response: MeltQuoteOnchainResponse<QuoteId> = self.into();
1133                response.change = change;
1134                crate::MeltQuoteResponse::Onchain(response)
1135            }
1136            _ => {
1137                let method = self.payment_method.clone();
1138                let mut response: MeltQuoteCustomResponse<QuoteId> = self.into();
1139                response.change = change;
1140                crate::MeltQuoteResponse::Custom((method, response))
1141            }
1142        }
1143    }
1144
1145    /// Total amount needed (amount + fee_reserve)
1146    pub fn total_needed(&self) -> Result<Amount, crate::Error> {
1147        let total = self
1148            .amount
1149            .checked_add(&self.fee_reserve)
1150            .map_err(|_| crate::Error::AmountOverflow)?;
1151        Ok(Amount::from(total.value()))
1152    }
1153
1154    /// Create MeltQuote from database fields (for deserialization)
1155    #[allow(clippy::too_many_arguments)]
1156    pub fn from_db(
1157        id: QuoteId,
1158        unit: CurrencyUnit,
1159        request: MeltPaymentRequest,
1160        amount: u64,
1161        fee_reserve: u64,
1162        state: MeltQuoteState,
1163        expiry: u64,
1164        payment_proof: Option<String>,
1165        request_lookup_id: Option<PaymentIdentifier>,
1166        options: Option<MeltOptions>,
1167        created_time: u64,
1168        paid_time: Option<u64>,
1169        payment_method: PaymentMethod,
1170        extra_json: Option<serde_json::Value>,
1171        estimated_blocks: Option<u32>,
1172        fee_options: Vec<MeltQuoteOnchainFeeOption>,
1173        selected_fee_index: Option<u32>,
1174    ) -> Result<Self, crate::Error> {
1175        // For onchain quotes, re-validate the persisted `fee_options` so a
1176        // corrupted or hand-edited row cannot silently be served as a valid
1177        // quote. Non-onchain quotes legitimately carry an empty vec and are
1178        // skipped.
1179        if payment_method == PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain) {
1180            validate_onchain_fee_options(&fee_options)?;
1181        }
1182
1183        Ok(Self {
1184            id,
1185            unit: unit.clone(),
1186            request,
1187            amount: Amount::new(amount, unit.clone()),
1188            fee_reserve: Amount::new(fee_reserve, unit),
1189            state,
1190            expiry,
1191            payment_proof,
1192            request_lookup_id,
1193            options,
1194            created_time,
1195            paid_time,
1196            payment_method,
1197            extra_json,
1198            estimated_blocks,
1199            fee_options,
1200            selected_fee_index,
1201        })
1202    }
1203}
1204
1205/// Validate the NUT `fee_options` rules for an onchain melt quote.
1206///
1207/// Per spec, for every onchain melt quote the mint MUST return at least one
1208/// `fee_options` item.
1209///
1210/// Returns:
1211/// - [`Error::OnchainFeeOptionsEmpty`]
1212///   when the slice is empty.
1213pub fn validate_onchain_fee_options(
1214    fee_options: &[MeltQuoteOnchainFeeOption],
1215) -> Result<(), crate::Error> {
1216    if fee_options.is_empty() {
1217        return Err(crate::Error::OnchainFeeOptionsEmpty);
1218    }
1219
1220    Ok(())
1221}
1222
1223impl From<MeltQuote> for MeltQuoteOnchainResponse<QuoteId> {
1224    fn from(quote: MeltQuote) -> Self {
1225        Self {
1226            quote: quote.id.clone(),
1227            amount: quote.amount().into(),
1228            unit: quote.unit.clone(),
1229            method: PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain),
1230            state: quote.state,
1231            expiry: quote.expiry,
1232            request: quote.request.to_string(),
1233            fee_options: quote.fee_options().to_vec(),
1234            selected_fee_index: quote.selected_fee_index,
1235            outpoint: quote.payment_proof.clone(),
1236            change: None,
1237        }
1238    }
1239}
1240
1241impl TryFrom<MintQuote> for MintQuoteOnchainResponse<QuoteId> {
1242    type Error = crate::error::Error;
1243    fn try_from(quote: MintQuote) -> Result<Self, Self::Error> {
1244        Ok(Self {
1245            quote: quote.id.clone(),
1246            request: quote.request.clone(),
1247            unit: quote.unit.clone(),
1248            method: PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain),
1249            expiry: (quote.expiry != 0).then_some(quote.expiry),
1250            pubkey: quote.pubkey.ok_or(crate::error::Error::MissingPubkey)?,
1251            amount_paid: quote.amount_paid().into(),
1252            amount_issued: quote.amount_issued().into(),
1253            updated_at: quote.updated_at(),
1254        })
1255    }
1256}
1257
1258/// Mint Keyset Info
1259#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
1260pub struct MintKeySetInfo {
1261    /// Keyset [`Id`]
1262    pub id: Id,
1263    /// Keyset [`CurrencyUnit`]
1264    pub unit: CurrencyUnit,
1265    /// Keyset active or inactive
1266    /// Mint will only issue new signatures on active keysets
1267    pub active: bool,
1268    /// Starting unix time Keyset is valid from
1269    pub valid_from: u64,
1270    /// [`DerivationPath`] keyset
1271    pub derivation_path: DerivationPath,
1272    /// DerivationPath index of Keyset
1273    pub derivation_path_index: Option<u32>,
1274    /// Supported amounts
1275    pub amounts: Vec<u64>,
1276    /// Input Fee ppk
1277    #[serde(default = "default_fee")]
1278    pub input_fee_ppk: u64,
1279    /// Final expiry
1280    pub final_expiry: Option<u64>,
1281    /// Issuer Version
1282    pub issuer_version: Option<IssuerVersion>,
1283}
1284
1285impl MintKeySetInfo {
1286    /// Returns true if `final_expiry` is set and strictly in the past.
1287    pub fn is_expired(&self) -> bool {
1288        self.final_expiry.is_some_and(|expiry| expiry < unix_time())
1289    }
1290}
1291
1292/// Default fee
1293pub fn default_fee() -> u64 {
1294    0
1295}
1296
1297impl From<MintKeySetInfo> for KeySetInfo {
1298    fn from(keyset_info: MintKeySetInfo) -> Self {
1299        Self {
1300            id: keyset_info.id,
1301            unit: keyset_info.unit,
1302            active: keyset_info.active,
1303            input_fee_ppk: keyset_info.input_fee_ppk,
1304            final_expiry: keyset_info.final_expiry,
1305        }
1306    }
1307}
1308
1309impl From<MintQuote> for MintQuoteBolt11Response<QuoteId> {
1310    fn from(mint_quote: MintQuote) -> MintQuoteBolt11Response<QuoteId> {
1311        let amount_paid = mint_quote.amount_paid().into();
1312        let amount_issued = mint_quote.amount_issued().into();
1313        let updated_at = mint_quote.updated_at();
1314
1315        MintQuoteBolt11Response {
1316            quote: mint_quote.id.clone(),
1317            state: mint_quote.state(),
1318            request: mint_quote.request,
1319            expiry: Some(mint_quote.expiry),
1320            pubkey: mint_quote.pubkey,
1321            amount: mint_quote.amount.map(Into::into),
1322            unit: Some(mint_quote.unit),
1323            method: PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Bolt11),
1324            amount_paid,
1325            amount_issued,
1326            updated_at,
1327        }
1328    }
1329}
1330
1331impl From<MintQuote> for MintQuoteBolt11Response<String> {
1332    fn from(quote: MintQuote) -> Self {
1333        let quote: MintQuoteBolt11Response<QuoteId> = quote.into();
1334        quote.into()
1335    }
1336}
1337
1338impl TryFrom<MintQuote> for MintQuoteBolt12Response<QuoteId> {
1339    type Error = Error;
1340
1341    fn try_from(mint_quote: MintQuote) -> Result<Self, Self::Error> {
1342        let amount_paid = mint_quote.amount_paid().into();
1343        let amount_issued = mint_quote.amount_issued().into();
1344        let updated_at = mint_quote.updated_at();
1345
1346        Ok(MintQuoteBolt12Response {
1347            quote: mint_quote.id.clone(),
1348            request: mint_quote.request,
1349            expiry: (mint_quote.expiry != 0).then_some(mint_quote.expiry),
1350            amount_paid,
1351            amount_issued,
1352            pubkey: mint_quote.pubkey.ok_or(Error::PubkeyRequired)?,
1353            amount: mint_quote.amount.map(Into::into),
1354            unit: mint_quote.unit,
1355            method: PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Bolt12),
1356            updated_at,
1357        })
1358    }
1359}
1360
1361impl TryFrom<MintQuote> for MintQuoteBolt12Response<String> {
1362    type Error = Error;
1363
1364    fn try_from(quote: MintQuote) -> Result<Self, Self::Error> {
1365        let quote: MintQuoteBolt12Response<QuoteId> = quote.try_into()?;
1366        Ok(quote.into())
1367    }
1368}
1369
1370impl TryFrom<MintQuote> for MintQuoteCustomResponse<QuoteId> {
1371    type Error = Error;
1372
1373    fn try_from(quote: MintQuote) -> Result<Self, Self::Error> {
1374        let amount_paid = quote.amount_paid().into();
1375        let amount_issued = quote.amount_issued().into();
1376        let updated_at = quote.updated_at();
1377
1378        Ok(MintQuoteCustomResponse {
1379            quote: quote.id,
1380            request: quote.request,
1381            method: quote.payment_method,
1382            unit: Some(quote.unit),
1383            expiry: Some(quote.expiry),
1384            pubkey: quote.pubkey,
1385            amount: quote.amount.map(Into::into),
1386            amount_paid,
1387            amount_issued,
1388            updated_at,
1389            extra: quote.extra_json.unwrap_or_default(),
1390        })
1391    }
1392}
1393
1394impl TryFrom<MintQuote> for MintQuoteCustomResponse<String> {
1395    type Error = Error;
1396
1397    fn try_from(quote: MintQuote) -> Result<Self, Self::Error> {
1398        let quote: MintQuoteCustomResponse<QuoteId> = quote.try_into()?;
1399        Ok(quote.into())
1400    }
1401}
1402
1403impl From<MeltQuote> for crate::nuts::MeltQuoteCustomResponse<QuoteId> {
1404    fn from(melt_quote: MeltQuote) -> Self {
1405        let method = melt_quote.payment_method.clone();
1406        let request = match melt_quote.request {
1407            MeltPaymentRequest::Custom { request, .. } => Some(request),
1408            _ => None,
1409        };
1410
1411        Self {
1412            quote: melt_quote.id,
1413            method,
1414            amount: melt_quote.amount.into(),
1415            fee_reserve: Some(melt_quote.fee_reserve.into()),
1416            state: melt_quote.state,
1417            expiry: melt_quote.expiry,
1418            payment_preimage: melt_quote.payment_proof,
1419            change: None,
1420            request,
1421            unit: Some(melt_quote.unit),
1422            extra: melt_quote.extra_json.unwrap_or_default(),
1423        }
1424    }
1425}
1426
1427impl From<&MeltQuote> for MeltQuoteBolt12Response<QuoteId> {
1428    fn from(melt_quote: &MeltQuote) -> MeltQuoteBolt12Response<QuoteId> {
1429        MeltQuoteBolt12Response {
1430            quote: melt_quote.id.clone(),
1431            payment_preimage: None,
1432            change: None,
1433            state: melt_quote.state,
1434            expiry: melt_quote.expiry,
1435            amount: melt_quote.amount().into(),
1436            fee_reserve: melt_quote.fee_reserve().into(),
1437            request: None,
1438            unit: Some(melt_quote.unit.clone()),
1439            method: PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Bolt12),
1440        }
1441    }
1442}
1443
1444impl From<MeltQuote> for MeltQuoteBolt12Response<QuoteId> {
1445    fn from(melt_quote: MeltQuote) -> MeltQuoteBolt12Response<QuoteId> {
1446        MeltQuoteBolt12Response {
1447            quote: melt_quote.id.clone(),
1448            amount: melt_quote.amount().into(),
1449            fee_reserve: melt_quote.fee_reserve().into(),
1450            state: melt_quote.state,
1451            expiry: melt_quote.expiry,
1452            payment_preimage: melt_quote.payment_proof,
1453            change: None,
1454            request: Some(melt_quote.request.to_string()),
1455            unit: Some(melt_quote.unit.clone()),
1456            method: PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Bolt12),
1457        }
1458    }
1459}
1460
1461impl TryFrom<MintQuote> for MintQuoteResponse<QuoteId> {
1462    type Error = Error;
1463
1464    fn try_from(quote: MintQuote) -> Result<Self, Self::Error> {
1465        if quote.payment_method.is_bolt11() {
1466            Ok(Self::Bolt11(crate::nuts::nut23::MintQuoteBolt11Response {
1467                quote: quote.id.clone(),
1468                request: quote.request.clone(),
1469                state: quote.state(),
1470                expiry: Some(quote.expiry),
1471                amount: quote.amount.as_ref().map(|a| a.clone().into()),
1472                unit: Some(quote.unit.clone()),
1473                method: PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Bolt11),
1474                pubkey: quote.pubkey,
1475                amount_paid: quote.amount_paid().into(),
1476                amount_issued: quote.amount_issued().into(),
1477                updated_at: quote.updated_at(),
1478            }))
1479        } else if quote.payment_method.is_bolt12() {
1480            Ok(Self::Bolt12(crate::nuts::nut25::MintQuoteBolt12Response {
1481                quote: quote.id.clone(),
1482                request: quote.request.clone(),
1483                amount: quote.amount.as_ref().map(|a| a.clone().into()),
1484                unit: quote.unit.clone(),
1485                method: PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Bolt12),
1486                expiry: (quote.expiry != 0).then_some(quote.expiry),
1487                pubkey: quote.pubkey.ok_or(Error::PubkeyRequired)?,
1488                amount_paid: quote.amount_paid().into(),
1489                amount_issued: quote.amount_issued().into(),
1490                updated_at: quote.updated_at(),
1491            }))
1492        } else if quote.payment_method.is_onchain() {
1493            let onchain_response = MintQuoteOnchainResponse::try_from(quote)?;
1494            Ok(MintQuoteResponse::Onchain(onchain_response))
1495        } else {
1496            let method = quote.payment_method.clone();
1497            Ok(MintQuoteResponse::Custom {
1498                method: method.clone(),
1499                response: crate::nuts::nut04::MintQuoteCustomResponse {
1500                    quote: quote.id.clone(),
1501                    request: quote.request.clone(),
1502                    method: method.clone(),
1503                    expiry: Some(quote.expiry),
1504                    amount: quote.amount.as_ref().map(|a| a.clone().into()),
1505                    amount_paid: quote.amount_paid().into(),
1506                    amount_issued: quote.amount_issued().into(),
1507                    updated_at: quote.updated_at(),
1508                    unit: Some(quote.unit.clone()),
1509                    pubkey: quote.pubkey,
1510                    extra: quote.extra_json.clone().unwrap_or_default(),
1511                },
1512            })
1513        }
1514    }
1515}
1516
1517impl From<MintQuoteResponse<QuoteId>> for MintQuoteResponse<String> {
1518    fn from(response: MintQuoteResponse<QuoteId>) -> Self {
1519        match response {
1520            MintQuoteResponse::Bolt11(response) => MintQuoteResponse::Bolt11(response.into()),
1521            MintQuoteResponse::Bolt12(response) => MintQuoteResponse::Bolt12(response.into()),
1522            MintQuoteResponse::Onchain(response) => MintQuoteResponse::Onchain(response.into()),
1523            MintQuoteResponse::Custom { method, response } => MintQuoteResponse::Custom {
1524                method,
1525                response: response.into(),
1526            },
1527        }
1528    }
1529}
1530
1531impl From<MintQuoteResponse<QuoteId>> for MintQuoteBolt11Response<String> {
1532    fn from(response: MintQuoteResponse<QuoteId>) -> Self {
1533        match response {
1534            MintQuoteResponse::Bolt11(bolt11_response) => MintQuoteBolt11Response {
1535                quote: bolt11_response.quote.to_string(),
1536                state: bolt11_response.state,
1537                request: bolt11_response.request,
1538                expiry: bolt11_response.expiry,
1539                pubkey: bolt11_response.pubkey,
1540                amount: bolt11_response.amount,
1541                unit: bolt11_response.unit,
1542                method: bolt11_response.method,
1543                amount_paid: bolt11_response.amount_paid,
1544                amount_issued: bolt11_response.amount_issued,
1545                updated_at: bolt11_response.updated_at,
1546            },
1547            _ => panic!("Expected Bolt11 response"),
1548        }
1549    }
1550}
1551
1552impl TryFrom<MintQuoteResponse<QuoteId>> for MintQuoteBolt11Response<QuoteId> {
1553    type Error = Error;
1554
1555    fn try_from(response: MintQuoteResponse<QuoteId>) -> Result<Self, Self::Error> {
1556        match response {
1557            MintQuoteResponse::Bolt11(r) => Ok(r),
1558            _ => Err(Error::InvalidPaymentMethod),
1559        }
1560    }
1561}
1562
1563impl TryFrom<MintQuoteResponse<QuoteId>> for MintQuoteBolt12Response<QuoteId> {
1564    type Error = Error;
1565
1566    fn try_from(response: MintQuoteResponse<QuoteId>) -> Result<Self, Self::Error> {
1567        match response {
1568            MintQuoteResponse::Bolt12(r) => Ok(r),
1569            _ => Err(Error::InvalidPaymentMethod),
1570        }
1571    }
1572}
1573
1574impl TryFrom<MintQuoteResponse<QuoteId>> for MintQuoteOnchainResponse<QuoteId> {
1575    type Error = Error;
1576
1577    fn try_from(response: MintQuoteResponse<QuoteId>) -> Result<Self, Self::Error> {
1578        match response {
1579            MintQuoteResponse::Onchain(r) => Ok(r),
1580            _ => Err(Error::InvalidPaymentMethod),
1581        }
1582    }
1583}
1584
1585impl From<&MeltQuote> for MeltQuoteBolt11Response<QuoteId> {
1586    fn from(melt_quote: &MeltQuote) -> MeltQuoteBolt11Response<QuoteId> {
1587        MeltQuoteBolt11Response {
1588            quote: melt_quote.id.clone(),
1589            payment_preimage: None,
1590            change: None,
1591            state: melt_quote.state,
1592            expiry: melt_quote.expiry,
1593            amount: melt_quote.amount().into(),
1594            fee_reserve: melt_quote.fee_reserve().into(),
1595            request: None,
1596            unit: Some(melt_quote.unit.clone()),
1597            method: melt_quote.payment_method.clone(),
1598        }
1599    }
1600}
1601
1602impl From<MeltQuote> for MeltQuoteBolt11Response<QuoteId> {
1603    fn from(melt_quote: MeltQuote) -> MeltQuoteBolt11Response<QuoteId> {
1604        MeltQuoteBolt11Response {
1605            quote: melt_quote.id.clone(),
1606            amount: melt_quote.amount().into(),
1607            fee_reserve: melt_quote.fee_reserve().into(),
1608            state: melt_quote.state,
1609            expiry: melt_quote.expiry,
1610            payment_preimage: melt_quote.payment_proof,
1611            change: None,
1612            request: Some(melt_quote.request.to_string()),
1613            unit: Some(melt_quote.unit.clone()),
1614            method: melt_quote.payment_method.clone(),
1615        }
1616    }
1617}
1618
1619/// Payment request
1620#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
1621pub enum MeltPaymentRequest {
1622    /// Bolt11 Payment
1623    Bolt11 {
1624        /// Bolt11 invoice
1625        bolt11: Bolt11Invoice,
1626    },
1627    /// Bolt12 Payment
1628    Bolt12 {
1629        /// Offer
1630        #[serde(with = "offer_serde")]
1631        offer: Box<Offer>,
1632    },
1633    /// Custom payment method
1634    Custom {
1635        /// Payment method name
1636        method: String,
1637        /// Payment request string
1638        request: String,
1639    },
1640    /// Onchain Payment
1641    Onchain {
1642        /// Onchain address
1643        address: String,
1644    },
1645}
1646
1647impl std::fmt::Display for MeltPaymentRequest {
1648    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1649        match self {
1650            MeltPaymentRequest::Bolt11 { bolt11 } => write!(f, "{bolt11}"),
1651            MeltPaymentRequest::Bolt12 { offer } => write!(f, "{offer}"),
1652            MeltPaymentRequest::Custom { request, .. } => write!(f, "{request}"),
1653            MeltPaymentRequest::Onchain { address } => write!(f, "{address}"),
1654        }
1655    }
1656}
1657
1658mod offer_serde {
1659    use std::str::FromStr;
1660
1661    use serde::{self, Deserialize, Deserializer, Serializer};
1662
1663    use super::Offer;
1664
1665    pub fn serialize<S>(offer: &Offer, serializer: S) -> Result<S::Ok, S::Error>
1666    where
1667        S: Serializer,
1668    {
1669        let s = offer.to_string();
1670        serializer.serialize_str(&s)
1671    }
1672
1673    pub fn deserialize<'de, D>(deserializer: D) -> Result<Box<Offer>, D::Error>
1674    where
1675        D: Deserializer<'de>,
1676    {
1677        let s = String::deserialize(deserializer)?;
1678        Ok(Box::new(Offer::from_str(&s).map_err(|_| {
1679            serde::de::Error::custom("Invalid Bolt12 Offer")
1680        })?))
1681    }
1682}
1683
1684#[cfg(test)]
1685mod tests {
1686    use std::str::FromStr;
1687
1688    use cashu::Bolt11Invoice;
1689
1690    use super::*;
1691
1692    #[test]
1693    fn test_operation_new_mint_uses_uuid_v7() {
1694        let operation = Operation::new_mint(Amount::from(100), PaymentMethod::BOLT11);
1695
1696        assert_eq!(operation.id.get_version(), Some(uuid::Version::SortRand));
1697    }
1698
1699    #[test]
1700    fn mint_payment_records_debug_redact_payment_proofs() {
1701        let secret = "mint-payment-preimage-secret";
1702        let lookup_id = PaymentIdentifier::CustomId("public-lookup-id".to_string());
1703        let mut quote = MeltQuote::new(
1704            Some(QuoteId::new()),
1705            MeltPaymentRequest::Custom {
1706                method: "custom".to_string(),
1707                request: "public-payment-request".to_string(),
1708            },
1709            CurrencyUnit::Sat,
1710            Amount::new(100, CurrencyUnit::Sat),
1711            Amount::new(2, CurrencyUnit::Sat),
1712            unix_time() + 3_600,
1713            Some(lookup_id.clone()),
1714            None,
1715            PaymentMethod::Custom("custom".to_string()),
1716            None,
1717            None,
1718        );
1719        quote.payment_proof = Some(secret.to_string());
1720        let finalization = MeltFinalizationData {
1721            total_spent: Amount::new(102, CurrencyUnit::Sat),
1722            payment_lookup_id: lookup_id,
1723            payment_proof: Some(secret.to_string()),
1724        };
1725
1726        for debug in [format!("{quote:?}"), format!("{finalization:?}")] {
1727            assert!(debug.contains("public-lookup-id"));
1728            assert!(debug.contains("[REDACTED]"));
1729            assert!(!debug.contains(secret));
1730        }
1731    }
1732
1733    #[test]
1734    fn test_melt_quote_to_custom_response_with_custom_request() {
1735        let melt_quote = MeltQuote::new(
1736            Some(QuoteId::new()),
1737            MeltPaymentRequest::Custom {
1738                method: "custom".to_string(),
1739                request: "custom_request_string".to_string(),
1740            },
1741            CurrencyUnit::Sat,
1742            Amount::new(100, CurrencyUnit::Sat),
1743            Amount::new(2, CurrencyUnit::Sat),
1744            unix_time() + 3600,
1745            None,
1746            None,
1747            PaymentMethod::Custom("custom".to_string()),
1748            Some(serde_json::json!({"extra_field": "value"})),
1749            None,
1750        );
1751
1752        let response: crate::nuts::MeltQuoteCustomResponse<QuoteId> = melt_quote.clone().into();
1753
1754        assert_eq!(response.quote, melt_quote.id);
1755        assert_eq!(response.amount, 100.into());
1756        assert_eq!(response.fee_reserve, Some(2.into()));
1757        assert_eq!(response.state, melt_quote.state);
1758        assert_eq!(response.expiry, melt_quote.expiry);
1759        assert_eq!(response.payment_preimage, melt_quote.payment_proof);
1760        assert_eq!(response.change, None);
1761        assert_eq!(response.request, Some("custom_request_string".to_string()));
1762        assert_eq!(response.unit, Some(CurrencyUnit::Sat));
1763        assert_eq!(response.extra, serde_json::json!({"extra_field": "value"}));
1764    }
1765
1766    #[test]
1767    fn test_melt_quote_to_custom_response_with_bolt11_request() {
1768        let bolt11_str = "lnbc100n1pnvpufspp5djn8hrq49r8cghwye9kqw752qjncwyfnrprhprpqk43mwcy4yfsqdq5g9kxy7fqd9h8vmmfvdjscqzzsxqyz5vqsp5uhpjt36rj75pl7jq2sshaukzfkt7uulj456s4mh7uy7l6vx7lvxs9qxpqysgqedwz08acmqwtk8g4vkwm2w78suwt2qyzz6jkkwcgrjm3r3hs6fskyhvud4fan3keru7emjm8ygqpcrwtlmhfjfmer3afs5hhwamgr4cqtactdq";
1769        let bolt11 = Bolt11Invoice::from_str(bolt11_str).unwrap();
1770
1771        let melt_quote = MeltQuote::new(
1772            Some(QuoteId::new()),
1773            MeltPaymentRequest::Bolt11 { bolt11 },
1774            CurrencyUnit::Sat,
1775            Amount::new(100, CurrencyUnit::Sat),
1776            Amount::new(2, CurrencyUnit::Sat),
1777            unix_time() + 3600,
1778            None,
1779            None,
1780            PaymentMethod::BOLT11,
1781            None,
1782            None,
1783        );
1784
1785        let response: crate::nuts::MeltQuoteCustomResponse<QuoteId> = melt_quote.clone().into();
1786
1787        assert_eq!(response.quote, melt_quote.id);
1788        assert_eq!(response.request, None);
1789    }
1790
1791    #[test]
1792    fn test_melt_quote_to_custom_response_with_bolt12_request() {
1793        use bitcoin::secp256k1::{PublicKey as Secp256k1PublicKey, Secp256k1, SecretKey};
1794        use lightning::offers::offer::OfferBuilder;
1795        let secp = Secp256k1::new();
1796        let secret_key = SecretKey::from_slice(&[0xcd; 32]).unwrap();
1797        let pubkey = Secp256k1PublicKey::from_secret_key(&secp, &secret_key);
1798        let offer = OfferBuilder::new(pubkey).build().unwrap();
1799
1800        let melt_quote = MeltQuote::new(
1801            Some(QuoteId::new()),
1802            MeltPaymentRequest::Bolt12 {
1803                offer: Box::new(offer),
1804            },
1805            CurrencyUnit::Sat,
1806            Amount::new(100, CurrencyUnit::Sat),
1807            Amount::new(2, CurrencyUnit::Sat),
1808            unix_time() + 3600,
1809            None,
1810            None,
1811            PaymentMethod::BOLT12,
1812            None,
1813            None,
1814        );
1815
1816        let response: crate::nuts::MeltQuoteCustomResponse<QuoteId> = melt_quote.clone().into();
1817
1818        assert_eq!(response.quote, melt_quote.id);
1819        assert_eq!(response.request, None);
1820    }
1821
1822    fn dummy_mint_keyset_info(final_expiry: Option<u64>) -> MintKeySetInfo {
1823        use std::str::FromStr;
1824        MintKeySetInfo {
1825            id: Id::from_str("009a1f293253e41e").unwrap(),
1826            unit: CurrencyUnit::Sat,
1827            active: true,
1828            valid_from: 0,
1829            derivation_path: "m/0'/0'/0'".parse().unwrap(),
1830            derivation_path_index: Some(0),
1831            amounts: vec![1, 2, 4, 8, 16, 32, 64, 128, 256, 512],
1832            input_fee_ppk: 0,
1833            final_expiry,
1834            issuer_version: None,
1835        }
1836    }
1837
1838    #[test]
1839    fn test_is_expired_none() {
1840        let info = dummy_mint_keyset_info(None);
1841        assert!(!info.is_expired());
1842    }
1843
1844    #[test]
1845    fn test_is_expired_far_future() {
1846        let info = dummy_mint_keyset_info(Some(unix_time() + 1_000_000));
1847        assert!(!info.is_expired());
1848    }
1849
1850    #[test]
1851    fn test_is_expired_exactly_now_is_not_expired() {
1852        // strict less-than: expiry == now is not yet expired
1853        let info = dummy_mint_keyset_info(Some(unix_time()));
1854        assert!(!info.is_expired());
1855    }
1856
1857    #[test]
1858    fn test_is_expired_one_second_ago() {
1859        let info = dummy_mint_keyset_info(Some(unix_time() - 1));
1860        assert!(info.is_expired());
1861    }
1862
1863    #[test]
1864    fn test_is_expired_zero() {
1865        let info = dummy_mint_keyset_info(Some(0));
1866        assert!(info.is_expired());
1867    }
1868
1869    #[test]
1870    fn test_melt_quote_into_response_onchain() {
1871        let address = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq";
1872        let mut melt_quote = MeltQuote::new(
1873            Some(QuoteId::new()),
1874            MeltPaymentRequest::Onchain {
1875                address: address.to_string(),
1876            },
1877            CurrencyUnit::Sat,
1878            Amount::new(5_000, CurrencyUnit::Sat),
1879            Amount::new(250, CurrencyUnit::Sat),
1880            unix_time() + 3600,
1881            None,
1882            None,
1883            PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain),
1884            None,
1885            Some(6),
1886        );
1887
1888        // Simulate the terminal paid path: payment_proof becomes the broadcast outpoint.
1889        melt_quote.payment_proof =
1890            Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:1".to_string());
1891        melt_quote.state = MeltQuoteState::Paid;
1892
1893        let expected_id = melt_quote.id.clone();
1894        let expected_amount: Amount = melt_quote.amount().into();
1895        let expected_fee_options = melt_quote.fee_options().to_vec();
1896        let expected_expiry = melt_quote.expiry;
1897        let expected_state = melt_quote.state;
1898        let expected_outpoint = melt_quote.payment_proof.clone();
1899
1900        let response = melt_quote.into_response(None);
1901        match response {
1902            crate::MeltQuoteResponse::Onchain(r) => {
1903                assert_eq!(r.quote, expected_id);
1904                assert_eq!(r.request, address);
1905                assert_eq!(r.amount, expected_amount);
1906                assert_eq!(r.unit, CurrencyUnit::Sat);
1907                assert_eq!(r.fee_options, expected_fee_options);
1908                assert_eq!(r.selected_fee_index, None);
1909                assert_eq!(r.state, expected_state);
1910                assert_eq!(r.expiry, expected_expiry);
1911                assert_eq!(r.outpoint, expected_outpoint);
1912                assert_eq!(r.change, None);
1913            }
1914            _ => panic!("expected MeltQuoteResponse::Onchain variant"),
1915        }
1916    }
1917
1918    #[test]
1919    fn test_mint_quote_onchain_response_converts_zero_expiry_to_none() {
1920        let pubkey = PublicKey::from_hex(
1921            "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac",
1922        )
1923        .unwrap();
1924        let quote_id = QuoteId::new();
1925        let now = unix_time();
1926        let mint_quote = MintQuote::new(
1927            Some(quote_id.clone()),
1928            "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh".to_string(),
1929            CurrencyUnit::Sat,
1930            None,
1931            0,
1932            PaymentIdentifier::QuoteId(quote_id.clone()),
1933            Some(pubkey),
1934            Amount::new(10_000, CurrencyUnit::Sat),
1935            Amount::new(1_000, CurrencyUnit::Sat),
1936            PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain),
1937            now,
1938            now,
1939            vec![],
1940            vec![],
1941            None,
1942        );
1943
1944        let response = MintQuoteOnchainResponse::try_from(mint_quote).unwrap();
1945
1946        assert_eq!(response.quote, quote_id);
1947        assert_eq!(response.expiry, None);
1948        assert_eq!(response.pubkey, pubkey);
1949        assert_eq!(response.amount_paid, Amount::from(10_000));
1950        assert_eq!(response.amount_issued, Amount::from(1_000));
1951    }
1952
1953    fn dummy_bolt12_mint_quote(expiry: u64) -> (MintQuote, QuoteId, PublicKey) {
1954        let pubkey = PublicKey::from_hex(
1955            "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac",
1956        )
1957        .expect("test pubkey must parse");
1958        let quote_id = QuoteId::new();
1959        let now = unix_time();
1960        let mint_quote = MintQuote::new(
1961            Some(quote_id.clone()),
1962            "lno1testoffer".to_string(),
1963            CurrencyUnit::Sat,
1964            Some(Amount::new(10_000, CurrencyUnit::Sat)),
1965            expiry,
1966            PaymentIdentifier::QuoteId(quote_id.clone()),
1967            Some(pubkey),
1968            Amount::new(10_000, CurrencyUnit::Sat),
1969            Amount::new(1_000, CurrencyUnit::Sat),
1970            PaymentMethod::BOLT12,
1971            now,
1972            now,
1973            vec![],
1974            vec![],
1975            None,
1976        );
1977
1978        (mint_quote, quote_id, pubkey)
1979    }
1980
1981    #[test]
1982    fn test_mint_quote_bolt12_response_converts_zero_expiry_to_none() {
1983        let (mint_quote, quote_id, pubkey) = dummy_bolt12_mint_quote(0);
1984
1985        let response: MintQuoteBolt12Response<QuoteId> =
1986            MintQuoteBolt12Response::try_from(mint_quote).unwrap();
1987
1988        assert_eq!(response.quote, quote_id);
1989        assert_eq!(response.expiry, None);
1990        assert_eq!(response.pubkey, pubkey);
1991        assert_eq!(response.amount_paid, Amount::from(10_000));
1992        assert_eq!(response.amount_issued, Amount::from(1_000));
1993    }
1994
1995    #[test]
1996    fn test_mint_quote_bolt12_response_preserves_nonzero_expiry() {
1997        let expiry = unix_time() + 3600;
1998        let (mint_quote, quote_id, _) = dummy_bolt12_mint_quote(expiry);
1999
2000        let response: MintQuoteBolt12Response<QuoteId> =
2001            MintQuoteBolt12Response::try_from(mint_quote).unwrap();
2002
2003        assert_eq!(response.quote, quote_id);
2004        assert_eq!(response.expiry, Some(expiry));
2005    }
2006
2007    #[test]
2008    fn test_mint_quote_response_bolt12_converts_zero_expiry_to_none() {
2009        let (mint_quote, quote_id, pubkey) = dummy_bolt12_mint_quote(0);
2010
2011        let response = MintQuoteResponse::try_from(mint_quote).unwrap();
2012
2013        match response {
2014            MintQuoteResponse::Bolt12(response) => {
2015                assert_eq!(response.quote, quote_id);
2016                assert_eq!(response.expiry, None);
2017                assert_eq!(response.pubkey, pubkey);
2018            }
2019            _ => panic!("expected MintQuoteResponse::Bolt12 variant"),
2020        }
2021    }
2022
2023    #[test]
2024    fn test_melt_quote_into_response_onchain_includes_change() {
2025        let address = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq";
2026        let melt_quote = MeltQuote::new(
2027            Some(QuoteId::new()),
2028            MeltPaymentRequest::Onchain {
2029                address: address.to_string(),
2030            },
2031            CurrencyUnit::Sat,
2032            Amount::new(1_000, CurrencyUnit::Sat),
2033            Amount::new(10, CurrencyUnit::Sat),
2034            unix_time() + 3600,
2035            None,
2036            None,
2037            PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain),
2038            None,
2039            Some(3),
2040        );
2041
2042        let response = melt_quote.into_response(Some(vec![]));
2043        match response {
2044            crate::MeltQuoteResponse::Onchain(r) => assert_eq!(r.change, Some(vec![])),
2045            _ => panic!("expected MeltQuoteResponse::Onchain variant"),
2046        }
2047    }
2048
2049    #[test]
2050    fn validate_onchain_fee_options_rejects_empty() {
2051        let err = validate_onchain_fee_options(&[]).expect_err("empty must be rejected");
2052        assert!(matches!(err, crate::Error::OnchainFeeOptionsEmpty));
2053    }
2054
2055    #[test]
2056    fn validate_onchain_fee_options_allows_duplicate_fee_index() {
2057        let options = [
2058            MeltQuoteOnchainFeeOption {
2059                fee_index: 10,
2060                fee_reserve: Amount::from(10),
2061                estimated_blocks: 3,
2062            },
2063            MeltQuoteOnchainFeeOption {
2064                fee_index: 10,
2065                fee_reserve: Amount::from(20),
2066                estimated_blocks: 6,
2067            },
2068        ];
2069        validate_onchain_fee_options(&options).expect("duplicate fee_index must be allowed");
2070    }
2071
2072    #[test]
2073    fn validate_onchain_fee_options_allows_duplicate_estimated_blocks() {
2074        // With selection by fee_index, duplicate estimated_blocks values are
2075        // permitted (although unusual).
2076        let options = [
2077            MeltQuoteOnchainFeeOption {
2078                fee_index: 20,
2079                fee_reserve: Amount::from(10),
2080                estimated_blocks: 3,
2081            },
2082            MeltQuoteOnchainFeeOption {
2083                fee_index: 1,
2084                fee_reserve: Amount::from(20),
2085                estimated_blocks: 3,
2086            },
2087        ];
2088        validate_onchain_fee_options(&options).expect("duplicate blocks must be allowed");
2089    }
2090
2091    #[test]
2092    fn validate_onchain_fee_options_allows_duplicate_fee_reserve() {
2093        // With selection by fee_index, duplicate fee_reserve values are
2094        // permitted (although unusual).
2095        let options = [
2096            MeltQuoteOnchainFeeOption {
2097                fee_index: 0,
2098                fee_reserve: Amount::from(42),
2099                estimated_blocks: 1,
2100            },
2101            MeltQuoteOnchainFeeOption {
2102                fee_index: 1,
2103                fee_reserve: Amount::from(42),
2104                estimated_blocks: 6,
2105            },
2106        ];
2107        validate_onchain_fee_options(&options).expect("duplicate fee must be allowed");
2108    }
2109
2110    #[test]
2111    fn validate_onchain_fee_options_accepts_well_formed() {
2112        let options = [
2113            MeltQuoteOnchainFeeOption {
2114                fee_index: 0,
2115                fee_reserve: Amount::from(500),
2116                estimated_blocks: 1,
2117            },
2118            MeltQuoteOnchainFeeOption {
2119                fee_index: 1,
2120                fee_reserve: Amount::from(200),
2121                estimated_blocks: 6,
2122            },
2123            MeltQuoteOnchainFeeOption {
2124                fee_index: 2,
2125                fee_reserve: Amount::from(50),
2126                estimated_blocks: 144,
2127            },
2128        ];
2129        validate_onchain_fee_options(&options).expect("well-formed must validate");
2130    }
2131
2132    #[test]
2133    fn new_onchain_rejects_empty_fee_options() {
2134        let err = MeltQuote::new_onchain(
2135            None,
2136            MeltPaymentRequest::Onchain {
2137                address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq".to_string(),
2138            },
2139            CurrencyUnit::Sat,
2140            Amount::new(1_000, CurrencyUnit::Sat),
2141            unix_time() + 3600,
2142            None,
2143            None,
2144            vec![],
2145        )
2146        .expect_err("empty fee_options must be rejected");
2147        assert!(matches!(err, crate::Error::OnchainFeeOptionsEmpty));
2148    }
2149
2150    #[test]
2151    fn new_onchain_initializes_reserve_to_cheapest_tier() {
2152        // Submit options in an unsorted order to ensure cheapest-by-fee_reserve
2153        // is what wins (not first-in-list).
2154        let options = vec![
2155            MeltQuoteOnchainFeeOption {
2156                fee_index: 10,
2157                fee_reserve: Amount::from(500),
2158                estimated_blocks: 1,
2159            },
2160            MeltQuoteOnchainFeeOption {
2161                fee_index: 30,
2162                fee_reserve: Amount::from(50),
2163                estimated_blocks: 144,
2164            },
2165            MeltQuoteOnchainFeeOption {
2166                fee_index: 20,
2167                fee_reserve: Amount::from(200),
2168                estimated_blocks: 6,
2169            },
2170        ];
2171        let quote = MeltQuote::new_onchain(
2172            None,
2173            MeltPaymentRequest::Onchain {
2174                address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq".to_string(),
2175            },
2176            CurrencyUnit::Sat,
2177            Amount::new(10_000, CurrencyUnit::Sat),
2178            unix_time() + 3600,
2179            None,
2180            None,
2181            options.clone(),
2182        )
2183        .expect("well-formed quote must construct");
2184
2185        assert_eq!(quote.fee_reserve().value(), 50);
2186        assert_eq!(quote.estimated_blocks, Some(144));
2187        assert_eq!(quote.selected_fee_index, None);
2188        let returned: Vec<u32> = quote.fee_options().iter().map(|o| o.fee_index).collect();
2189        assert_eq!(returned, vec![10, 30, 20]);
2190    }
2191
2192    #[test]
2193    fn new_onchain_preserves_duplicate_backend_fee_index() {
2194        let options = vec![
2195            MeltQuoteOnchainFeeOption {
2196                fee_index: 7,
2197                fee_reserve: Amount::from(500),
2198                estimated_blocks: 1,
2199            },
2200            MeltQuoteOnchainFeeOption {
2201                fee_index: 7,
2202                fee_reserve: Amount::from(200),
2203                estimated_blocks: 6,
2204            },
2205        ];
2206        let quote = MeltQuote::new_onchain(
2207            None,
2208            MeltPaymentRequest::Onchain {
2209                address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq".to_string(),
2210            },
2211            CurrencyUnit::Sat,
2212            Amount::new(10_000, CurrencyUnit::Sat),
2213            unix_time() + 3600,
2214            None,
2215            None,
2216            options,
2217        )
2218        .expect("duplicate backend fee_index must be preserved");
2219
2220        let returned: Vec<u32> = quote.fee_options().iter().map(|o| o.fee_index).collect();
2221        assert_eq!(returned, vec![7, 7]);
2222    }
2223
2224    #[test]
2225    fn select_onchain_fee_option_leaves_fee_options_untouched() {
2226        let options = vec![
2227            MeltQuoteOnchainFeeOption {
2228                fee_index: 1,
2229                fee_reserve: Amount::from(500),
2230                estimated_blocks: 1,
2231            },
2232            MeltQuoteOnchainFeeOption {
2233                fee_index: 2,
2234                fee_reserve: Amount::from(200),
2235                estimated_blocks: 6,
2236            },
2237        ];
2238        let mut quote = MeltQuote::new_onchain(
2239            None,
2240            MeltPaymentRequest::Onchain {
2241                address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq".to_string(),
2242            },
2243            CurrencyUnit::Sat,
2244            Amount::new(10_000, CurrencyUnit::Sat),
2245            unix_time() + 3600,
2246            None,
2247            None,
2248            options.clone(),
2249        )
2250        .unwrap();
2251
2252        let before = quote.fee_options().to_vec();
2253        quote
2254            .select_onchain_fee_option(1)
2255            .expect("selecting a known fee_index must succeed");
2256
2257        assert_eq!(
2258            quote.fee_options(),
2259            before.as_slice(),
2260            "fee_options is fixed for the lifetime of the quote and must not \
2261             mutate on selection"
2262        );
2263        assert_eq!(quote.selected_fee_index, Some(1));
2264        assert_eq!(quote.estimated_blocks, Some(1));
2265        assert_eq!(quote.fee_reserve().value(), 500);
2266    }
2267
2268    #[test]
2269    fn select_onchain_fee_option_unknown_index_rejected() {
2270        let options = vec![MeltQuoteOnchainFeeOption {
2271            fee_index: 0,
2272            fee_reserve: Amount::from(500),
2273            estimated_blocks: 1,
2274        }];
2275        let mut quote = MeltQuote::new_onchain(
2276            None,
2277            MeltPaymentRequest::Onchain {
2278                address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq".to_string(),
2279            },
2280            CurrencyUnit::Sat,
2281            Amount::new(10_000, CurrencyUnit::Sat),
2282            unix_time() + 3600,
2283            None,
2284            None,
2285            options,
2286        )
2287        .unwrap();
2288
2289        match quote
2290            .select_onchain_fee_option(7)
2291            .expect_err("unknown fee_index must be rejected")
2292        {
2293            crate::Error::OnchainFeeIndexNotFound { index: 7 } => {}
2294            other => panic!("unexpected error: {other:?}"),
2295        }
2296    }
2297
2298    #[test]
2299    fn from_db_preserves_duplicate_onchain_fee_options() {
2300        let options = vec![
2301            MeltQuoteOnchainFeeOption {
2302                fee_index: 0,
2303                fee_reserve: Amount::from(100),
2304                estimated_blocks: 6,
2305            },
2306            MeltQuoteOnchainFeeOption {
2307                fee_index: 0,
2308                fee_reserve: Amount::from(200),
2309                estimated_blocks: 6,
2310            },
2311        ];
2312        let quote = MeltQuote::from_db(
2313            QuoteId::new(),
2314            CurrencyUnit::Sat,
2315            MeltPaymentRequest::Onchain {
2316                address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq".to_string(),
2317            },
2318            10_000,
2319            100,
2320            MeltQuoteState::Unpaid,
2321            unix_time() + 3600,
2322            None,
2323            None,
2324            None,
2325            unix_time(),
2326            None,
2327            PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain),
2328            None,
2329            None,
2330            options,
2331            None,
2332        )
2333        .expect("duplicate onchain fee_options on reload must be preserved");
2334
2335        let returned: Vec<u32> = quote.fee_options().iter().map(|o| o.fee_index).collect();
2336        assert_eq!(returned, vec![0, 0]);
2337    }
2338
2339    #[test]
2340    fn test_custom_mint_quote_response_surfaces_extra_json() {
2341        let extra = serde_json::json!({"payment_url": "https://example.com/pay", "ref": 42});
2342        let now = unix_time();
2343        let quote = MintQuote::new(
2344            Some(QuoteId::new()),
2345            "custom://request".to_string(),
2346            CurrencyUnit::Sat,
2347            Some(Amount::new(500, CurrencyUnit::Sat)),
2348            unix_time() + 3600,
2349            PaymentIdentifier::Label("test".to_string()),
2350            None,
2351            Amount::new(0, CurrencyUnit::Sat),
2352            Amount::new(0, CurrencyUnit::Sat),
2353            PaymentMethod::Custom("custom".to_string()),
2354            now,
2355            now,
2356            Vec::new(),
2357            Vec::new(),
2358            Some(extra.clone()),
2359        );
2360
2361        let response: MintQuoteResponse<QuoteId> = quote.try_into().expect("conversion succeeds");
2362        match response {
2363            MintQuoteResponse::Custom { response, .. } => {
2364                assert_eq!(response.extra, extra);
2365            }
2366            other => panic!("expected Custom variant, got {:?}", other),
2367        }
2368    }
2369
2370    #[test]
2371    fn from_db_rejects_empty_onchain_fee_options() {
2372        let err = MeltQuote::from_db(
2373            QuoteId::new(),
2374            CurrencyUnit::Sat,
2375            MeltPaymentRequest::Onchain {
2376                address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq".to_string(),
2377            },
2378            10_000,
2379            100,
2380            MeltQuoteState::Unpaid,
2381            unix_time() + 3600,
2382            None,
2383            None,
2384            None,
2385            unix_time(),
2386            None,
2387            PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain),
2388            None,
2389            Some(6),
2390            Vec::new(),
2391            None,
2392        )
2393        .expect_err("empty onchain fee_options on reload must be rejected");
2394        assert!(matches!(err, crate::Error::OnchainFeeOptionsEmpty));
2395    }
2396}