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