Skip to main content

cdk_common/
mint.rs

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