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