Эх сурвалжийг харах

Add transactions to database (#686)

David Caseria 1 сар өмнө
parent
commit
b1dd321f0a

+ 2 - 2
crates/cdk-cli/src/sub_commands/create_request.rs

@@ -1,7 +1,7 @@
 use anyhow::Result;
 use cdk::nuts::nut18::TransportType;
 use cdk::nuts::{CurrencyUnit, PaymentRequest, PaymentRequestPayload, Token, Transport};
-use cdk::wallet::MultiMintWallet;
+use cdk::wallet::{MultiMintWallet, ReceiveOptions};
 use clap::Args;
 use nostr_sdk::nips::nip19::Nip19Profile;
 use nostr_sdk::prelude::*;
@@ -83,7 +83,7 @@ pub async fn create_request(
                 let token = Token::new(payload.mint, payload.proofs, payload.memo, payload.unit);
 
                 let amount = multi_mint_wallet
-                    .receive(&token.to_string(), &[], &[])
+                    .receive(&token.to_string(), ReceiveOptions::default())
                     .await?;
 
                 println!("Received {}", amount);

+ 9 - 1
crates/cdk-cli/src/sub_commands/receive.rs

@@ -7,6 +7,7 @@ use cdk::nuts::{SecretKey, Token};
 use cdk::util::unix_time;
 use cdk::wallet::multi_mint_wallet::MultiMintWallet;
 use cdk::wallet::types::WalletKey;
+use cdk::wallet::ReceiveOptions;
 use cdk::Amount;
 use clap::Args;
 use nostr_sdk::nips::nip04;
@@ -150,7 +151,14 @@ async fn receive_token(
     }
 
     let amount = multi_mint_wallet
-        .receive(token_str, signing_keys, preimage)
+        .receive(
+            token_str,
+            ReceiveOptions {
+                p2pk_signing_keys: signing_keys.to_vec(),
+                preimages: preimage.to_vec(),
+                ..Default::default()
+            },
+        )
         .await?;
     Ok(amount)
 }

+ 20 - 2
crates/cdk-common/src/database/wallet.rs

@@ -11,8 +11,9 @@ use crate::mint_url::MintUrl;
 use crate::nuts::{
     CurrencyUnit, Id, KeySetInfo, Keys, MintInfo, PublicKey, SpendingConditions, State,
 };
-use crate::wallet;
-use crate::wallet::MintQuote as WalletMintQuote;
+use crate::wallet::{
+    self, MintQuote as WalletMintQuote, Transaction, TransactionDirection, TransactionId,
+};
 
 /// Wallet Database trait
 #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
@@ -99,4 +100,21 @@ pub trait Database: Debug {
     async fn increment_keyset_counter(&self, keyset_id: &Id, count: u32) -> Result<(), Self::Err>;
     /// Get current Keyset counter
     async fn get_keyset_counter(&self, keyset_id: &Id) -> Result<Option<u32>, Self::Err>;
+
+    /// Add transaction to storage
+    async fn add_transaction(&self, transaction: Transaction) -> Result<(), Self::Err>;
+    /// Get transaction from storage
+    async fn get_transaction(
+        &self,
+        transaction_id: TransactionId,
+    ) -> Result<Option<Transaction>, Self::Err>;
+    /// List transactions from storage
+    async fn list_transactions(
+        &self,
+        mint_url: Option<MintUrl>,
+        direction: Option<TransactionDirection>,
+        unit: Option<CurrencyUnit>,
+    ) -> Result<Vec<Transaction>, Self::Err>;
+    /// Remove transaction from storage
+    async fn remove_transaction(&self, transaction_id: TransactionId) -> Result<(), Self::Err>;
 }

+ 6 - 0
crates/cdk-common/src/error.rs

@@ -217,6 +217,12 @@ pub enum Error {
     /// Invoice Description not supported
     #[error("Invoice Description not supported")]
     InvoiceDescriptionUnsupported,
+    /// Invalid transaction direction
+    #[error("Invalid transaction direction")]
+    InvalidTransactionDirection,
+    /// Invalid transaction id
+    #[error("Invalid transaction id")]
+    InvalidTransactionId,
     /// Custom Error
     #[error("`{0}`")]
     Custom(String),

+ 186 - 1
crates/cdk-common/src/wallet.rs

@@ -1,12 +1,17 @@
 //! Wallet Types
 
+use std::collections::HashMap;
 use std::fmt;
+use std::str::FromStr;
 
+use bitcoin::hashes::{sha256, Hash, HashEngine};
+use cashu::util::hex;
+use cashu::{nut00, Proofs, PublicKey};
 use serde::{Deserialize, Serialize};
 
 use crate::mint_url::MintUrl;
 use crate::nuts::{CurrencyUnit, MeltQuoteState, MintQuoteState, SecretKey};
-use crate::Amount;
+use crate::{Amount, Error};
 
 /// Wallet Key
 #[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
@@ -107,3 +112,183 @@ impl SendKind {
         matches!(self, Self::OnlineTolerance(_) | Self::OfflineTolerance(_))
     }
 }
+
+/// Wallet Transaction
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct Transaction {
+    /// Mint Url
+    pub mint_url: MintUrl,
+    /// Transaction direction
+    pub direction: TransactionDirection,
+    /// Amount
+    pub amount: Amount,
+    /// Fee
+    pub fee: Amount,
+    /// Currency Unit
+    pub unit: CurrencyUnit,
+    /// Proof Ys
+    pub ys: Vec<PublicKey>,
+    /// Unix timestamp
+    pub timestamp: u64,
+    /// Memo
+    pub memo: Option<String>,
+    /// User-defined metadata
+    pub metadata: HashMap<String, String>,
+}
+
+impl Transaction {
+    /// Transaction ID
+    pub fn id(&self) -> TransactionId {
+        TransactionId::new(self.ys.clone())
+    }
+
+    /// Check if transaction matches conditions
+    pub fn matches_conditions(
+        &self,
+        mint_url: &Option<MintUrl>,
+        direction: &Option<TransactionDirection>,
+        unit: &Option<CurrencyUnit>,
+    ) -> bool {
+        if let Some(mint_url) = mint_url {
+            if &self.mint_url != mint_url {
+                return false;
+            }
+        }
+        if let Some(direction) = direction {
+            if &self.direction != direction {
+                return false;
+            }
+        }
+        if let Some(unit) = unit {
+            if &self.unit != unit {
+                return false;
+            }
+        }
+        true
+    }
+}
+
+impl PartialOrd for Transaction {
+    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
+        Some(self.cmp(other))
+    }
+}
+
+impl Ord for Transaction {
+    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
+        self.timestamp.cmp(&other.timestamp).reverse()
+    }
+}
+
+/// Transaction Direction
+#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum TransactionDirection {
+    /// Incoming transaction (i.e., receive or mint)
+    Incoming,
+    /// Outgoing transaction (i.e., send or melt)
+    Outgoing,
+}
+
+impl std::fmt::Display for TransactionDirection {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            TransactionDirection::Incoming => write!(f, "Incoming"),
+            TransactionDirection::Outgoing => write!(f, "Outgoing"),
+        }
+    }
+}
+
+impl FromStr for TransactionDirection {
+    type Err = Error;
+
+    fn from_str(value: &str) -> Result<Self, Self::Err> {
+        match value {
+            "Incoming" => Ok(Self::Incoming),
+            "Outgoing" => Ok(Self::Outgoing),
+            _ => Err(Error::InvalidTransactionDirection),
+        }
+    }
+}
+
+/// Transaction ID
+#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
+#[serde(transparent)]
+pub struct TransactionId([u8; 32]);
+
+impl TransactionId {
+    /// Create new [`TransactionId`]
+    pub fn new(ys: Vec<PublicKey>) -> Self {
+        let mut ys = ys;
+        ys.sort();
+        let mut hasher = sha256::Hash::engine();
+        for y in ys {
+            hasher.input(&y.to_bytes());
+        }
+        let hash = sha256::Hash::from_engine(hasher);
+        Self(hash.to_byte_array())
+    }
+
+    /// From proofs
+    pub fn from_proofs(proofs: Proofs) -> Result<Self, nut00::Error> {
+        let ys = proofs
+            .iter()
+            .map(|proof| proof.y())
+            .collect::<Result<Vec<PublicKey>, nut00::Error>>()?;
+        Ok(Self::new(ys))
+    }
+
+    /// From bytes
+    pub fn from_bytes(bytes: [u8; 32]) -> Self {
+        Self(bytes)
+    }
+
+    /// From hex string
+    pub fn from_hex(value: &str) -> Result<Self, Error> {
+        let bytes = hex::decode(value)?;
+        let mut array = [0u8; 32];
+        array.copy_from_slice(&bytes);
+        Ok(Self(array))
+    }
+
+    /// From slice
+    pub fn from_slice(slice: &[u8]) -> Result<Self, Error> {
+        if slice.len() != 32 {
+            return Err(Error::InvalidTransactionId);
+        }
+        let mut array = [0u8; 32];
+        array.copy_from_slice(slice);
+        Ok(Self(array))
+    }
+
+    /// Get inner value
+    pub fn as_bytes(&self) -> &[u8; 32] {
+        &self.0
+    }
+
+    /// Get inner value as slice
+    pub fn as_slice(&self) -> &[u8] {
+        &self.0
+    }
+}
+
+impl std::fmt::Display for TransactionId {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "{}", hex::encode(self.0))
+    }
+}
+
+impl FromStr for TransactionId {
+    type Err = Error;
+
+    fn from_str(value: &str) -> Result<Self, Self::Err> {
+        Self::from_hex(value)
+    }
+}
+
+impl TryFrom<Proofs> for TransactionId {
+    type Error = nut00::Error;
+
+    fn try_from(proofs: Proofs) -> Result<Self, Self::Error> {
+        Self::from_proofs(proofs)
+    }
+}

