Skip to main content

cdk_common/wallet/
mod.rs

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