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