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