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 bitcoin::hashes::{sha256, Hash, HashEngine};
8use cashu::util::hex;
9use cashu::{nut00, PaymentMethod, Proof, Proofs, PublicKey};
10use serde::{Deserialize, Serialize};
11use uuid::Uuid;
12
13use crate::mint_url::MintUrl;
14use crate::nuts::{
15    CurrencyUnit, MeltQuoteState, MintQuoteState, SecretKey, SpendingConditions, State,
16};
17use crate::{Amount, Error};
18
19pub mod saga;
20
21pub use saga::{
22    IssueSagaState, MeltOperationData, MeltSagaState, MintOperationData, OperationData,
23    ReceiveOperationData, ReceiveSagaState, SendOperationData, SendSagaState, SwapOperationData,
24    SwapSagaState, WalletSaga, WalletSagaState,
25};
26
27/// Wallet Key
28#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
29pub struct WalletKey {
30    /// Mint Url
31    pub mint_url: MintUrl,
32    /// Currency Unit
33    pub unit: CurrencyUnit,
34}
35
36impl fmt::Display for WalletKey {
37    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38        write!(f, "mint_url: {}, unit: {}", self.mint_url, self.unit,)
39    }
40}
41
42impl WalletKey {
43    /// Create new [`WalletKey`]
44    pub fn new(mint_url: MintUrl, unit: CurrencyUnit) -> Self {
45        Self { mint_url, unit }
46    }
47}
48
49/// Proof info
50#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
51pub struct ProofInfo {
52    /// Proof
53    pub proof: Proof,
54    /// y
55    pub y: PublicKey,
56    /// Mint Url
57    pub mint_url: MintUrl,
58    /// Proof State
59    pub state: State,
60    /// Proof Spending Conditions
61    pub spending_condition: Option<SpendingConditions>,
62    /// Unit
63    pub unit: CurrencyUnit,
64    /// Operation ID that is using/spending this proof
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub used_by_operation: Option<Uuid>,
67    /// Operation ID that created this proof
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub created_by_operation: Option<Uuid>,
70}
71
72impl ProofInfo {
73    /// Create new [`ProofInfo`]
74    pub fn new(
75        proof: Proof,
76        mint_url: MintUrl,
77        state: State,
78        unit: CurrencyUnit,
79    ) -> Result<Self, Error> {
80        let y = proof.y()?;
81
82        let spending_condition: Option<SpendingConditions> = (&proof.secret).try_into().ok();
83
84        Ok(Self {
85            proof,
86            y,
87            mint_url,
88            state,
89            spending_condition,
90            unit,
91            used_by_operation: None,
92            created_by_operation: None,
93        })
94    }
95
96    /// Create new [`ProofInfo`] with operation tracking
97    pub fn new_with_operations(
98        proof: Proof,
99        mint_url: MintUrl,
100        state: State,
101        unit: CurrencyUnit,
102        used_by_operation: Option<Uuid>,
103        created_by_operation: Option<Uuid>,
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,
117            created_by_operation,
118        })
119    }
120
121    /// Check if [`Proof`] matches conditions
122    pub fn matches_conditions(
123        &self,
124        mint_url: &Option<MintUrl>,
125        unit: &Option<CurrencyUnit>,
126        state: &Option<Vec<State>>,
127        spending_conditions: &Option<Vec<SpendingConditions>>,
128    ) -> bool {
129        if let Some(mint_url) = mint_url {
130            if mint_url.ne(&self.mint_url) {
131                return false;
132            }
133        }
134
135        if let Some(unit) = unit {
136            if unit.ne(&self.unit) {
137                return false;
138            }
139        }
140
141        if let Some(state) = state {
142            if !state.contains(&self.state) {
143                return false;
144            }
145        }
146
147        if let Some(spending_conditions) = spending_conditions {
148            match &self.spending_condition {
149                None => {
150                    if !spending_conditions.is_empty() {
151                        return false;
152                    }
153                }
154                Some(s) => {
155                    if !spending_conditions.contains(s) {
156                        return false;
157                    }
158                }
159            }
160        }
161
162        true
163    }
164}
165
166/// Mint Quote Info
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
168pub struct MintQuote {
169    /// Quote id
170    pub id: String,
171    /// Mint Url
172    pub mint_url: MintUrl,
173    /// Payment method
174    pub payment_method: PaymentMethod,
175    /// Amount of quote
176    pub amount: Option<Amount>,
177    /// Unit of quote
178    pub unit: CurrencyUnit,
179    /// Quote payment request e.g. bolt11
180    pub request: String,
181    /// Quote state
182    pub state: MintQuoteState,
183    /// Expiration time of quote
184    pub expiry: u64,
185    /// Secretkey for signing mint quotes [NUT-20]
186    pub secret_key: Option<SecretKey>,
187    /// Amount minted
188    #[serde(default)]
189    pub amount_issued: Amount,
190    /// Amount paid to the mint for the quote
191    #[serde(default)]
192    pub amount_paid: Amount,
193    /// Operation ID that has reserved this quote (for saga pattern)
194    #[serde(default)]
195    pub used_by_operation: Option<String>,
196    /// Version for optimistic locking
197    #[serde(default)]
198    pub version: u32,
199}
200
201/// Melt Quote Info
202#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
203pub struct MeltQuote {
204    /// Quote id
205    pub id: String,
206    /// Quote unit
207    pub unit: CurrencyUnit,
208    /// Quote amount
209    pub amount: Amount,
210    /// Quote Payment request e.g. bolt11
211    pub request: String,
212    /// Quote fee reserve
213    pub fee_reserve: Amount,
214    /// Quote state
215    pub state: MeltQuoteState,
216    /// Expiration time of quote
217    pub expiry: u64,
218    /// Payment preimage
219    pub payment_preimage: Option<String>,
220    /// Payment method
221    pub payment_method: PaymentMethod,
222    /// Operation ID that has reserved this quote (for saga pattern)
223    #[serde(default)]
224    pub used_by_operation: Option<String>,
225    /// Version for optimistic locking
226    #[serde(default)]
227    pub version: u32,
228}
229
230impl MintQuote {
231    /// Create a new MintQuote
232    #[allow(clippy::too_many_arguments)]
233    pub fn new(
234        id: String,
235        mint_url: MintUrl,
236        payment_method: PaymentMethod,
237        amount: Option<Amount>,
238        unit: CurrencyUnit,
239        request: String,
240        expiry: u64,
241        secret_key: Option<SecretKey>,
242    ) -> Self {
243        Self {
244            id,
245            mint_url,
246            payment_method,
247            amount,
248            unit,
249            request,
250            state: MintQuoteState::Unpaid,
251            expiry,
252            secret_key,
253            amount_issued: Amount::ZERO,
254            amount_paid: Amount::ZERO,
255            used_by_operation: None,
256            version: 0,
257        }
258    }
259
260    /// Calculate the total amount including any fees
261    pub fn total_amount(&self) -> Amount {
262        self.amount_paid
263    }
264
265    /// Check if the quote has expired
266    pub fn is_expired(&self, current_time: u64) -> bool {
267        current_time > self.expiry
268    }
269
270    /// Amount that can be minted
271    pub fn amount_mintable(&self) -> Amount {
272        if self.payment_method == PaymentMethod::BOLT11 {
273            // BOLT11 is all-or-nothing: mint full amount when state is Paid
274            if self.state == MintQuoteState::Paid {
275                self.amount.unwrap_or(Amount::ZERO)
276            } else {
277                Amount::ZERO
278            }
279        } else {
280            // Other payment methods track incremental payments
281            self.amount_paid
282                .checked_sub(self.amount_issued)
283                .unwrap_or(Amount::ZERO)
284        }
285    }
286}
287
288/// Send Kind
289#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Default, Serialize, Deserialize)]
290pub enum SendKind {
291    #[default]
292    /// Allow online swap before send if wallet does not have exact amount
293    OnlineExact,
294    /// Prefer offline send if difference is less then tolerance
295    OnlineTolerance(Amount),
296    /// Wallet cannot do an online swap and selected proof must be exactly send amount
297    OfflineExact,
298    /// Wallet must remain offline but can over pay if below tolerance
299    OfflineTolerance(Amount),
300}
301
302impl SendKind {
303    /// Check if send kind is online
304    pub fn is_online(&self) -> bool {
305        matches!(self, Self::OnlineExact | Self::OnlineTolerance(_))
306    }
307
308    /// Check if send kind is offline
309    pub fn is_offline(&self) -> bool {
310        matches!(self, Self::OfflineExact | Self::OfflineTolerance(_))
311    }
312
313    /// Check if send kind is exact
314    pub fn is_exact(&self) -> bool {
315        matches!(self, Self::OnlineExact | Self::OfflineExact)
316    }
317
318    /// Check if send kind has tolerance
319    pub fn has_tolerance(&self) -> bool {
320        matches!(self, Self::OnlineTolerance(_) | Self::OfflineTolerance(_))
321    }
322}
323
324/// Wallet Transaction
325#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
326pub struct Transaction {
327    /// Mint Url
328    pub mint_url: MintUrl,
329    /// Transaction direction
330    pub direction: TransactionDirection,
331    /// Amount
332    pub amount: Amount,
333    /// Fee
334    pub fee: Amount,
335    /// Currency Unit
336    pub unit: CurrencyUnit,
337    /// Proof Ys
338    pub ys: Vec<PublicKey>,
339    /// Unix timestamp
340    pub timestamp: u64,
341    /// Memo
342    pub memo: Option<String>,
343    /// User-defined metadata
344    pub metadata: HashMap<String, String>,
345    /// Quote ID if this is a mint or melt transaction
346    pub quote_id: Option<String>,
347    /// Payment request (e.g., BOLT11 invoice, BOLT12 offer)
348    pub payment_request: Option<String>,
349    /// Payment proof (e.g., preimage for Lightning melt transactions)
350    pub payment_proof: Option<String>,
351    /// Payment method (e.g., Bolt11, Bolt12) for mint/melt transactions
352    #[serde(default)]
353    pub payment_method: Option<PaymentMethod>,
354    /// Saga ID if this transaction was part of a saga
355    #[serde(default)]
356    pub saga_id: Option<Uuid>,
357}
358
359impl Transaction {
360    /// Transaction ID
361    pub fn id(&self) -> TransactionId {
362        TransactionId::new(self.ys.clone())
363    }
364
365    /// Check if transaction matches conditions
366    pub fn matches_conditions(
367        &self,
368        mint_url: &Option<MintUrl>,
369        direction: &Option<TransactionDirection>,
370        unit: &Option<CurrencyUnit>,
371    ) -> bool {
372        if let Some(mint_url) = mint_url {
373            if &self.mint_url != mint_url {
374                return false;
375            }
376        }
377        if let Some(direction) = direction {
378            if &self.direction != direction {
379                return false;
380            }
381        }
382        if let Some(unit) = unit {
383            if &self.unit != unit {
384                return false;
385            }
386        }
387        true
388    }
389}
390
391impl PartialOrd for Transaction {
392    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
393        Some(self.cmp(other))
394    }
395}
396
397impl Ord for Transaction {
398    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
399        self.timestamp
400            .cmp(&other.timestamp)
401            .reverse()
402            .then_with(|| self.id().cmp(&other.id()))
403    }
404}
405
406/// Transaction Direction
407#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
408pub enum TransactionDirection {
409    /// Incoming transaction (i.e., receive or mint)
410    Incoming,
411    /// Outgoing transaction (i.e., send or melt)
412    Outgoing,
413}
414
415impl std::fmt::Display for TransactionDirection {
416    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
417        match self {
418            TransactionDirection::Incoming => write!(f, "Incoming"),
419            TransactionDirection::Outgoing => write!(f, "Outgoing"),
420        }
421    }
422}
423
424impl FromStr for TransactionDirection {
425    type Err = Error;
426
427    fn from_str(value: &str) -> Result<Self, Self::Err> {
428        match value {
429            "Incoming" => Ok(Self::Incoming),
430            "Outgoing" => Ok(Self::Outgoing),
431            _ => Err(Error::InvalidTransactionDirection),
432        }
433    }
434}
435
436/// Transaction ID
437#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
438#[serde(transparent)]
439pub struct TransactionId([u8; 32]);
440
441impl TransactionId {
442    /// Create new [`TransactionId`]
443    pub fn new(ys: Vec<PublicKey>) -> Self {
444        let mut ys = ys;
445        ys.sort();
446        let mut hasher = sha256::Hash::engine();
447        for y in ys {
448            hasher.input(&y.to_bytes());
449        }
450        let hash = sha256::Hash::from_engine(hasher);
451        Self(hash.to_byte_array())
452    }
453
454    /// From proofs
455    pub fn from_proofs(proofs: Proofs) -> Result<Self, nut00::Error> {
456        let ys = proofs
457            .iter()
458            .map(|proof| proof.y())
459            .collect::<Result<Vec<PublicKey>, nut00::Error>>()?;
460        Ok(Self::new(ys))
461    }
462
463    /// From bytes
464    pub fn from_bytes(bytes: [u8; 32]) -> Self {
465        Self(bytes)
466    }
467
468    /// From hex string
469    pub fn from_hex(value: &str) -> Result<Self, Error> {
470        let bytes = hex::decode(value)?;
471        if bytes.len() != 32 {
472            return Err(Error::InvalidTransactionId);
473        }
474        let mut array = [0u8; 32];
475        array.copy_from_slice(&bytes);
476        Ok(Self(array))
477    }
478
479    /// From slice
480    pub fn from_slice(slice: &[u8]) -> Result<Self, Error> {
481        if slice.len() != 32 {
482            return Err(Error::InvalidTransactionId);
483        }
484        let mut array = [0u8; 32];
485        array.copy_from_slice(slice);
486        Ok(Self(array))
487    }
488
489    /// Get inner value
490    pub fn as_bytes(&self) -> &[u8; 32] {
491        &self.0
492    }
493
494    /// Get inner value as slice
495    pub fn as_slice(&self) -> &[u8] {
496        &self.0
497    }
498}
499
500impl std::fmt::Display for TransactionId {
501    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
502        write!(f, "{}", hex::encode(self.0))
503    }
504}
505
506impl FromStr for TransactionId {
507    type Err = Error;
508
509    fn from_str(value: &str) -> Result<Self, Self::Err> {
510        Self::from_hex(value)
511    }
512}
513
514impl TryFrom<Proofs> for TransactionId {
515    type Error = nut00::Error;
516
517    fn try_from(proofs: Proofs) -> Result<Self, Self::Error> {
518        Self::from_proofs(proofs)
519    }
520}
521
522/// Wallet operation kind
523#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize)]
524#[serde(rename_all = "snake_case")]
525pub enum OperationKind {
526    /// Send operation
527    Send,
528    /// Receive operation
529    Receive,
530    /// Swap operation
531    Swap,
532    /// Mint operation
533    Mint,
534    /// Melt operation
535    Melt,
536}
537
538impl fmt::Display for OperationKind {
539    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
540        match self {
541            OperationKind::Send => write!(f, "send"),
542            OperationKind::Receive => write!(f, "receive"),
543            OperationKind::Swap => write!(f, "swap"),
544            OperationKind::Mint => write!(f, "mint"),
545            OperationKind::Melt => write!(f, "melt"),
546        }
547    }
548}
549
550impl FromStr for OperationKind {
551    type Err = Error;
552
553    fn from_str(s: &str) -> Result<Self, Self::Err> {
554        match s {
555            "send" => Ok(OperationKind::Send),
556            "receive" => Ok(OperationKind::Receive),
557            "swap" => Ok(OperationKind::Swap),
558            "mint" => Ok(OperationKind::Mint),
559            "melt" => Ok(OperationKind::Melt),
560            _ => Err(Error::InvalidOperationKind),
561        }
562    }
563}
564
565#[cfg(test)]
566mod tests {
567    use super::*;
568    use crate::nuts::Id;
569    use crate::secret::Secret;
570
571    #[test]
572    fn test_transaction_id_from_hex() {
573        let hex_str = "a1b2c3d4e5f60718293a0b1c2d3e4f506172839a0b1c2d3e4f506172839a0b1c";
574        let transaction_id = TransactionId::from_hex(hex_str).unwrap();
575        assert_eq!(transaction_id.to_string(), hex_str);
576    }
577
578    #[test]
579    fn test_transaction_id_from_hex_empty_string() {
580        let hex_str = "";
581        let res = TransactionId::from_hex(hex_str);
582        assert!(matches!(res, Err(Error::InvalidTransactionId)));
583    }
584
585    #[test]
586    fn test_transaction_id_from_hex_longer_string() {
587        let hex_str = "a1b2c3d4e5f60718293a0b1c2d3e4f506172839a0b1c2d3e4f506172839a0b1ca1b2";
588        let res = TransactionId::from_hex(hex_str);
589        assert!(matches!(res, Err(Error::InvalidTransactionId)));
590    }
591
592    #[test]
593    fn test_matches_conditions() {
594        let keyset_id = Id::from_str("00deadbeef123456").unwrap();
595        let proof = Proof::new(
596            Amount::from(64),
597            keyset_id,
598            Secret::new("test_secret"),
599            PublicKey::from_hex(
600                "02deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
601            )
602            .unwrap(),
603        );
604
605        let mint_url = MintUrl::from_str("https://example.com").unwrap();
606        let proof_info =
607            ProofInfo::new(proof, mint_url.clone(), State::Unspent, CurrencyUnit::Sat).unwrap();
608
609        // Test matching mint_url
610        assert!(proof_info.matches_conditions(&Some(mint_url.clone()), &None, &None, &None));
611        assert!(!proof_info.matches_conditions(
612            &Some(MintUrl::from_str("https://different.com").unwrap()),
613            &None,
614            &None,
615            &None
616        ));
617
618        // Test matching unit
619        assert!(proof_info.matches_conditions(&None, &Some(CurrencyUnit::Sat), &None, &None));
620        assert!(!proof_info.matches_conditions(&None, &Some(CurrencyUnit::Msat), &None, &None));
621
622        // Test matching state
623        assert!(proof_info.matches_conditions(&None, &None, &Some(vec![State::Unspent]), &None));
624        assert!(proof_info.matches_conditions(
625            &None,
626            &None,
627            &Some(vec![State::Unspent, State::Spent]),
628            &None
629        ));
630        assert!(!proof_info.matches_conditions(&None, &None, &Some(vec![State::Spent]), &None));
631
632        // Test with no conditions (should match)
633        assert!(proof_info.matches_conditions(&None, &None, &None, &None));
634
635        // Test with multiple conditions
636        assert!(proof_info.matches_conditions(
637            &Some(mint_url),
638            &Some(CurrencyUnit::Sat),
639            &Some(vec![State::Unspent]),
640            &None
641        ));
642    }
643
644    #[test]
645    fn test_matches_conditions_with_spending_conditions() {
646        // This test would need to be expanded with actual SpendingConditions
647        // implementation, but we can test the basic case where no spending
648        // conditions are present
649
650        let keyset_id = Id::from_str("00deadbeef123456").unwrap();
651        let proof = Proof::new(
652            Amount::from(64),
653            keyset_id,
654            Secret::new("test_secret"),
655            PublicKey::from_hex(
656                "02deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
657            )
658            .unwrap(),
659        );
660
661        let mint_url = MintUrl::from_str("https://example.com").unwrap();
662        let proof_info =
663            ProofInfo::new(proof, mint_url, State::Unspent, CurrencyUnit::Sat).unwrap();
664
665        // Test with empty spending conditions (should match when proof has none)
666        assert!(proof_info.matches_conditions(&None, &None, &None, &Some(vec![])));
667
668        // Test with non-empty spending conditions (should not match when proof has none)
669        let dummy_condition = SpendingConditions::P2PKConditions {
670            data: SecretKey::generate().public_key(),
671            conditions: None,
672        };
673        assert!(!proof_info.matches_conditions(&None, &None, &None, &Some(vec![dummy_condition])));
674    }
675}