Skip to main content

cdk_common/wallet/
mod.rs

1//! Wallet Types
2
3use std::collections::HashMap;
4use std::fmt;
5use std::str::FromStr;
6
7use async_trait::async_trait;
8use bitcoin::bip32::DerivationPath;
9use bitcoin::hashes::{sha256, Hash, HashEngine};
10use cashu::amount::{FeeAndAmounts, KeysetFeeAndAmounts, SplitTarget};
11use cashu::nuts::nut07::ProofState;
12use cashu::nuts::AuthProof;
13use cashu::util::hex;
14use cashu::{nut00, PaymentMethod, Proof, Proofs, PublicKey};
15use serde::{Deserialize, Serialize};
16use uuid::Uuid;
17
18use crate::mint_quote::quote_state_from_amounts;
19use crate::mint_url::MintUrl;
20use crate::nuts::{
21    CurrencyUnit, Id, MeltQuoteState, MintQuoteState, SecretKey, SpendingConditions, State,
22};
23#[cfg(feature = "http")]
24use crate::rate_limit::RateLimitConfig;
25use crate::{Amount, Error};
26
27pub mod saga;
28
29pub use saga::{
30    IssueSagaState, MeltOperationData, MeltSagaState, MintOperationData, OperationData,
31    ReceiveOperationData, ReceiveSagaState, SendOperationData, SendSagaState, SwapOperationData,
32    SwapSagaState, WalletSaga, WalletSagaState,
33};
34
35/// Wallet Key
36#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
37pub struct WalletKey {
38    /// Mint Url
39    pub mint_url: MintUrl,
40    /// Currency Unit
41    pub unit: CurrencyUnit,
42}
43
44impl fmt::Display for WalletKey {
45    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46        write!(f, "mint_url: {}, unit: {}", self.mint_url, self.unit,)
47    }
48}
49
50impl WalletKey {
51    /// Create new [`WalletKey`]
52    pub fn new(mint_url: MintUrl, unit: CurrencyUnit) -> Self {
53        Self { mint_url, unit }
54    }
55}
56
57/// Proof info
58#[derive(Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
59pub struct ProofInfo {
60    /// Proof
61    pub proof: Proof,
62    /// y
63    pub y: PublicKey,
64    /// Mint Url
65    pub mint_url: MintUrl,
66    /// Proof State
67    pub state: State,
68    /// Proof Spending Conditions
69    pub spending_condition: Option<SpendingConditions>,
70    /// Unit
71    pub unit: CurrencyUnit,
72    /// NUT-13 derivation index for deterministic proofs.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub derivation_index: Option<u32>,
75    /// Operation ID that is using/spending this proof
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub used_by_operation: Option<Uuid>,
78    /// Operation ID that created this proof
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub created_by_operation: Option<Uuid>,
81}
82
83impl fmt::Debug for ProofInfo {
84    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85        f.debug_struct("ProofInfo")
86            .field("amount", &self.proof.amount)
87            .field("keyset_id", &self.proof.keyset_id)
88            .field("proof", &"[REDACTED]")
89            .field("y", &self.y)
90            .field("mint_url", &self.mint_url)
91            .field("state", &self.state)
92            .field("spending_condition", &self.spending_condition)
93            .field("unit", &self.unit)
94            .field("used_by_operation", &self.used_by_operation)
95            .field("created_by_operation", &self.created_by_operation)
96            .finish()
97    }
98}
99
100impl ProofInfo {
101    /// Create new [`ProofInfo`]
102    pub fn new(
103        proof: Proof,
104        mint_url: MintUrl,
105        state: State,
106        unit: CurrencyUnit,
107    ) -> Result<Self, Error> {
108        let y = proof.y()?;
109
110        let spending_condition: Option<SpendingConditions> = (&proof.secret).try_into().ok();
111
112        Ok(Self {
113            proof,
114            y,
115            mint_url,
116            state,
117            spending_condition,
118            unit,
119            derivation_index: None,
120            used_by_operation: None,
121            created_by_operation: None,
122        })
123    }
124
125    /// Create new [`ProofInfo`] with operation tracking
126    pub fn new_with_operations(
127        proof: Proof,
128        mint_url: MintUrl,
129        state: State,
130        unit: CurrencyUnit,
131        used_by_operation: Option<Uuid>,
132        created_by_operation: Option<Uuid>,
133    ) -> Result<Self, Error> {
134        let y = proof.y()?;
135
136        let spending_condition: Option<SpendingConditions> = (&proof.secret).try_into().ok();
137
138        Ok(Self {
139            proof,
140            y,
141            mint_url,
142            state,
143            spending_condition,
144            unit,
145            derivation_index: None,
146            used_by_operation,
147            created_by_operation,
148        })
149    }
150
151    /// Set the NUT-13 derivation index for this proof.
152    pub fn with_derivation_index(mut self, derivation_index: u32) -> Self {
153        self.derivation_index = Some(derivation_index);
154        self
155    }
156
157    /// Check if [`Proof`] matches conditions
158    pub fn matches_conditions(
159        &self,
160        mint_url: &Option<MintUrl>,
161        unit: &Option<CurrencyUnit>,
162        state: &Option<Vec<State>>,
163        spending_conditions: &Option<Vec<SpendingConditions>>,
164    ) -> bool {
165        if let Some(mint_url) = mint_url {
166            if mint_url.ne(&self.mint_url) {
167                return false;
168            }
169        }
170
171        if let Some(unit) = unit {
172            if unit.ne(&self.unit) {
173                return false;
174            }
175        }
176
177        if let Some(state) = state {
178            if !state.contains(&self.state) {
179                return false;
180            }
181        }
182
183        if let Some(spending_conditions) = spending_conditions {
184            match &self.spending_condition {
185                None => {
186                    if !spending_conditions.is_empty() {
187                        return false;
188                    }
189                }
190                Some(s) => {
191                    if !spending_conditions.contains(s) {
192                        return false;
193                    }
194                }
195            }
196        }
197
198        true
199    }
200}
201
202/// Mint Quote Info
203#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
204pub struct MintQuote {
205    /// Quote id
206    pub id: String,
207    /// Mint Url
208    pub mint_url: MintUrl,
209    /// Payment method
210    pub payment_method: PaymentMethod,
211    /// Requested or fixed quote amount, when defined by the payment method.
212    ///
213    /// Variable-amount methods such as onchain leave this unset and track
214    /// funds through `amount_paid` and `amount_issued`.
215    pub amount: Option<Amount>,
216    /// Unit of quote
217    pub unit: CurrencyUnit,
218    /// Quote payment request e.g. bolt11
219    pub request: String,
220    /// Quote state
221    pub state: MintQuoteState,
222    /// Expiration time of quote
223    pub expiry: u64,
224    /// Secretkey for signing mint quotes [NUT-20]
225    pub secret_key: Option<SecretKey>,
226    /// Amount minted
227    #[serde(default)]
228    pub amount_issued: Amount,
229    /// Amount paid to the mint for the quote
230    #[serde(default)]
231    pub amount_paid: Amount,
232    /// Unix timestamp indicating when the mint quote was last updated
233    #[serde(default)]
234    pub updated_at: u64,
235    /// Estimated confirmation target in blocks for onchain quotes
236    pub estimated_blocks: Option<u32>,
237    /// Operation ID that has reserved this quote (for saga pattern)
238    #[serde(default)]
239    pub used_by_operation: Option<String>,
240    /// Version for optimistic locking
241    #[serde(default)]
242    pub version: u32,
243}
244
245/// Melt Quote Info
246#[derive(Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
247pub struct MeltQuote {
248    /// Quote id
249    pub id: String,
250    /// Mint Url
251    pub mint_url: Option<MintUrl>,
252    /// Quote unit
253    pub unit: CurrencyUnit,
254    /// Quote amount
255    pub amount: Amount,
256    /// Quote Payment request e.g. bolt11
257    pub request: String,
258    /// Quote fee reserve
259    pub fee_reserve: Amount,
260    /// Quote state
261    pub state: MeltQuoteState,
262    /// Expiration time of quote
263    pub expiry: u64,
264    /// Payment proof (e.g. Lightning preimage or onchain outpoint)
265    #[serde(alias = "payment_preimage")]
266    pub payment_proof: Option<String>,
267    /// Estimated confirmation target in blocks for onchain quotes
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub estimated_blocks: Option<u32>,
270    /// Selected fee option index for onchain quotes
271    #[serde(default, skip_serializing_if = "Option::is_none")]
272    pub fee_index: Option<u32>,
273    /// Payment method
274    pub payment_method: PaymentMethod,
275    /// Operation ID that has reserved this quote (for saga pattern)
276    #[serde(default)]
277    pub used_by_operation: Option<String>,
278    /// Version for optimistic locking
279    #[serde(default)]
280    pub version: u32,
281}
282
283impl fmt::Debug for MeltQuote {
284    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
285        f.debug_struct("MeltQuote")
286            .field("id", &self.id)
287            .field("mint_url", &self.mint_url)
288            .field("unit", &self.unit)
289            .field("amount", &self.amount)
290            .field("request", &self.request)
291            .field("fee_reserve", &self.fee_reserve)
292            .field("state", &self.state)
293            .field("expiry", &self.expiry)
294            .field(
295                "payment_proof",
296                &self.payment_proof.as_ref().map(|_| "[REDACTED]"),
297            )
298            .field("estimated_blocks", &self.estimated_blocks)
299            .field("fee_index", &self.fee_index)
300            .field("payment_method", &self.payment_method)
301            .field("used_by_operation", &self.used_by_operation)
302            .field("version", &self.version)
303            .finish()
304    }
305}
306
307/// Quotes and fee information for a maximum cross-mint Lightning transfer.
308#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
309pub struct CrossMintTransferQuote {
310    /// Quote used to receive the Lightning payment at the destination mint.
311    pub mint_quote: MintQuote,
312    /// Quote used to pay the destination invoice from the source mint.
313    pub melt_quote: MeltQuote,
314    /// Input fee for spending all currently unspent source proofs.
315    pub input_fee: Amount,
316}
317
318impl MintQuote {
319    /// Create a new MintQuote
320    #[allow(clippy::too_many_arguments)]
321    pub fn new(
322        id: String,
323        mint_url: MintUrl,
324        payment_method: PaymentMethod,
325        amount: Option<Amount>,
326        unit: CurrencyUnit,
327        request: String,
328        expiry: u64,
329        secret_key: Option<SecretKey>,
330    ) -> Self {
331        Self {
332            id,
333            mint_url,
334            payment_method,
335            amount,
336            unit,
337            request,
338            state: MintQuoteState::Unpaid,
339            expiry,
340            secret_key,
341            amount_issued: Amount::ZERO,
342            amount_paid: Amount::ZERO,
343            updated_at: 0,
344            estimated_blocks: None,
345            used_by_operation: None,
346            version: 0,
347        }
348    }
349
350    /// Calculate the total amount including any fees
351    pub fn total_amount(&self) -> Amount {
352        self.amount_paid
353    }
354
355    /// Derive quote state from the tracked payment and issuance counters.
356    pub fn state_from_amounts(&self) -> MintQuoteState {
357        quote_state_from_amounts(self.amount_paid, self.amount_issued).unwrap_or(self.state)
358    }
359
360    /// Update quote state from the tracked payment and issuance counters.
361    pub fn update_state_from_amounts(&mut self) {
362        self.state = self.state_from_amounts();
363    }
364
365    /// Check if the quote has expired
366    pub fn is_expired(&self, current_time: u64) -> bool {
367        current_time > self.expiry
368    }
369
370    /// Amount that can be minted
371    pub fn amount_mintable(&self) -> Amount {
372        if self.payment_method == PaymentMethod::BOLT11 {
373            // BOLT11 is all-or-nothing: mint full amount when state is Paid
374            if self.state == MintQuoteState::Paid {
375                self.amount.unwrap_or(Amount::ZERO)
376            } else {
377                Amount::ZERO
378            }
379        } else {
380            // Other payment methods track incremental payments
381            self.amount_paid
382                .checked_sub(self.amount_issued)
383                .unwrap_or(Amount::ZERO)
384        }
385    }
386}
387
388/// Amounts recovered during a restore operation
389#[derive(Debug, Clone, Hash, PartialEq, Eq, Default)]
390pub struct Restored {
391    /// Amount in the restore that has already been spent
392    pub spent: Amount,
393    /// Amount restored that is unspent
394    pub unspent: Amount,
395    /// Amount restored that is pending
396    pub pending: Amount,
397}
398
399/// Options for [`crate::wallet::Wallet::restore_with_opts`].
400///
401/// Defaults match the NUT-13 spec recommendation
402/// (<https://github.com/cashubtc/nuts/blob/main/13.md#generate-blindedmessages>):
403/// a batch of 100 blinded messages and three consecutive empty batches to
404/// signal end-of-history. Callers that need more conservative pacing or
405/// different gap tolerance can override either field.
406#[derive(Debug, Clone)]
407pub struct NUT13Options {
408    /// Number of blinded messages to request per batch.
409    pub batch_size: u32,
410    /// Number of consecutive empty batches that terminate the scan.
411    pub max_gap: u32,
412}
413
414impl Default for NUT13Options {
415    fn default() -> Self {
416        Self {
417            batch_size: Self::DEFAULT_BATCH_SIZE,
418            max_gap: Self::DEFAULT_MAX_GAP,
419        }
420    }
421}
422
423impl NUT13Options {
424    /// NUT-13 default restore batch size.
425    pub const DEFAULT_BATCH_SIZE: u32 = 100;
426
427    /// NUT-13 default restore gap limit.
428    pub const DEFAULT_MAX_GAP: u32 = 3;
429
430    /// Create new NUT-13 restore options.
431    pub fn new(batch_size: u32, max_gap: u32) -> Result<Self, Error> {
432        let opts = Self {
433            batch_size,
434            max_gap,
435        };
436        opts.validate()?;
437        Ok(opts)
438    }
439
440    pub(crate) fn validate(&self) -> Result<(), Error> {
441        if self.batch_size == 0 {
442            return Err(Error::InvalidNut13Options {
443                field: "batch_size",
444                reason: "must be greater than zero",
445            });
446        }
447
448        if self.max_gap == 0 {
449            return Err(Error::InvalidNut13Options {
450                field: "max_gap",
451                reason: "must be greater than zero",
452            });
453        }
454
455        Ok(())
456    }
457}
458
459/// Send options
460#[derive(Clone, Default)]
461pub struct SendOptions {
462    /// Memo
463    pub memo: Option<SendMemo>,
464    /// Spending conditions
465    pub conditions: Option<SpendingConditions>,
466    /// Amount split target
467    pub amount_split_target: SplitTarget,
468    /// Send kind
469    pub send_kind: SendKind,
470    /// Include fee
471    pub include_fee: bool,
472    /// Maximum number of proofs to include in the token
473    pub max_proofs: Option<usize>,
474    /// Metadata
475    pub metadata: HashMap<String, String>,
476    /// Use P2BK (NUT-28)
477    pub use_p2bk: bool,
478    /// Signing keys for P2PK-locked input proofs; auto-detected from the wallet keyring if omitted
479    pub p2pk_signing_keys: Vec<SecretKey>,
480    /// How P2PK-locked input proofs should be handled during send
481    pub p2pk_locked_proof_send_mode: P2PKLockedProofSendMode,
482}
483
484impl fmt::Debug for SendOptions {
485    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
486        f.debug_struct("SendOptions")
487            .field("memo", &self.memo)
488            .field("conditions", &self.conditions)
489            .field("amount_split_target", &self.amount_split_target)
490            .field("send_kind", &self.send_kind)
491            .field("include_fee", &self.include_fee)
492            .field("max_proofs", &self.max_proofs)
493            .field("metadata", &self.metadata)
494            .field("use_p2bk", &self.use_p2bk)
495            .field("p2pk_signing_keys", &"[redacted]")
496            .field(
497                "p2pk_locked_proof_send_mode",
498                &self.p2pk_locked_proof_send_mode,
499            )
500            .finish()
501    }
502}
503
504/// Send behavior for selected P2PK-locked input proofs
505#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
506pub enum P2PKLockedProofSendMode {
507    /// Swap locked proofs into fresh proofs before creating the token
508    #[default]
509    Swap,
510    /// Sign locked proofs and include them directly in the token
511    SignAndSend,
512}
513
514/// Send memo
515#[derive(Debug, Clone)]
516pub struct SendMemo {
517    /// Memo
518    pub memo: String,
519    /// Include memo in token
520    pub include_memo: bool,
521}
522
523impl SendMemo {
524    /// Create a new send memo
525    pub fn for_token(memo: &str) -> Self {
526        Self {
527            memo: memo.to_string(),
528            include_memo: true,
529        }
530    }
531}
532
533/// Receive options
534#[derive(Clone, Default)]
535pub struct ReceiveOptions {
536    /// Amount split target
537    pub amount_split_target: SplitTarget,
538    /// P2PK signing keys
539    pub p2pk_signing_keys: Vec<SecretKey>,
540    /// Preimages
541    pub preimages: Vec<String>,
542    /// Metadata
543    pub metadata: HashMap<String, String>,
544}
545
546impl fmt::Debug for ReceiveOptions {
547    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
548        f.debug_struct("ReceiveOptions")
549            .field("amount_split_target", &self.amount_split_target)
550            .field("p2pk_signing_keys", &"[redacted]")
551            .field("preimages", &"[redacted]")
552            .field("metadata", &self.metadata)
553            .finish()
554    }
555}
556
557/// Send Kind
558#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Default, Serialize, Deserialize)]
559pub enum SendKind {
560    #[default]
561    /// Allow online swap before send if wallet does not have exact amount
562    OnlineExact,
563    /// Prefer offline send if difference is less then tolerance
564    OnlineTolerance(Amount),
565    /// Wallet cannot do an online swap and selected proof must be exactly send amount
566    OfflineExact,
567    /// Wallet must remain offline but can over pay if below tolerance
568    OfflineTolerance(Amount),
569}
570
571impl SendKind {
572    /// Check if send kind is online
573    pub fn is_online(&self) -> bool {
574        matches!(self, Self::OnlineExact | Self::OnlineTolerance(_))
575    }
576
577    /// Check if send kind is offline
578    pub fn is_offline(&self) -> bool {
579        matches!(self, Self::OfflineExact | Self::OfflineTolerance(_))
580    }
581
582    /// Check if send kind is exact
583    pub fn is_exact(&self) -> bool {
584        matches!(self, Self::OnlineExact | Self::OfflineExact)
585    }
586
587    /// Check if send kind has tolerance
588    pub fn has_tolerance(&self) -> bool {
589        matches!(self, Self::OnlineTolerance(_) | Self::OfflineTolerance(_))
590    }
591}
592
593/// Wallet Transaction
594#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
595pub struct Transaction {
596    /// Mint Url
597    pub mint_url: MintUrl,
598    /// Transaction direction
599    pub direction: TransactionDirection,
600    /// Amount
601    pub amount: Amount,
602    /// Fee
603    pub fee: Amount,
604    /// Currency Unit
605    pub unit: CurrencyUnit,
606    /// Proof Ys
607    pub ys: Vec<PublicKey>,
608    /// Unix timestamp
609    pub timestamp: u64,
610    /// Memo
611    pub memo: Option<String>,
612    /// User-defined metadata
613    pub metadata: HashMap<String, String>,
614    /// Quote ID if this is a mint or melt transaction
615    pub quote_id: Option<String>,
616    /// Payment request (e.g., BOLT11 invoice, BOLT12 offer)
617    pub payment_request: Option<String>,
618    /// Payment proof (e.g., preimage for Lightning melt transactions)
619    #[serde(alias = "payment_preimage")]
620    pub payment_proof: Option<String>,
621    /// Payment method (e.g., Bolt11, Bolt12) for mint/melt transactions
622    #[serde(default)]
623    pub payment_method: Option<PaymentMethod>,
624    /// Saga ID if this transaction was part of a saga
625    #[serde(default)]
626    pub saga_id: Option<Uuid>,
627    /// Transaction status
628    #[serde(default)]
629    pub status: TransactionStatus,
630}
631
632impl fmt::Debug for Transaction {
633    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
634        f.debug_struct("Transaction")
635            .field("mint_url", &self.mint_url)
636            .field("direction", &self.direction)
637            .field("amount", &self.amount)
638            .field("fee", &self.fee)
639            .field("unit", &self.unit)
640            .field("ys", &self.ys)
641            .field("timestamp", &self.timestamp)
642            .field("memo", &self.memo)
643            .field("metadata", &self.metadata)
644            .field("quote_id", &self.quote_id)
645            .field("payment_request", &self.payment_request)
646            .field(
647                "payment_proof",
648                &self.payment_proof.as_ref().map(|_| "[REDACTED]"),
649            )
650            .field("payment_method", &self.payment_method)
651            .field("saga_id", &self.saga_id)
652            .field("status", &self.status)
653            .finish()
654    }
655}
656
657impl Transaction {
658    /// Transaction ID.
659    ///
660    /// Saga-managed transactions are identified by their saga ID so that
661    /// separate wallet operations involving the same proofs do not collide.
662    /// Legacy transactions without a saga ID retain their proof-derived ID.
663    pub fn id(&self) -> TransactionId {
664        match self.saga_id {
665            Some(saga_id) => match self.metadata.get("batch_quote_id") {
666                Some(quote_id) => TransactionId::from_batch_quote(saga_id, quote_id),
667                None => TransactionId::from_saga_id(saga_id),
668            },
669            None => TransactionId::new(self.ys.clone()),
670        }
671    }
672
673    /// Check if transaction matches conditions
674    pub fn matches_conditions(
675        &self,
676        mint_url: &Option<MintUrl>,
677        direction: &Option<TransactionDirection>,
678        unit: &Option<CurrencyUnit>,
679    ) -> bool {
680        if let Some(mint_url) = mint_url {
681            if &self.mint_url != mint_url {
682                return false;
683            }
684        }
685        if let Some(direction) = direction {
686            if &self.direction != direction {
687                return false;
688            }
689        }
690        if let Some(unit) = unit {
691            if &self.unit != unit {
692                return false;
693            }
694        }
695        true
696    }
697}
698
699impl PartialOrd for Transaction {
700    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
701        Some(self.cmp(other))
702    }
703}
704
705impl Ord for Transaction {
706    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
707        self.timestamp
708            .cmp(&other.timestamp)
709            .reverse()
710            .then_with(|| self.id().cmp(&other.id()))
711    }
712}
713
714/// Transaction Direction
715#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
716pub enum TransactionDirection {
717    /// Incoming transaction (i.e., receive or mint)
718    Incoming,
719    /// Outgoing transaction (i.e., send or melt)
720    Outgoing,
721}
722
723/// Wallet transaction status.
724#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
725#[serde(rename_all = "snake_case")]
726pub enum TransactionStatus {
727    /// The transaction is still in progress.
728    Pending,
729    /// The transaction completed successfully.
730    #[default]
731    Completed,
732    /// The transaction failed or was revoked.
733    Failed,
734}
735
736impl fmt::Display for TransactionStatus {
737    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
738        match self {
739            Self::Pending => write!(f, "pending"),
740            Self::Completed => write!(f, "completed"),
741            Self::Failed => write!(f, "failed"),
742        }
743    }
744}
745
746impl FromStr for TransactionStatus {
747    type Err = Error;
748
749    fn from_str(value: &str) -> Result<Self, Self::Err> {
750        match value {
751            "pending" => Ok(Self::Pending),
752            "completed" => Ok(Self::Completed),
753            "failed" => Ok(Self::Failed),
754            _ => Err(Error::InvalidTransactionStatus),
755        }
756    }
757}
758
759impl std::fmt::Display for TransactionDirection {
760    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
761        match self {
762            TransactionDirection::Incoming => write!(f, "Incoming"),
763            TransactionDirection::Outgoing => write!(f, "Outgoing"),
764        }
765    }
766}
767
768impl FromStr for TransactionDirection {
769    type Err = Error;
770
771    fn from_str(value: &str) -> Result<Self, Self::Err> {
772        match value {
773            "Incoming" => Ok(Self::Incoming),
774            "Outgoing" => Ok(Self::Outgoing),
775            _ => Err(Error::InvalidTransactionDirection),
776        }
777    }
778}
779
780/// Transaction ID
781#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
782#[serde(transparent)]
783pub struct TransactionId([u8; 32]);
784
785impl TransactionId {
786    /// Create a legacy proof-derived [`TransactionId`].
787    ///
788    /// Saga-managed transactions use [`Self::from_saga_id`] instead.
789    pub fn new(ys: Vec<PublicKey>) -> Self {
790        let mut ys = ys;
791        ys.sort();
792        let mut hasher = sha256::Hash::engine();
793        for y in ys {
794            hasher.input(&y.to_bytes());
795        }
796        let hash = sha256::Hash::from_engine(hasher);
797        Self(hash.to_byte_array())
798    }
799
800    /// Create a legacy proof-derived [`TransactionId`] from proofs.
801    ///
802    /// Saga-managed transactions use [`Self::from_saga_id`] instead.
803    pub fn from_proofs(proofs: Proofs) -> Result<Self, nut00::Error> {
804        let ys = proofs
805            .iter()
806            .map(|proof| proof.y())
807            .collect::<Result<Vec<PublicKey>, nut00::Error>>()?;
808        Ok(Self::new(ys))
809    }
810
811    /// Create a [`TransactionId`] from a wallet saga ID.
812    ///
813    /// The UUID's canonical 32-character representation preserves the existing
814    /// 32-byte transaction ID storage format and is portable across backends.
815    pub fn from_saga_id(saga_id: Uuid) -> Self {
816        let mut bytes = [0_u8; 32];
817        let encoded = saga_id.simple().to_string();
818        for (destination, source) in bytes.iter_mut().zip(encoded.bytes()) {
819            *destination = source;
820        }
821        Self(bytes)
822    }
823
824    /// Create a stable transaction ID for one quote within a batch saga.
825    pub fn from_batch_quote(saga_id: Uuid, quote_id: &str) -> Self {
826        let mut hasher = sha256::Hash::engine();
827        hasher.input(saga_id.as_bytes());
828        hasher.input(quote_id.as_bytes());
829        Self(sha256::Hash::from_engine(hasher).to_byte_array())
830    }
831
832    /// From bytes
833    pub fn from_bytes(bytes: [u8; 32]) -> Self {
834        Self(bytes)
835    }
836
837    /// From hex string
838    pub fn from_hex(value: &str) -> Result<Self, Error> {
839        let bytes = hex::decode(value)?;
840        if bytes.len() != 32 {
841            return Err(Error::InvalidTransactionId);
842        }
843        let mut array = [0u8; 32];
844        array.copy_from_slice(&bytes);
845        Ok(Self(array))
846    }
847
848    /// From slice
849    pub fn from_slice(slice: &[u8]) -> Result<Self, Error> {
850        if slice.len() != 32 {
851            return Err(Error::InvalidTransactionId);
852        }
853        let mut array = [0u8; 32];
854        array.copy_from_slice(slice);
855        Ok(Self(array))
856    }
857
858    /// Get inner value
859    pub fn as_bytes(&self) -> &[u8; 32] {
860        &self.0
861    }
862
863    /// Get inner value as slice
864    pub fn as_slice(&self) -> &[u8] {
865        &self.0
866    }
867}
868
869impl std::fmt::Display for TransactionId {
870    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
871        write!(f, "{}", hex::encode(self.0))
872    }
873}
874
875impl FromStr for TransactionId {
876    type Err = Error;
877
878    fn from_str(value: &str) -> Result<Self, Self::Err> {
879        Self::from_hex(value)
880    }
881}
882
883impl TryFrom<Proofs> for TransactionId {
884    type Error = nut00::Error;
885
886    fn try_from(proofs: Proofs) -> Result<Self, Self::Error> {
887        Self::from_proofs(proofs)
888    }
889}
890
891/// Wallet operation kind
892#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize)]
893#[serde(rename_all = "snake_case")]
894pub enum OperationKind {
895    /// Send operation
896    Send,
897    /// Receive operation
898    Receive,
899    /// Swap operation
900    Swap,
901    /// Mint operation
902    Mint,
903    /// Melt operation
904    Melt,
905}
906
907impl fmt::Display for OperationKind {
908    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
909        match self {
910            OperationKind::Send => write!(f, "send"),
911            OperationKind::Receive => write!(f, "receive"),
912            OperationKind::Swap => write!(f, "swap"),
913            OperationKind::Mint => write!(f, "mint"),
914            OperationKind::Melt => write!(f, "melt"),
915        }
916    }
917}
918
919impl FromStr for OperationKind {
920    type Err = Error;
921
922    fn from_str(s: &str) -> Result<Self, Self::Err> {
923        match s {
924            "send" => Ok(OperationKind::Send),
925            "receive" => Ok(OperationKind::Receive),
926            "swap" => Ok(OperationKind::Swap),
927            "mint" => Ok(OperationKind::Mint),
928            "melt" => Ok(OperationKind::Melt),
929            _ => Err(Error::InvalidOperationKind),
930        }
931    }
932}
933
934/// Filter for keyset queries
935#[derive(Debug, Clone, Copy, PartialEq, Eq)]
936pub enum KeysetFilter {
937    /// Only return active keysets
938    Active,
939    /// Return all keysets (active and inactive)
940    All,
941}
942
943/// Policy controlling how keysets are loaded.
944///
945/// Determines the data-fetching strategy for keyset queries:
946/// memory cache, local database, and/or network.
947#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
948pub enum KeysetLoadPolicy {
949    /// Use in-memory cache and local database only. Never contacts the network.
950    /// Returns an error if neither cache nor database has data.
951    CacheOnly,
952    /// Return cached data when fresh (TTL not expired). When the cache is
953    /// empty, tries the database first, then the network. When the cache is
954    /// populated but stale (TTL expired), fetches directly from the network,
955    /// falling back to the stale cache if the network call fails.
956    /// This is the default.
957    #[default]
958    CacheThenNetwork,
959    /// Fetch data from the mint over the network and update the cache.
960    /// Falls back to stale cached data if the network call fails;
961    /// only returns an error when no cached data exists at all.
962    Refresh,
963}
964
965/// Unified wallet trait providing a common interface for wallet operations.
966///
967/// This trait abstracts over different wallet implementations (CDK wallet, FFI
968/// wrappers, etc.) and provides a consistent interface for balance queries,
969/// minting, melting, keyset management, and other core wallet operations.
970///
971/// All domain types are associated types so each implementation can use its own
972/// type system (e.g. FFI-friendly records vs native Rust types).
973#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
974#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
975pub trait Wallet: Send + Sync {
976    /// Error type
977    type Error: std::error::Error + Send + Sync + 'static;
978    /// Amount type (e.g. `cdk_common::Amount` or FFI `Amount`)
979    type Amount: Clone + Send + Sync;
980    /// Mint URL type
981    type MintUrl: Clone + Send + Sync;
982    /// Currency unit type
983    type CurrencyUnit: Clone + Send + Sync;
984    /// Mint info type
985    type MintInfo: Clone + Send + Sync;
986    /// Keyset info type
987    type KeySetInfo: Clone + Send + Sync;
988    /// Mint quote type
989    type MintQuote: Clone + Send + Sync;
990    /// Melt quote type
991    type MeltQuote: Clone + Send + Sync;
992    /// Cross-mint transfer quote type
993    type CrossMintTransferQuote: Clone + Send + Sync;
994    /// Payment method type
995    type PaymentMethod: Clone + Send + Sync;
996    /// Melt options type
997    type MeltOptions: Clone + Send + Sync;
998    /// Operation ID type (CDK uses `Uuid`, FFI uses `String`)
999    type OperationId: Clone + Send + Sync;
1000    /// Prepared send type
1001    type PreparedSend<'a>: Send + Sync
1002    where
1003        Self: 'a;
1004    /// Prepared melt type
1005    type PreparedMelt<'a>: Send + Sync
1006    where
1007        Self: 'a;
1008    /// Active subscription handle for receiving notifications
1009    type Subscription: Send + Sync;
1010    /// Subscribe params type
1011    type SubscribeParams: Clone + Send + Sync;
1012    /// Saga recovery report type
1013    type RecoveryReport: Clone + Send + Sync;
1014
1015    /// Get the mint URL this wallet is connected to
1016    fn mint_url(&self) -> Self::MintUrl;
1017
1018    /// Get the currency unit of this wallet
1019    fn unit(&self) -> Self::CurrencyUnit;
1020
1021    /// Total unspent balance of the wallet
1022    async fn total_balance(&self) -> Result<Self::Amount, Self::Error>;
1023
1024    /// Total pending balance of the wallet
1025    async fn total_pending_balance(&self) -> Result<Self::Amount, Self::Error>;
1026
1027    /// Total reserved balance of the wallet
1028    async fn total_reserved_balance(&self) -> Result<Self::Amount, Self::Error>;
1029
1030    /// Fetch mint info from the mint (always makes a network call)
1031    async fn fetch_mint_info(&self) -> Result<Option<Self::MintInfo>, Self::Error>;
1032
1033    /// Load mint info (from cache if fresh, otherwise fetches)
1034    async fn load_mint_info(&self) -> Result<Self::MintInfo, Self::Error>;
1035
1036    /// Get all keysets for this wallet's unit.
1037    ///
1038    /// The `policy` parameter controls the fetching strategy:
1039    /// - [`CacheOnly`](KeysetLoadPolicy::CacheOnly) — in-memory cache + local DB, no network
1040    /// - [`CacheThenNetwork`](KeysetLoadPolicy::CacheThenNetwork) — cache if fresh, network if
1041    ///   stale/absent, stale fallback on failure (default)
1042    /// - [`Refresh`](KeysetLoadPolicy::Refresh) — network first, stale-cache fallback on failure
1043    async fn keysets(&self, policy: KeysetLoadPolicy)
1044        -> Result<Vec<Self::KeySetInfo>, Self::Error>;
1045
1046    /// Get the active keyset with the lowest fees.
1047    ///
1048    /// Filters the output of [`keysets()`](Self::keysets) for active keysets
1049    /// and returns the one with the minimum `input_fee_ppk`.
1050    async fn active_keyset(&self) -> Result<Self::KeySetInfo, Self::Error>;
1051
1052    /// Get a single keyset by ID.
1053    async fn keyset(&self, keyset_id: Id) -> Result<Self::KeySetInfo, Self::Error>;
1054
1055    /// Get fees and available amounts for all keysets
1056    async fn get_keyset_fees_and_amounts(&self) -> Result<KeysetFeeAndAmounts, Self::Error>;
1057
1058    /// Get fee for count of proofs in a keyset
1059    async fn get_keyset_count_fee(
1060        &self,
1061        keyset_id: &Id,
1062        count: u64,
1063    ) -> Result<Self::Amount, Self::Error>;
1064
1065    /// Get fees and amounts for a specific keyset ID
1066    async fn get_keyset_fees_and_amounts_by_id(
1067        &self,
1068        keyset_id: Id,
1069    ) -> Result<FeeAndAmounts, Self::Error>;
1070
1071    /// Create a mint quote for the given payment method
1072    async fn mint_quote(
1073        &self,
1074        method: Self::PaymentMethod,
1075        amount: Option<Self::Amount>,
1076        description: Option<String>,
1077        extra: Option<String>,
1078    ) -> Result<Self::MintQuote, Self::Error>;
1079
1080    /// Create a melt quote for the given payment method
1081    async fn melt_quote(
1082        &self,
1083        method: Self::PaymentMethod,
1084        request: String,
1085        options: Option<Self::MeltOptions>,
1086        extra: Option<String>,
1087    ) -> Result<Self::MeltQuote, Self::Error>;
1088
1089    /// Create destination mint and source melt quotes for the maximum amount
1090    /// allowed by the source balance and both mints' advertised limits.
1091    ///
1092    /// # Remote side effects
1093    ///
1094    /// The search may create multiple quote pairs because the source mint's fee
1095    /// reserve is learned from each destination invoice. Only the returned pair
1096    /// is persisted locally; unused remote quotes remain until they expire.
1097    async fn cross_mint_transfer_quote_max(
1098        &self,
1099        target_wallet: &Self,
1100    ) -> Result<Self::CrossMintTransferQuote, Self::Error>;
1101
1102    /// List transactions, optionally filtered by direction
1103    async fn list_transactions(
1104        &self,
1105        direction: Option<TransactionDirection>,
1106    ) -> Result<Vec<Transaction>, Self::Error>;
1107
1108    /// Get a transaction by ID
1109    async fn get_transaction(&self, id: TransactionId) -> Result<Option<Transaction>, Self::Error>;
1110
1111    /// Get proofs for a transaction by transaction ID
1112    async fn get_proofs_for_transaction(&self, id: TransactionId) -> Result<Proofs, Self::Error>;
1113
1114    /// Revert a transaction by reclaiming unspent proofs
1115    async fn revert_transaction(&self, id: TransactionId) -> Result<(), Self::Error>;
1116
1117    /// Check all pending proofs and return total amount still pending
1118    async fn check_all_pending_proofs(&self) -> Result<Self::Amount, Self::Error>;
1119
1120    /// Recover from incomplete operations after a crash
1121    async fn recover_incomplete_sagas(&self) -> Result<Self::RecoveryReport, Self::Error>;
1122
1123    /// Check if proofs are spent
1124    async fn check_proofs_spent(&self, proofs: Proofs) -> Result<Vec<ProofState>, Self::Error>;
1125
1126    /// Get fees for a specific keyset ID
1127    async fn get_keyset_fees_by_id(&self, keyset_id: Id) -> Result<u64, Self::Error>;
1128
1129    /// Calculate fee for a given number of proofs with the specified keyset
1130    async fn calculate_fee(
1131        &self,
1132        proof_count: u64,
1133        keyset_id: Id,
1134    ) -> Result<Self::Amount, Self::Error>;
1135
1136    /// Receive an encoded token
1137    async fn receive(
1138        &self,
1139        encoded_token: &str,
1140        options: ReceiveOptions,
1141    ) -> Result<Self::Amount, Self::Error>;
1142
1143    /// Receive proofs directly
1144    async fn receive_proofs(
1145        &self,
1146        proofs: Proofs,
1147        options: ReceiveOptions,
1148        memo: Option<String>,
1149        token: Option<String>,
1150    ) -> Result<Self::Amount, Self::Error>;
1151
1152    /// Prepare a send transaction
1153    async fn prepare_send(
1154        &self,
1155        amount: Self::Amount,
1156        options: SendOptions,
1157    ) -> Result<Self::PreparedSend<'_>, Self::Error>;
1158
1159    /// Get pending send operation IDs
1160    async fn get_pending_sends(&self) -> Result<Vec<Self::OperationId>, Self::Error>;
1161
1162    /// Revoke a pending send operation
1163    async fn revoke_send(
1164        &self,
1165        operation_id: Self::OperationId,
1166    ) -> Result<Self::Amount, Self::Error>;
1167
1168    /// Check if a pending send has been claimed
1169    async fn check_send_status(&self, operation_id: Self::OperationId)
1170        -> Result<bool, Self::Error>;
1171
1172    /// Mint tokens for a quote
1173    async fn mint(
1174        &self,
1175        quote_id: &str,
1176        split_target: SplitTarget,
1177        spending_conditions: Option<SpendingConditions>,
1178    ) -> Result<Proofs, Self::Error>;
1179
1180    /// Check and mint any paid but unissued mint quotes
1181    async fn mint_unissued_quotes(&self) -> Result<Self::Amount, Self::Error>;
1182
1183    /// Check mint quote status
1184    async fn check_mint_quote_status(&self, quote_id: &str)
1185        -> Result<Self::MintQuote, Self::Error>;
1186
1187    /// Fetch a mint quote from the mint and store it locally
1188    async fn fetch_mint_quote(
1189        &self,
1190        quote_id: &str,
1191        payment_method: Option<Self::PaymentMethod>,
1192    ) -> Result<Self::MintQuote, Self::Error>;
1193
1194    /// Fetch a mint quote and try to recover its NUT-20 signing key from the seed.
1195    ///
1196    /// Use after restoring from seed to claim a known quote ID. Searches the
1197    /// wallet's NUT-20 keys at counters `0..key_search_limit` for the public key
1198    /// the mint reports. Returns `Ok` even if no key matched: the returned quote's
1199    /// `secret_key` is set only on success, so check it before minting and retry
1200    /// with a larger limit if needed. Zero disables the search.
1201    async fn fetch_mint_quote_with_key_search(
1202        &self,
1203        quote_id: &str,
1204        payment_method: Option<Self::PaymentMethod>,
1205        key_search_limit: u32,
1206    ) -> Result<Self::MintQuote, Self::Error>;
1207
1208    /// Prepare a melt operation
1209    async fn prepare_melt(
1210        &self,
1211        quote_id: &str,
1212        metadata: HashMap<String, String>,
1213    ) -> Result<Self::PreparedMelt<'_>, Self::Error>;
1214
1215    /// Prepare a melt operation with specific proofs
1216    async fn prepare_melt_proofs(
1217        &self,
1218        quote_id: &str,
1219        proofs: Proofs,
1220        metadata: HashMap<String, String>,
1221    ) -> Result<Self::PreparedMelt<'_>, Self::Error>;
1222
1223    /// Prepare a melt operation from an encoded token
1224    ///
1225    /// Decodes the token, extracts proofs (handling keyset state internally),
1226    /// and prepares the melt. This is useful when the caller has a token and
1227    /// wants to skip manual decoding, which requires keyset state for v2 keysets.
1228    async fn prepare_melt_token(
1229        &self,
1230        quote_id: &str,
1231        encoded_token: &str,
1232        metadata: HashMap<String, String>,
1233    ) -> Result<Self::PreparedMelt<'_>, Self::Error>;
1234
1235    /// Swap proofs
1236    async fn swap(
1237        &self,
1238        amount: Option<Self::Amount>,
1239        split_target: SplitTarget,
1240        input_proofs: Proofs,
1241        spending_conditions: Option<SpendingConditions>,
1242        include_fees: bool,
1243        use_p2bk: bool,
1244    ) -> Result<Option<Proofs>, Self::Error>;
1245
1246    /// Set Clear Auth Token (CAT)
1247    async fn set_cat(&self, cat: String) -> Result<(), Self::Error>;
1248
1249    /// Set refresh token
1250    async fn set_refresh_token(&self, refresh_token: String) -> Result<(), Self::Error>;
1251
1252    /// Refresh access token using stored refresh token
1253    async fn refresh_access_token(&self) -> Result<(), Self::Error>;
1254
1255    /// Mint blind auth tokens
1256    async fn mint_blind_auth(&self, amount: Self::Amount) -> Result<Proofs, Self::Error>;
1257
1258    /// Get unspent auth proofs
1259    async fn get_unspent_auth_proofs(&self) -> Result<Vec<AuthProof>, Self::Error>;
1260
1261    /// Restore wallet from seed
1262    async fn restore(&self) -> Result<Restored, Self::Error>;
1263
1264    /// Restore wallet from seed with custom [`NUT13Options`]
1265    async fn restore_with_opts(&self, opts: NUT13Options) -> Result<Restored, Self::Error>;
1266
1267    /// Verify DLEQ proofs in a token
1268    async fn verify_token_dleq(&self, token_str: &str) -> Result<(), Self::Error>;
1269
1270    /// Subscribe to mint quote state updates
1271    ///
1272    /// Returns a subscription handle that receives notifications when
1273    /// any of the given mint quotes change state (e.g., Unpaid → Paid → Issued).
1274    async fn subscribe_mint_quote_state(
1275        &self,
1276        quote_ids: Vec<String>,
1277        method: Self::PaymentMethod,
1278    ) -> Result<Self::Subscription, Self::Error>;
1279
1280    /// Set metadata cache TTL (time-to-live) in seconds
1281    ///
1282    /// Controls how long cached mint metadata (keysets, keys, mint info) is considered fresh
1283    /// before requiring a refresh from the mint server.
1284    /// If `None`, cache never expires and is always used.
1285    fn set_metadata_cache_ttl(&self, ttl_secs: Option<u64>);
1286
1287    /// Configure client-side request pacing, or turn it off with `None`.
1288    ///
1289    /// Reaches every host the wallet paces, not only its mint, and is
1290    /// repository-wide when the wallet came from a wallet repository, since
1291    /// those share one limiter. `None` then `Some` is a reversible toggle.
1292    #[cfg(feature = "http")]
1293    fn set_rate_limiting_config(&self, config: Option<RateLimitConfig>);
1294
1295    /// Whether requests from this wallet are being paced right now.
1296    ///
1297    /// Also false when a custom transport left the limiter wired to nothing,
1298    /// which is what silently makes [`Self::set_rate_limiting_config`] a no-op.
1299    #[cfg(feature = "http")]
1300    fn is_rate_limited(&self) -> bool;
1301
1302    /// Wait until the rate-limit budget drawn down so far has been persisted.
1303    ///
1304    /// Persistence is otherwise best effort, so a caller that rebuilds the
1305    /// wallet or tears down the runtime without this barrier can lose the final
1306    /// write and start the next wallet with a full burst.
1307    #[cfg(feature = "http")]
1308    async fn flush_rate_limits(&self);
1309
1310    /// Subscribe to wallet events
1311    async fn subscribe(
1312        &self,
1313        params: Self::SubscribeParams,
1314    ) -> Result<Self::Subscription, Self::Error>;
1315
1316    /// Get a melt quote for a BIP353 address
1317    #[cfg(all(feature = "bip353", not(target_arch = "wasm32")))]
1318    async fn melt_bip353_quote(
1319        &self,
1320        bip353_address: &str,
1321        amount_msat: Self::Amount,
1322        network: bitcoin::Network,
1323    ) -> Result<Self::MeltQuote, Self::Error>;
1324
1325    /// Get a melt quote for a Lightning address
1326    #[cfg(not(target_arch = "wasm32"))]
1327    async fn melt_lightning_address_quote(
1328        &self,
1329        lightning_address: &str,
1330        amount_msat: Self::Amount,
1331    ) -> Result<Self::MeltQuote, Self::Error>;
1332
1333    /// Get a melt quote for a human-readable address
1334    ///
1335    /// Accepts a human-readable address that could be either a BIP353 address
1336    /// or a Lightning address. Tries BIP353 first if mint supports Bolt12,
1337    /// falls back to Lightning address.
1338    #[cfg(all(feature = "bip353", not(target_arch = "wasm32")))]
1339    async fn melt_human_readable_quote(
1340        &self,
1341        address: &str,
1342        amount_msat: Self::Amount,
1343        network: bitcoin::Network,
1344    ) -> Result<Self::MeltQuote, Self::Error>;
1345
1346    /// Get a melt quote for a human-readable address (alias for `melt_human_readable_quote`)
1347    #[cfg(all(feature = "bip353", not(target_arch = "wasm32")))]
1348    async fn melt_human_readable(
1349        &self,
1350        address: &str,
1351        amount_msat: Self::Amount,
1352        network: bitcoin::Network,
1353    ) -> Result<Self::MeltQuote, Self::Error> {
1354        self.melt_human_readable_quote(address, amount_msat, network)
1355            .await
1356    }
1357
1358    /// Check a mint quote status (alias for `check_mint_quote_status`)
1359    async fn check_mint_quote(&self, quote_id: &str) -> Result<Self::MintQuote, Self::Error> {
1360        self.check_mint_quote_status(quote_id).await
1361    }
1362
1363    /// Mint tokens for a quote (alias for `mint`)
1364    async fn mint_unified(
1365        &self,
1366        quote_id: &str,
1367        split_target: SplitTarget,
1368        spending_conditions: Option<SpendingConditions>,
1369    ) -> Result<Proofs, Self::Error> {
1370        self.mint(quote_id, split_target, spending_conditions).await
1371    }
1372
1373    /// Get proofs filtered by states
1374    ///
1375    /// Returns all proofs whose state matches any of the given states.
1376    /// The `Spent` state is typically excluded since spent proofs are removed
1377    /// from the database.
1378    async fn get_proofs_by_states(&self, states: Vec<State>) -> Result<Proofs, Self::Error>;
1379
1380    // P2PK proofs
1381    /// generates and stores public key in database
1382    async fn generate_public_key(&self) -> Result<PublicKey, Self::Error>;
1383
1384    /// gets public key by it's hex value
1385    async fn get_public_key(
1386        &self,
1387        pubkey: &PublicKey,
1388    ) -> Result<Option<P2PKSigningKey>, Self::Error>;
1389
1390    /// gets list of stored public keys in database
1391    async fn get_public_keys(&self) -> Result<Vec<P2PKSigningKey>, Self::Error>;
1392
1393    /// Gets the latest generated P2PK signing key (most recently created)
1394    async fn get_latest_public_key(&self) -> Result<Option<P2PKSigningKey>, Self::Error>;
1395
1396    /// try to get secret key from p2pk signing key in localstore
1397    async fn get_signing_key(&self, pubkey: &PublicKey) -> Result<Option<SecretKey>, Self::Error>;
1398}
1399
1400/// Public key generated for proof signing
1401#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
1402pub struct P2PKSigningKey {
1403    /// Public key
1404    pub pubkey: PublicKey,
1405    /// Derivation path
1406    pub derivation_path: DerivationPath,
1407    /// Derivation index
1408    pub derivation_index: u32,
1409    /// Created time
1410    pub created_time: u64,
1411}
1412
1413#[cfg(test)]
1414mod tests {
1415    use super::*;
1416    use crate::nuts::Id;
1417    use crate::secret::Secret;
1418
1419    #[test]
1420    fn test_transaction_id_from_hex() {
1421        let hex_str = "a1b2c3d4e5f60718293a0b1c2d3e4f506172839a0b1c2d3e4f506172839a0b1c";
1422        let transaction_id = TransactionId::from_hex(hex_str).unwrap();
1423        assert_eq!(transaction_id.to_string(), hex_str);
1424    }
1425
1426    #[test]
1427    fn test_transaction_id_from_hex_empty_string() {
1428        let hex_str = "";
1429        let res = TransactionId::from_hex(hex_str);
1430        assert!(matches!(res, Err(Error::InvalidTransactionId)));
1431    }
1432
1433    #[test]
1434    fn test_transaction_id_from_hex_longer_string() {
1435        let hex_str = "a1b2c3d4e5f60718293a0b1c2d3e4f506172839a0b1c2d3e4f506172839a0b1ca1b2";
1436        let res = TransactionId::from_hex(hex_str);
1437        assert!(matches!(res, Err(Error::InvalidTransactionId)));
1438    }
1439
1440    #[test]
1441    fn transaction_id_from_saga_id_uses_canonical_uuid_bytes() {
1442        let saga_id =
1443            Uuid::parse_str("019fa338-b72f-7f21-9bb2-a504cdd5927b").expect("valid saga ID");
1444        let transaction_id = TransactionId::from_saga_id(saga_id);
1445
1446        assert_eq!(
1447            transaction_id.as_bytes(),
1448            b"019fa338b72f7f219bb2a504cdd5927b"
1449        );
1450    }
1451
1452    #[test]
1453    fn batch_quote_transaction_ids_are_stable_and_distinct() {
1454        let saga_id =
1455            Uuid::parse_str("019fa338-b72f-7f21-9bb2-a504cdd5927b").expect("valid saga ID");
1456
1457        let first = TransactionId::from_batch_quote(saga_id, "quote-a");
1458        let first_again = TransactionId::from_batch_quote(saga_id, "quote-a");
1459        let second = TransactionId::from_batch_quote(saga_id, "quote-b");
1460
1461        assert_eq!(first, first_again);
1462        assert_ne!(first, second);
1463        assert_ne!(first, TransactionId::from_saga_id(saga_id));
1464    }
1465
1466    #[test]
1467    fn saga_managed_transactions_with_the_same_ys_have_distinct_ids() {
1468        let ys = vec![SecretKey::generate().public_key()];
1469        let transaction = Transaction {
1470            mint_url: MintUrl::from_str("https://mint.example.com").expect("valid mint URL"),
1471            direction: TransactionDirection::Outgoing,
1472            amount: Amount::from(10),
1473            fee: Amount::ZERO,
1474            unit: CurrencyUnit::Sat,
1475            ys,
1476            timestamp: 42,
1477            memo: None,
1478            metadata: HashMap::new(),
1479            quote_id: None,
1480            payment_request: None,
1481            payment_proof: None,
1482            payment_method: None,
1483            saga_id: Some(Uuid::new_v4()),
1484            status: TransactionStatus::Pending,
1485        };
1486        let mut received = transaction.clone();
1487        received.direction = TransactionDirection::Incoming;
1488        received.saga_id = Some(Uuid::new_v4());
1489
1490        assert_ne!(transaction.id(), received.id());
1491    }
1492
1493    #[test]
1494    fn test_matches_conditions() {
1495        let keyset_id = Id::from_str("00deadbeef123456").unwrap();
1496        let proof = Proof::new(
1497            Amount::from(64),
1498            keyset_id,
1499            Secret::new("test_secret"),
1500            PublicKey::from_hex(
1501                "02deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
1502            )
1503            .unwrap(),
1504        );
1505
1506        let mint_url = MintUrl::from_str("https://example.com").unwrap();
1507        let proof_info =
1508            ProofInfo::new(proof, mint_url.clone(), State::Unspent, CurrencyUnit::Sat).unwrap();
1509
1510        // Test matching mint_url
1511        assert!(proof_info.matches_conditions(&Some(mint_url.clone()), &None, &None, &None));
1512        assert!(!proof_info.matches_conditions(
1513            &Some(MintUrl::from_str("https://different.com").unwrap()),
1514            &None,
1515            &None,
1516            &None
1517        ));
1518
1519        // Test matching unit
1520        assert!(proof_info.matches_conditions(&None, &Some(CurrencyUnit::Sat), &None, &None));
1521        assert!(!proof_info.matches_conditions(&None, &Some(CurrencyUnit::Msat), &None, &None));
1522
1523        // Test matching state
1524        assert!(proof_info.matches_conditions(&None, &None, &Some(vec![State::Unspent]), &None));
1525        assert!(proof_info.matches_conditions(
1526            &None,
1527            &None,
1528            &Some(vec![State::Unspent, State::Spent]),
1529            &None
1530        ));
1531        assert!(!proof_info.matches_conditions(&None, &None, &Some(vec![State::Spent]), &None));
1532
1533        // Test with no conditions (should match)
1534        assert!(proof_info.matches_conditions(&None, &None, &None, &None));
1535
1536        // Test with multiple conditions
1537        assert!(proof_info.matches_conditions(
1538            &Some(mint_url),
1539            &Some(CurrencyUnit::Sat),
1540            &Some(vec![State::Unspent]),
1541            &None
1542        ));
1543    }
1544
1545    #[test]
1546    fn test_matches_conditions_with_spending_conditions() {
1547        // This test would need to be expanded with actual SpendingConditions
1548        // implementation, but we can test the basic case where no spending
1549        // conditions are present
1550
1551        let keyset_id = Id::from_str("00deadbeef123456").unwrap();
1552        let proof = Proof::new(
1553            Amount::from(64),
1554            keyset_id,
1555            Secret::new("test_secret"),
1556            PublicKey::from_hex(
1557                "02deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
1558            )
1559            .unwrap(),
1560        );
1561
1562        let mint_url = MintUrl::from_str("https://example.com").unwrap();
1563        let proof_info =
1564            ProofInfo::new(proof, mint_url, State::Unspent, CurrencyUnit::Sat).unwrap();
1565
1566        // Test with empty spending conditions (should match when proof has none)
1567        assert!(proof_info.matches_conditions(&None, &None, &None, &Some(vec![])));
1568
1569        // Test with non-empty spending conditions (should not match when proof has none)
1570        let dummy_condition = SpendingConditions::P2PKConditions {
1571            data: SecretKey::generate().public_key(),
1572            conditions: None,
1573        };
1574        assert!(!proof_info.matches_conditions(&None, &None, &None, &Some(vec![dummy_condition])));
1575    }
1576
1577    #[test]
1578    fn wallet_record_debug_redacts_spendable_secrets() {
1579        let proof_secret = "wallet-proof-secret";
1580        let payment_proof = "wallet-payment-preimage";
1581        let keyset_id = Id::from_str("00deadbeef123456").expect("valid keyset ID");
1582        let proof = Proof::new(
1583            Amount::from(64),
1584            keyset_id,
1585            Secret::new(proof_secret),
1586            PublicKey::from_hex(
1587                "02deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
1588            )
1589            .expect("valid public key"),
1590        );
1591        let mint_url = MintUrl::from_str("https://mint.example.com").expect("valid mint URL");
1592        let proof_info = ProofInfo::new(proof, mint_url.clone(), State::Unspent, CurrencyUnit::Sat)
1593            .expect("valid proof");
1594        let melt_quote = MeltQuote {
1595            id: "public-quote-id".to_string(),
1596            mint_url: Some(mint_url.clone()),
1597            unit: CurrencyUnit::Sat,
1598            amount: Amount::from(10),
1599            request: "public-payment-request".to_string(),
1600            fee_reserve: Amount::from(1),
1601            state: MeltQuoteState::Paid,
1602            expiry: 1_000,
1603            payment_proof: Some(payment_proof.to_string()),
1604            estimated_blocks: None,
1605            fee_index: None,
1606            payment_method: PaymentMethod::BOLT11,
1607            used_by_operation: None,
1608            version: 0,
1609        };
1610        let transaction = Transaction {
1611            mint_url,
1612            direction: TransactionDirection::Outgoing,
1613            amount: Amount::from(10),
1614            fee: Amount::from(1),
1615            unit: CurrencyUnit::Sat,
1616            ys: vec![],
1617            timestamp: 42,
1618            memo: None,
1619            metadata: HashMap::new(),
1620            quote_id: Some("public-quote-id".to_string()),
1621            payment_request: None,
1622            payment_proof: Some(payment_proof.to_string()),
1623            payment_method: Some(PaymentMethod::BOLT11),
1624            saga_id: None,
1625            status: TransactionStatus::Completed,
1626        };
1627
1628        for debug in [
1629            format!("{proof_info:?}"),
1630            format!("{melt_quote:?}"),
1631            format!("{transaction:?}"),
1632        ] {
1633            assert!(debug.contains("[REDACTED]"));
1634            assert!(!debug.contains(proof_secret));
1635            assert!(!debug.contains(payment_proof));
1636        }
1637    }
1638
1639    #[test]
1640    fn test_wallet_options_debug_redacts_p2pk_signing_keys() {
1641        let secret_key = SecretKey::generate();
1642        let secret_hex = secret_key.to_secret_hex();
1643        let preimage = "super_secret_htlc_preimage_xyz";
1644
1645        let send_options = SendOptions {
1646            p2pk_signing_keys: vec![secret_key.clone()],
1647            ..Default::default()
1648        };
1649        let receive_options = ReceiveOptions {
1650            p2pk_signing_keys: vec![secret_key],
1651            preimages: vec![preimage.to_string()],
1652            ..Default::default()
1653        };
1654
1655        let send_debug = format!("{:?}", send_options);
1656        let receive_debug = format!("{:?}", receive_options);
1657
1658        assert!(!send_debug.contains(&secret_hex));
1659        assert!(send_debug.contains("[redacted]"));
1660        assert!(!receive_debug.contains(&secret_hex));
1661        assert!(!receive_debug.contains(preimage));
1662        assert!(receive_debug.contains("[redacted]"));
1663    }
1664
1665    #[test]
1666    fn nut13_options_defaults_match_nut13_spec() {
1667        // NUT-13 recommends batch_size=100 and gap_limit=3.
1668        // https://github.com/cashubtc/nuts/blob/main/13.md#generate-blindedmessages
1669        let opts = NUT13Options::default();
1670        assert_eq!(opts.batch_size, NUT13Options::DEFAULT_BATCH_SIZE);
1671        assert_eq!(opts.max_gap, NUT13Options::DEFAULT_MAX_GAP);
1672    }
1673
1674    #[test]
1675    fn nut13_options_new_accepts_custom_values() {
1676        let opts = NUT13Options::new(25, 2).unwrap();
1677        let cloned = opts.clone();
1678        assert_eq!(cloned.batch_size, 25);
1679        assert_eq!(cloned.max_gap, 2);
1680    }
1681
1682    #[test]
1683    fn nut13_options_reject_zero_batch_size() {
1684        let err = NUT13Options::new(0, 2).unwrap_err();
1685        assert!(matches!(
1686            err,
1687            Error::InvalidNut13Options {
1688                field: "batch_size",
1689                ..
1690            }
1691        ));
1692    }
1693
1694    #[test]
1695    fn nut13_options_reject_zero_max_gap() {
1696        let err = NUT13Options::new(25, 0).unwrap_err();
1697        assert!(matches!(
1698            err,
1699            Error::InvalidNut13Options {
1700                field: "max_gap",
1701                ..
1702            }
1703        ));
1704    }
1705
1706    #[test]
1707    fn transaction_status_round_trips_and_rejects_unknown_values() {
1708        for status in [
1709            TransactionStatus::Pending,
1710            TransactionStatus::Completed,
1711            TransactionStatus::Failed,
1712        ] {
1713            assert_eq!(
1714                TransactionStatus::from_str(&status.to_string()).expect("valid status"),
1715                status
1716            );
1717        }
1718
1719        assert!(matches!(
1720            TransactionStatus::from_str("unknown"),
1721            Err(Error::InvalidTransactionStatus)
1722        ));
1723    }
1724
1725    #[test]
1726    fn transaction_without_status_defaults_to_completed() {
1727        let transaction = Transaction {
1728            mint_url: MintUrl::from_str("https://mint.example.com").expect("valid mint URL"),
1729            direction: TransactionDirection::Incoming,
1730            amount: Amount::from(10),
1731            fee: Amount::ZERO,
1732            unit: CurrencyUnit::Sat,
1733            ys: vec![SecretKey::generate().public_key()],
1734            timestamp: 42,
1735            memo: None,
1736            metadata: HashMap::new(),
1737            quote_id: None,
1738            payment_request: None,
1739            payment_proof: None,
1740            payment_method: None,
1741            saga_id: None,
1742            status: TransactionStatus::Pending,
1743        };
1744        let mut value = serde_json::to_value(transaction).expect("serialize transaction");
1745        value
1746            .as_object_mut()
1747            .expect("transaction serializes as an object")
1748            .remove("status");
1749
1750        let decoded: Transaction =
1751            serde_json::from_value(value).expect("deserialize legacy transaction");
1752
1753        assert_eq!(decoded.status, TransactionStatus::Completed);
1754    }
1755}