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