+ 68 - 0
crates/cdk-integration-tests/tests/fake_wallet.rs

@@ -2,12 +2,14 @@ use std::sync::Arc;
 
 use anyhow::{bail, Result};
 use bip39::Mnemonic;
+use cashu::Amount;
 use cdk::amount::SplitTarget;
 use cdk::nuts::nut00::ProofsMethods;
 use cdk::nuts::{
     CurrencyUnit, MeltBolt11Request, MeltQuoteState, MintBolt11Request, PreMintSecrets, Proofs,
     SecretKey, State, SwapRequest,
 };
+use cdk::wallet::types::TransactionDirection;
 use cdk::wallet::{HttpClient, MintConnector, Wallet};
 use cdk_fake_wallet::{create_fake_invoice, FakeInvoiceDescription};
 use cdk_integration_tests::{attempt_to_swap_pending, wait_for_mint_to_be_paid};
@@ -322,6 +324,72 @@ async fn test_fake_melt_payment_err_paid() -> Result<()> {
     Ok(())
 }
 
+/// Tests that change outputs in a melt quote are correctly handled
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_fake_melt_change_in_quote() -> Result<()> {
+    let wallet = Wallet::new(
+        MINT_URL,
+        CurrencyUnit::Sat,
+        Arc::new(memory::empty().await?),
+        &Mnemonic::generate(12)?.to_seed_normalized(""),
+        None,
+    )?;
+
+    let mint_quote = wallet.mint_quote(100.into(), None).await?;
+
+    wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60).await?;
+
+    let _mint_amount = wallet
+        .mint(&mint_quote.id, SplitTarget::default(), None)
+        .await?;
+
+    let transaction = wallet
+        .list_transactions(Some(TransactionDirection::Incoming))
+        .await?
+        .pop()
+        .expect("No transaction found");
+    assert_eq!(wallet.mint_url, transaction.mint_url);
+    assert_eq!(TransactionDirection::Incoming, transaction.direction);
+    assert_eq!(Amount::from(100), transaction.amount);
+    assert_eq!(Amount::from(0), transaction.fee);
+    assert_eq!(CurrencyUnit::Sat, transaction.unit);
+
+    let fake_description = FakeInvoiceDescription::default();
+
+    let invoice = create_fake_invoice(9000, serde_json::to_string(&fake_description).unwrap());
+
+    let proofs = wallet.get_unspent_proofs().await?;
+
+    let melt_quote = wallet.melt_quote(invoice.to_string(), None).await?;
+
+    let keyset = wallet.get_active_mint_keyset().await?;
+
+    let premint_secrets = PreMintSecrets::random(keyset.id, 100.into(), &SplitTarget::default())?;
+
+    let client = HttpClient::new(MINT_URL.parse()?, None);
+
+    let melt_request = MeltBolt11Request::new(
+        melt_quote.id.clone(),
+        proofs.clone(),
+        Some(premint_secrets.blinded_messages()),
+    );
+
+    let melt_response = client.post_melt(melt_request).await?;
+
+    assert!(melt_response.change.is_some());
+
+    let check = wallet.melt_quote_status(&melt_quote.id).await?;
+    let mut melt_change = melt_response.change.unwrap();
+    melt_change.sort_by(|a, b| a.amount.cmp(&b.amount));
+
+    let mut check = check.change.unwrap();
+    check.sort_by(|a, b| a.amount.cmp(&b.amount));
+
+    assert_eq!(melt_change, check);
+
+    Ok(())
+}
+
 /// Tests that the correct database type is used based on environment variables
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
 async fn test_database_type() -> Result<()> {

+ 51 - 4
crates/cdk-integration-tests/tests/integration_tests_pure.rs

@@ -9,17 +9,18 @@ use std::collections::{HashMap, HashSet};
 use std::hash::RandomState;
 use std::str::FromStr;
 
+use cashu::amount::SplitTarget;
 use cashu::dhke::construct_proofs;
 use cashu::mint_url::MintUrl;
 use cashu::{
     CurrencyUnit, Id, MeltBolt11Request, NotificationPayload, PreMintSecrets, ProofState,
     SecretKey, SpendingConditions, State, SwapRequest,
 };
-use cdk::amount::SplitTarget;
 use cdk::mint::Mint;
 use cdk::nuts::nut00::ProofsMethods;
 use cdk::subscription::{IndexableParams, Params};
-use cdk::wallet::SendOptions;
+use cdk::wallet::types::{TransactionDirection, TransactionId};
+use cdk::wallet::{ReceiveOptions, SendMemo, SendOptions};
 use cdk::Amount;
 use cdk_fake_wallet::create_fake_invoice;
 use cdk_integration_tests::init_pure_tests::*;
@@ -68,7 +69,10 @@ async fn test_swap_to_send() {
         )
     );
     let token = wallet_alice
