Add transactions to database (#686)

This commit is contained in:
David Caseria
2025-04-03 06:37:43 -04:00
committed by GitHub
parent 7fbe55ea02
commit b1dd321f0a
20 changed files with 812 additions and 45 deletions

View File

@@ -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);

View File

@@ -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)
}

View File

@@ -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>;
}

View File

@@ -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),

View File

@@ -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)
}
}

View File

@@ -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<()> {

View File

@@ -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()

View File

@@ -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(())
}
}

View File

@@ -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),

View File

@@ -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);

View File

@@ -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;

View File

@@ -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));

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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};

View File

@@ -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) => {

View File

@@ -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(())
}

View File

@@ -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>,
}

View File

@@ -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,
}
}
}

View File

@@ -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)
}
}