-        .send(prepared_send, None)
+        .send(
+            prepared_send,
+            Some(SendMemo::for_token("test_swapt_to_send")),
+        )
         .await
         .expect("Failed to send token");
     assert_eq!(
@@ -97,12 +101,30 @@ async fn test_swap_to_send() {
         )
     );
 
+    let transaction_id = TransactionId::from_proofs(token.proofs()).expect("Failed to get tx id");
+
+    let transaction = wallet_alice
+        .get_transaction(transaction_id)
+        .await
+        .expect("Failed to get transaction")
+        .expect("Transaction not found");
+    assert_eq!(wallet_alice.mint_url, transaction.mint_url);
+    assert_eq!(TransactionDirection::Outgoing, transaction.direction);
+    assert_eq!(Amount::from(40), transaction.amount);
+    assert_eq!(Amount::from(0), transaction.fee);
+    assert_eq!(CurrencyUnit::Sat, transaction.unit);
+    assert_eq!(token.proofs().ys().unwrap(), transaction.ys);
+
     // Alice sends cashu, Carol receives
     let wallet_carol = create_test_wallet_for_mint(mint_bob.clone())
         .await
         .expect("Failed to create Carol's wallet");
     let received_amount = wallet_carol
-        .receive_proofs(token.proofs(), SplitTarget::None, &[], &[])
+        .receive_proofs(
+            token.proofs(),
+            ReceiveOptions::default(),
+            token.memo().clone(),
+        )
         .await
         .expect("Failed to receive proofs");
 
@@ -114,6 +136,19 @@ async fn test_swap_to_send() {
             .await
             .expect("Failed to get Carol's balance")
     );
+
+    let transaction = wallet_carol
+        .get_transaction(transaction_id)
+        .await
+        .expect("Failed to get transaction")
+        .expect("Transaction not found");
+    assert_eq!(wallet_carol.mint_url, transaction.mint_url);
+    assert_eq!(TransactionDirection::Incoming, transaction.direction);
+    assert_eq!(Amount::from(40), transaction.amount);
+    assert_eq!(Amount::from(0), transaction.fee);
+    assert_eq!(CurrencyUnit::Sat, transaction.unit);
+    assert_eq!(token.proofs().ys().unwrap(), transaction.ys);
+    assert_eq!(token.memo().clone(), transaction.memo);
 }
 
 /// Tests the NUT-06 functionality (mint discovery):
@@ -141,6 +176,18 @@ async fn test_mint_nut06() {
         .expect("Failed to get balance");
     assert_eq!(Amount::from(64), balance_alice);
 
+    let transaction = wallet_alice
+        .list_transactions(None)
+        .await
+        .expect("Failed to list transactions")
+        .pop()
+        .expect("No transactions found");
+    assert_eq!(wallet_alice.mint_url, transaction.mint_url);
+    assert_eq!(TransactionDirection::Incoming, transaction.direction);
+    assert_eq!(Amount::from(64), transaction.amount);
+    assert_eq!(Amount::from(0), transaction.fee);
+    assert_eq!(CurrencyUnit::Sat, transaction.unit);
+
     let initial_mint_url = wallet_alice.mint_url.clone();
     let mint_info_before = wallet_alice
         .get_mint_info()

+ 95 - 1
crates/cdk-redb/src/wallet/mod.rs

@@ -11,7 +11,7 @@ use cdk_common::common::ProofInfo;
 use cdk_common::database::WalletDatabase;
 use cdk_common::mint_url::MintUrl;
 use cdk_common::util::unix_time;
-use cdk_common::wallet::{self, MintQuote};
+use cdk_common::wallet::{self, MintQuote, Transaction, TransactionDirection, TransactionId};
 use cdk_common::{
     database, CurrencyUnit, Id, KeySetInfo, Keys, MintInfo, PublicKey, SpendingConditions, State,
 };
@@ -40,6 +40,8 @@ const MINT_KEYS_TABLE: TableDefinition<&str, &str> = TableDefinition::new("mint_
 const PROOFS_TABLE: TableDefinition<&[u8], &str> = TableDefinition::new("proofs");
 const CONFIG_TABLE: TableDefinition<&str, &str> = TableDefinition::new("config");
 const KEYSET_COUNTER: TableDefinition<&str, u32> = TableDefinition::new("keyset_counter");
+// <Transaction_id, Transaction>
+const TRANSACTIONS_TABLE: TableDefinition<&[u8], &str> = TableDefinition::new("transactions");
 
 const DATABASE_VERSION: u32 = 2;
 
@@ -132,6 +134,7 @@ impl WalletRedbDatabase {
                         let _ = write_txn.open_table(MINT_KEYS_TABLE)?;
                         let _ = write_txn.open_table(PROOFS_TABLE)?;
                         let _ = write_txn.open_table(KEYSET_COUNTER)?;
+                        let _ = write_txn.open_table(TRANSACTIONS_TABLE)?;
                         table.insert("db_version", DATABASE_VERSION.to_string().as_str())?;
                     }
 
@@ -685,4 +688,95 @@ impl WalletDatabase for WalletRedbDatabase {
 
         Ok(counter.map(|c| c.value()))
     }
+
+    #[instrument(skip(self))]
+    async fn add_transaction(&self, transaction: Transaction) -> Result<(), Self::Err> {
+        let write_txn = self.db.begin_write().map_err(Error::from)?;
+
+        {
+            let mut table = write_txn
+                .open_table(TRANSACTIONS_TABLE)
+                .map_err(Error::from)?;
+            table
+                .insert(
+                    transaction.id().as_slice(),
+                    serde_json::to_string(&transaction)
+                        .map_err(Error::from)?
+                        .as_str(),
+                )
+                .map_err(Error::from)?;
+        }
+
+        write_txn.commit().map_err(Error::from)?;
+
+        Ok(())
+    }
+
+    #[instrument(skip(self))]
+    async fn get_transaction(
+        &self,
+        transaction_id: TransactionId,
+    ) -> Result<Option<Transaction>, Self::Err> {
+        let read_txn = self.db.begin_read().map_err(Error::from)?;
+        let table = read_txn
+            .open_table(TRANSACTIONS_TABLE)
+            .map_err(Error::from)?;
+
+        if let Some(transaction) = table.get(transaction_id.as_slice()).map_err(Error::from)? {
+            return Ok(serde_json::from_str(transaction.value()).map_err(Error::from)?);
+        }
+
+        Ok(None)
+    }
+
+    #[instrument(skip(self))]
+    async fn list_transactions(
+        &self,
+        mint_url: Option<MintUrl>,
+        direction: Option<TransactionDirection>,
+        unit: Option<CurrencyUnit>,
+    ) -> Result<Vec<Transaction>, Self::Err> {
+        let read_txn = self.db.begin_read().map_err(Error::from)?;
+
+        let table = read_txn
+            .open_table(TRANSACTIONS_TABLE)
+            .map_err(Error::from)?;
+
+        let transactions: Vec<Transaction> = table
+            .iter()
+            .map_err(Error::from)?
+            .flatten()
+            .filter_map(|(_k, v)| {
+                let mut transaction = None;
+
+                if let Ok(tx) = serde_json::from_str::<Transaction>(v.value()) {
+                    if tx.matches_conditions(&mint_url, &direction, &unit) {
+                        transaction = Some(tx)
+                    }
+                }
+
+                transaction
+            })
+            .collect();
+
+        Ok(transactions)
+    }
+
+    #[instrument(skip(self))]
+    async fn remove_transaction(&self, transaction_id: TransactionId) -> Result<(), Self::Err> {
+        let write_txn = self.db.begin_write().map_err(Error::from)?;
+
+        {
+            let mut table = write_txn
+                .open_table(TRANSACTIONS_TABLE)
+                .map_err(Error::from)?;
+            table
+                .remove(transaction_id.as_slice())
+                .map_err(Error::from)?;
+        }
+
+        write_txn.commit().map_err(Error::from)?;
+
+        Ok(())
+    }
 }

+ 3 - 0
crates/cdk-sqlite/src/wallet/error.rs

@@ -11,6 +11,9 @@ pub enum Error {
     /// Serde Error
     #[error(transparent)]
     Serde(#[from] serde_json::Error),
+    /// CDK Error
+    #[error(transparent)]
+    CDK(#[from] cdk_common::Error),
     /// NUT00 Error
     #[error(transparent)]
     CDKNUT00(#[from] cdk_common::nuts::nut00::Error),

+ 18 - 0
crates/cdk-sqlite/src/wallet/migrations/20250401120000_add_transactions_table.sql

@@ -0,0 +1,18 @@
+-- Migration to add transactions table
+CREATE TABLE IF NOT EXISTS transactions (
+    id BLOB PRIMARY KEY,
+    mint_url TEXT NOT NULL,
+    direction TEXT CHECK (direction IN ('Incoming', 'Outgoing')) NOT NULL,
+    amount INTEGER NOT NULL,
+    fee INTEGER NOT NULL,
+    unit TEXT NOT NULL,
+    ys BLOB NOT NULL,
+    timestamp INTEGER NOT NULL,
+    memo TEXT,
+    metadata TEXT
+);
+
+CREATE INDEX IF NOT EXISTS mint_url_index ON transactions(mint_url);
+CREATE INDEX IF NOT EXISTS direction_index ON transactions(direction);
+CREATE INDEX IF NOT EXISTS unit_index ON transactions(unit);
+CREATE INDEX IF NOT EXISTS timestamp_index ON transactions(timestamp);

+ 166 - 3
crates/cdk-sqlite/src/wallet/mod.rs

@@ -10,10 +10,10 @@ use cdk_common::database::WalletDatabase;
 use cdk_common::mint_url::MintUrl;
 use cdk_common::nuts::{MeltQuoteState, MintQuoteState};
 use cdk_common::secret::Secret;
-use cdk_common::wallet::{self, MintQuote};
+use cdk_common::wallet::{self, MintQuote, Transaction, TransactionDirection, TransactionId};
 use cdk_common::{
-    database, Amount, CurrencyUnit, Id, KeySetInfo, Keys, MintInfo, Proof, ProofDleq, PublicKey,
-    SecretKey, SpendingConditions, State,
+    database, nut01, Amount, CurrencyUnit, Id, KeySetInfo, Keys, MintInfo, Proof, ProofDleq,
+    PublicKey, SecretKey, SpendingConditions, State,
 };
 use error::Error;
 use sqlx::sqlite::SqliteRow;
@@ -778,6 +778,138 @@ WHERE id=?;
 
         Ok(count)
     }
+
+    #[instrument(skip(self))]
+    async fn add_transaction(&self, transaction: Transaction) -> Result<(), Self::Err> {
+        let mint_url = transaction.mint_url.to_string();
+        let direction = transaction.direction.to_string();
+        let unit = transaction.unit.to_string();
+        let amount = u64::from(transaction.amount) as i64;
+        let fee = u64::from(transaction.fee) as i64;
+        let ys = transaction
+            .ys
+            .iter()
+            .flat_map(|y| y.to_bytes().to_vec())
+            .collect::<Vec<_>>();
+
+        sqlx::query(
+            r#"
+INSERT INTO transactions
+(id, mint_url, direction, unit, amount, fee, ys, timestamp, memo, metadata)
+VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ON CONFLICT(id) DO UPDATE SET
+    mint_url = excluded.mint_url,
+    direction = excluded.direction,
+    unit = excluded.unit,
+    amount = excluded.amount,
+    fee = excluded.fee,
+    ys = excluded.ys,
+    timestamp = excluded.timestamp,
+    memo = excluded.memo,
+    metadata = excluded.metadata
+;
+        "#,
+        )
+        .bind(transaction.id().as_slice())
+        .bind(mint_url)
+        .bind(direction)
+        .bind(unit)
+        .bind(amount)
+        .bind(fee)
+        .bind(ys)
+        .bind(transaction.timestamp as i64)
+        .bind(transaction.memo)
+        .bind(serde_json::to_string(&transaction.metadata).map_err(Error::from)?)
+        .execute(&self.pool)
+        .await
+        .map_err(Error::from)?;
+
+        Ok(())
+    }
+
+    #[instrument(skip(self))]
+    async fn get_transaction(
+        &self,
+        transaction_id: TransactionId,
+    ) -> Result<Option<Transaction>, Self::Err> {
+        let rec = sqlx::query(
+            r#"
+SELECT *
+FROM transactions
+WHERE id=?;
+        "#,
+        )
+        .bind(transaction_id.as_slice())
+        .fetch_one(&self.pool)
+        .await;
+
+        let rec = match rec {
+            Ok(rec) => rec,
+            Err(err) => match err {
+                sqlx::Error::RowNotFound => return Ok(None),
+                _ => return Err(Error::SQLX(err).into()),
+            },
+        };
+
+        let transaction = sqlite_row_to_transaction(&rec)?;
+
+        Ok(Some(transaction))
+    }
+
+    #[instrument(skip(self))]
+    async fn list_transactions(
+        &self,
+        mint_url: Option<MintUrl>,
+        direction: Option<TransactionDirection>,
+        unit: Option<CurrencyUnit>,
+    ) -> Result<Vec<Transaction>, Self::Err> {
+        let recs = sqlx::query(
+            r#"
+SELECT *
+FROM transactions;
+        "#,
+        )
+        .fetch_all(&self.pool)
+        .await;
+
+        let recs = match recs {
+            Ok(rec) => rec,
+            Err(err) => match err {
+                sqlx::Error::RowNotFound => return Ok(vec![]),
+                _ => return Err(Error::SQLX(err).into()),
+            },
+        };
+
+        let transactions = recs
+            .iter()
+            .filter_map(|p| {
+                let transaction = sqlite_row_to_transaction(p).ok()?;
+                if transaction.matches_conditions(&mint_url, &direction, &unit) {
+                    Some(transaction)
+                } else {
+                    None
+                }
+            })
+            .collect();
+
+        Ok(transactions)
+    }
+
+    #[instrument(skip(self))]
+    async fn remove_transaction(&self, transaction_id: TransactionId) -> Result<(), Self::Err> {
+        sqlx::query(
+            r#"
+DELETE FROM transactions
+WHERE id=?
+        "#,
+        )
+        .bind(transaction_id.as_slice())
+        .execute(&self.pool)
+        .await
+        .map_err(Error::from)?;
+
+        Ok(())
+    }
 }
 
 fn sqlite_row_to_mint_info(row: &SqliteRow) -> Result<MintInfo, Error> {
@@ -926,6 +1058,37 @@ fn sqlite_row_to_proof_info(row: &SqliteRow) -> Result<ProofInfo, Error> {
     })
 }
 
+fn sqlite_row_to_transaction(row: &SqliteRow) -> Result<Transaction, Error> {
+    let mint_url: String = row.try_get("mint_url").map_err(Error::from)?;
+    let direction: String = row.try_get("direction").map_err(Error::from)?;
+    let unit: String = row.try_get("unit").map_err(Error::from)?;
+    let amount: i64 = row.try_get("amount").map_err(Error::from)?;
+    let fee: i64 = row.try_get("fee").map_err(Error::from)?;
+    let ys: Vec<u8> = row.try_get("ys").map_err(Error::from)?;
+    let timestamp: i64 = row.try_get("timestamp").map_err(Error::from)?;
+    let memo: Option<String> = row.try_get("memo").map_err(Error::from)?;
+    let row_metadata: Option<String> = row.try_get("metadata").map_err(Error::from)?;
+
+    let metadata: HashMap<String, String> = row_metadata
+        .and_then(|m| serde_json::from_str(&m).ok())
+        .unwrap_or_default();
+
+    let ys: Result<Vec<PublicKey>, nut01::Error> =
+        ys.chunks(33).map(PublicKey::from_slice).collect();
+
+    Ok(Transaction {
+        mint_url: MintUrl::from_str(&mint_url)?,
+        direction: TransactionDirection::from_str(&direction)?,
+        unit: CurrencyUnit::from_str(&unit)?,
+        amount: Amount::from(amount as u64),
+        fee: Amount::from(fee as u64),
+        ys: ys?,
+        timestamp: timestamp as u64,
+        memo,
+        metadata,
+    })
+}
+
 #[cfg(test)]
 mod tests {
     use cdk_common::database::WalletDatabase;

+ 8 - 2
crates/cdk/examples/p2pk.rs

@@ -3,7 +3,7 @@ use std::sync::Arc;
 use cdk::amount::SplitTarget;
 use cdk::error::Error;
 use cdk::nuts::{CurrencyUnit, MintQuoteState, NotificationPayload, SecretKey, SpendingConditions};
-use cdk::wallet::{SendOptions, Wallet, WalletSubscription};
+use cdk::wallet::{ReceiveOptions, SendOptions, Wallet, WalletSubscription};
 use cdk::Amount;
 use cdk_sqlite::wallet::memory;
 use rand::random;
@@ -94,7 +94,13 @@ async fn main() -> Result<(), Error> {
 
     // Receive the token using the secret key
     let amount = wallet
-        .receive(&token.to_string(), SplitTarget::default(), &[secret], &[])
+        .receive(
+            &token.to_string(),
+            ReceiveOptions {
+                p2pk_signing_keys: vec![secret],
+                ..Default::default()
+            },
+        )
         .await?;
 
     println!("Redeemed locked token worth: {}", u64::from(amount));

+ 17 - 0
crates/cdk/src/wallet/melt.rs

@@ -1,5 +1,7 @@
+use std::collections::HashMap;
 use std::str::FromStr;
 
+use cdk_common::wallet::{Transaction, TransactionDirection};
 use lightning_invoice::Bolt11Invoice;
 use tracing::instrument;
 
@@ -243,6 +245,21 @@ impl Wallet {
             .update_proofs(change_proof_infos, deleted_ys)
             .await?;
 
+        // Add transaction to store
+        self.localstore
+            .add_transaction(Transaction {
+                mint_url: self.mint_url.clone(),
+                direction: TransactionDirection::Outgoing,
+                amount: melted.amount,
+                fee: melted.fee_paid,
+                unit: self.unit.clone(),
+                ys: proofs.ys()?,
+                timestamp: unix_time(),
+                memo: None,
+                metadata: HashMap::new(),
+            })
+            .await?;
+
         Ok(melted)
     }
 

+ 18 - 0
crates/cdk/src/wallet/mint.rs

@@ -1,4 +1,7 @@
+use std::collections::HashMap;
+
 use cdk_common::ensure_cdk;
+use cdk_common::wallet::{Transaction, TransactionDirection};
 use tracing::instrument;
 
 use super::MintQuote;
@@ -286,6 +289,21 @@ impl Wallet {
         // Add new proofs to store
         self.localstore.update_proofs(proof_infos, vec![]).await?;
 
+        // Add transaction to store
+        self.localstore
+            .add_transaction(Transaction {
+                mint_url: self.mint_url.clone(),
+                direction: TransactionDirection::Incoming,
+                amount: proofs.total_amount()?,
+                fee: Amount::ZERO,
+                unit: self.unit.clone(),
+                ys: proofs.ys()?,
+                timestamp: unix_time,
+                memo: None,
+                metadata: HashMap::new(),
+            })
+            .await?;
+
         Ok(proofs)
     }
 }

+ 2 - 0
crates/cdk/src/wallet/mod.rs

@@ -44,6 +44,7 @@ mod receive;
 mod send;
 pub mod subscription;
 mod swap;
+mod transactions;
 pub mod util;
 
 #[cfg(feature = "auth")]
@@ -54,6 +55,7 @@ pub use cdk_common::wallet as types;
 pub use mint_connector::AuthHttpClient;
 pub use mint_connector::{HttpClient, MintConnector};
 pub use multi_mint_wallet::MultiMintWallet;
+pub use receive::ReceiveOptions;
 pub use send::{PreparedSend, SendMemo, SendOptions};
 pub use types::{MeltQuote, MintQuote, SendKind};
 

+ 23 - 5
crates/cdk/src/wallet/multi_mint_wallet.rs

@@ -10,15 +10,16 @@ use std::sync::Arc;
 use anyhow::Result;
 use cdk_common::database;
 use cdk_common::database::WalletDatabase;
-use cdk_common::wallet::WalletKey;
+use cdk_common::wallet::{Transaction, TransactionDirection, WalletKey};
 use tokio::sync::Mutex;
 use tracing::instrument;
 
+use super::receive::ReceiveOptions;
 use super::send::{PreparedSend, SendMemo, SendOptions};
 use super::Error;
 use crate::amount::SplitTarget;
 use crate::mint_url::MintUrl;
-use crate::nuts::{CurrencyUnit, MeltOptions, Proof, Proofs, SecretKey, SpendingConditions, Token};
+use crate::nuts::{CurrencyUnit, MeltOptions, Proof, Proofs, SpendingConditions, Token};
 use crate::types::Melted;
 use crate::wallet::types::MintQuote;
 use crate::{ensure_cdk, Amount, Wallet};
@@ -142,6 +143,24 @@ impl MultiMintWallet {
         Ok(mint_proofs)
     }
 
+    /// List transactions
+    #[instrument(skip(self))]
+    pub async fn list_transactions(
+        &self,
+        direction: Option<TransactionDirection>,
+    ) -> Result<Vec<Transaction>, Error> {
+        let mut transactions = Vec::new();
+
+        for (_, wallet) in self.wallets.lock().await.iter() {
+            let wallet_transactions = wallet.list_transactions(direction).await?;
+            transactions.extend(wallet_transactions);
+        }
+
+        transactions.sort();
+
+        Ok(transactions)
+    }
+
     /// Prepare to send
     #[instrument(skip(self))]
     pub async fn prepare_send(
@@ -246,8 +265,7 @@ impl MultiMintWallet {
     pub async fn receive(
         &self,
         encoded_token: &str,
-        p2pk_signing_keys: &[SecretKey],
-        preimages: &[String],
+        opts: ReceiveOptions,
     ) -> Result<Amount, Error> {
         let token_data = Token::from_str(encoded_token)?;
         let unit = token_data.unit().unwrap_or_default();
@@ -273,7 +291,7 @@ impl MultiMintWallet {
             .ok_or(Error::UnknownWallet(wallet_key.clone()))?;
 
         match wallet
-            .receive_proofs(proofs, SplitTarget::default(), p2pk_signing_keys, preimages)
+            .receive_proofs(proofs, opts, token_data.memo().clone())
             .await
         {
             Ok(amount) => {

+ 10 - 0
crates/cdk/src/wallet/proofs.rs

@@ -1,5 +1,6 @@
 use std::collections::{HashMap, HashSet};
 
+use cdk_common::wallet::TransactionId;
 use cdk_common::Id;
 use tracing::instrument;
 
@@ -75,6 +76,8 @@ impl Wallet {
     pub async fn reclaim_unspent(&self, proofs: Proofs) -> Result<(), Error> {
         let proof_ys = proofs.ys()?;
 
+        let transaction_id = TransactionId::new(proof_ys.clone());
+
         let spendable = self
             .client
             .post_check_state(CheckStateRequest { ys: proof_ys })
@@ -90,6 +93,13 @@ impl Wallet {
         self.swap(None, SplitTarget::default(), unspent, None, false)
             .await?;
 
+        match self.localstore.remove_transaction(transaction_id).await {
+            Ok(_) => (),
+            Err(e) => {
+                tracing::warn!("Failed to remove transaction: {:?}", e);
+            }
+        }
+
         Ok(())
     }
 

+ 48 - 24
crates/cdk/src/wallet/receive.rs

@@ -4,6 +4,8 @@ use std::str::FromStr;
 use bitcoin::hashes::sha256::Hash as Sha256Hash;
 use bitcoin::hashes::Hash;
 use bitcoin::XOnlyPublicKey;
+use cdk_common::util::unix_time;
+use cdk_common::wallet::{Transaction, TransactionDirection};
 use tracing::instrument;
 
 use crate::amount::SplitTarget;
@@ -21,9 +23,8 @@ impl Wallet {
     pub async fn receive_proofs(
         &self,
         proofs: Proofs,
-        amount_split_target: SplitTarget,
-        p2pk_signing_keys: &[SecretKey],
-        preimages: &[String],
+        opts: ReceiveOptions,
+        memo: Option<String>,
     ) -> Result<Amount, Error> {
         let mint_url = &self.mint_url;
         // Add mint if it does not exist in the store
@@ -45,10 +46,14 @@ impl Wallet {
 
         let mut proofs = proofs;
 
+        let proofs_amount = proofs.total_amount()?;
+        let proofs_ys = proofs.ys()?;
+
         let mut sig_flag = SigFlag::SigInputs;
 
         // Map hash of preimage to preimage
-        let hashed_to_preimage: HashMap<String, &String> = preimages
+        let hashed_to_preimage: HashMap<String, &String> = opts
+            .preimages
             .iter()
             .map(|p| {
                 let hex_bytes = hex::decode(p)?;
@@ -56,7 +61,8 @@ impl Wallet {
             })
             .collect::<Result<HashMap<String, &String>, _>>()?;
 
-        let p2pk_signing_keys: HashMap<XOnlyPublicKey, &SecretKey> = p2pk_signing_keys
+        let p2pk_signing_keys: HashMap<XOnlyPublicKey, &SecretKey> = opts
+            .p2pk_signing_keys
             .iter()
             .map(|s| (s.x_only_public_key(&SECP256K1).0, s))
             .collect();
@@ -117,7 +123,7 @@ impl Wallet {
             .await?;
 
         let mut pre_swap = self
-            .create_swap(None, amount_split_target, proofs, None, false)
+            .create_swap(None, opts.amount_split_target, proofs, None, false)
             .await?;
 
         if sig_flag.eq(&SigFlag::SigAll) {
@@ -155,6 +161,21 @@ impl Wallet {
             )
             .await?;
 
+        // Add transaction to store
+        self.localstore
+            .add_transaction(Transaction {
+                mint_url: self.mint_url.clone(),
+                direction: TransactionDirection::Incoming,
+                amount: total_amount,
+                fee: proofs_amount - total_amount,
+                unit: self.unit.clone(),
+                ys: proofs_ys,
+                timestamp: unix_time(),
+                memo,
+                metadata: opts.metadata,
+            })
+            .await?;
+
         Ok(total_amount)
     }
 
@@ -166,7 +187,7 @@ impl Wallet {
     ///  use cdk::amount::SplitTarget;
     ///  use cdk_sqlite::wallet::memory;
     ///  use cdk::nuts::CurrencyUnit;
-    ///  use cdk::wallet::Wallet;
+    ///  use cdk::wallet::{ReceiveOptions, Wallet};
     ///  use rand::random;
     ///
     /// #[tokio::main]
@@ -178,7 +199,7 @@ impl Wallet {
     ///  let localstore = memory::empty().await?;
     ///  let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, None).unwrap();
     ///  let token = "cashuAeyJ0b2tlbiI6W3sicHJvb2ZzIjpbeyJhbW91bnQiOjEsInNlY3JldCI6ImI0ZjVlNDAxMDJhMzhiYjg3NDNiOTkwMzU5MTU1MGYyZGEzZTQxNWEzMzU0OTUyN2M2MmM5ZDc5MGVmYjM3MDUiLCJDIjoiMDIzYmU1M2U4YzYwNTMwZWVhOWIzOTQzZmRhMWEyY2U3MWM3YjNmMGNmMGRjNmQ4NDZmYTc2NWFhZjc3OWZhODFkIiwiaWQiOiIwMDlhMWYyOTMyNTNlNDFlIn1dLCJtaW50IjoiaHR0cHM6Ly90ZXN0bnV0LmNhc2h1LnNwYWNlIn1dLCJ1bml0Ijoic2F0In0=";
-    ///  let amount_receive = wallet.receive(token, SplitTarget::default(), &[], &[]).await?;
+    ///  let amount_receive = wallet.receive(token, ReceiveOptions::default()).await?;
     ///  Ok(())
     /// }
     /// ```
@@ -186,9 +207,7 @@ impl Wallet {
     pub async fn receive(
         &self,
         encoded_token: &str,
-        amount_split_target: SplitTarget,
-        p2pk_signing_keys: &[SecretKey],
-        preimages: &[String],
+        opts: ReceiveOptions,
     ) -> Result<Amount, Error> {
         let token = Token::from_str(encoded_token)?;
 
@@ -205,7 +224,7 @@ impl Wallet {
         ensure_cdk!(self.mint_url == token.mint_url()?, Error::IncorrectMint);
 
         let amount = self
-            .receive_proofs(proofs, amount_split_target, p2pk_signing_keys, preimages)
+            .receive_proofs(proofs, opts, token.memo().clone())
             .await?;
 
         Ok(amount)
@@ -219,7 +238,7 @@ impl Wallet {
     ///  use cdk::amount::SplitTarget;
     ///  use cdk_sqlite::wallet::memory;
     ///  use cdk::nuts::CurrencyUnit;
-    ///  use cdk::wallet::Wallet;
+    ///  use cdk::wallet::{ReceiveOptions, Wallet};
     ///  use cdk::util::hex;
     ///  use rand::random;
     ///
@@ -232,7 +251,7 @@ impl Wallet {
     ///  let localstore = memory::empty().await?;
     ///  let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, None).unwrap();
     ///  let token_raw = hex::decode("6372617742a4617481a261694800ad268c4d1f5826617081a3616101617378403961366462623834376264323332626137366462306466313937323136623239643362386363313435353363643237383237666331636339343266656462346561635821038618543ffb6b8695df4ad4babcde92a34a96bdcd97dcee0d7ccf98d4721267926164695468616e6b20796f75616d75687474703a2f2f6c6f63616c686f73743a33333338617563736174").unwrap();
-    ///  let amount_receive = wallet.receive_raw(&token_raw, SplitTarget::default(), &[], &[]).await?;
+    ///  let amount_receive = wallet.receive_raw(&token_raw, ReceiveOptions::default()).await?;
     ///  Ok(())
     /// }
     /// ```
@@ -240,17 +259,22 @@ impl Wallet {
     pub async fn receive_raw(
         &self,
         binary_token: &Vec<u8>,
-        amount_split_target: SplitTarget,
-        p2pk_signing_keys: &[SecretKey],
-        preimages: &[String],
+        opts: ReceiveOptions,
     ) -> Result<Amount, Error> {
         let token_str = Token::try_from(binary_token)?.to_string();
-        self.receive(
-            token_str.as_str(),
-            amount_split_target,
-            p2pk_signing_keys,
-            preimages,
-        )
-        .await
+        self.receive(token_str.as_str(), opts).await
     }
 }
+
+/// Receive options
+#[derive(Debug, Clone, Default)]
+pub struct ReceiveOptions {
+    /// Amount split target
+    pub amount_split_target: SplitTarget,
+    /// P2PK signing keys
+    pub p2pk_signing_keys: Vec<SecretKey>,
+    /// Preimages
+    pub preimages: Vec<String>,
+    /// Metadata
+    pub metadata: HashMap<String, String>,
+}

+ 31 - 0
crates/cdk/src/wallet/send.rs

@@ -1,5 +1,8 @@
+use std::collections::HashMap;
 use std::fmt::Debug;
 
+use cdk_common::util::unix_time;
+use cdk_common::wallet::{Transaction, TransactionDirection};
 use tracing::instrument;
 
 use super::SendKind;
@@ -202,6 +205,7 @@ impl Wallet {
     #[instrument(skip(self), err)]
     pub async fn send(&self, send: PreparedSend, memo: Option<SendMemo>) -> Result<Token, Error> {
         tracing::info!("Sending prepared send");
+        let total_send_fee = send.fee();
         let mut proofs_to_send = send.proofs_to_send;
 
         // Get active keyset ID
@@ -273,6 +277,21 @@ impl Wallet {
         let send_memo = send.options.memo.or(memo);
         let memo = send_memo.and_then(|m| if m.include_memo { Some(m.memo) } else { None });
 
+        // Add transaction to store
+        self.localstore
+            .add_transaction(Transaction {
+                mint_url: self.mint_url.clone(),
+                direction: TransactionDirection::Outgoing,
+                amount: send.amount,
+                fee: total_send_fee,
+                unit: self.unit.clone(),
+                ys: proofs_to_send.ys()?,
+                timestamp: unix_time(),
+                memo: memo.clone(),
+                metadata: send.options.metadata,
+            })
+            .await?;
+
         // Create and return token
         Ok(Token::new(
             self.mint_url.clone(),
@@ -401,6 +420,8 @@ pub struct SendOptions {
     ///
     /// When this is true the token created will include the amount of fees needed to redeem the token (amount + fee_to_redeem)
     pub include_fee: bool,
+    /// Metadata
+    pub metadata: HashMap<String, String>,
 }
 
 /// Send memo
@@ -411,3 +432,13 @@ pub struct SendMemo {
     /// Include memo in token
     pub include_memo: bool,
 }
+
+impl SendMemo {
+    /// Create a new send memo
+    pub fn for_token(memo: &str) -> Self {
+        Self {
+            memo: memo.to_string(),
+            include_memo: true,
+        }
+    }
+}

+ 31 - 0
crates/cdk/src/wallet/transactions.rs

@@ -0,0 +1,31 @@
+use cdk_common::wallet::{Transaction, TransactionDirection, TransactionId};
+
+use crate::{Error, Wallet};
+
+impl Wallet {
+    /// List transactions
+    pub async fn list_transactions(
+        &self,
+        direction: Option<TransactionDirection>,
+    ) -> Result<Vec<Transaction>, Error> {
+        let mut transactions = self
+            .localstore
+            .list_transactions(
+                Some(self.mint_url.clone()),
+                direction,
+                Some(self.unit.clone()),
+            )
+            .await?;
+
+        transactions.sort();
+
+        Ok(transactions)
+    }
+
+    /// Get transaction by ID
+    pub async fn get_transaction(&self, id: TransactionId) -> Result<Option<Transaction>, Error> {
+        let transaction = self.localstore.get_transaction(id).await?;
+
+        Ok(transaction)
+    }
+}