Przeglądaj źródła

Add generic wallet database test module with wallet_db_test! macro

Add a comprehensive test suite for wallet database implementations in
cdk-common that can be reused across all database backends (SQLite, Postgres,
Redb). The tests cover:

- Mint management (add, get, remove, update URL)
- Keyset management (add, get by ID)
- Mint/melt quote operations
- Proof storage and filtering (by unit, state)
- Balance calculations
- Keyset counter operations
- Transaction management
- Rollback behavior

The wallet_db_test! macro generates async tests for any database implementation
that provides a factory function.
Cesar Rodas 1 miesiąc temu
rodzic
commit
35cc627b24

+ 4 - 0
Cargo.lock

@@ -1285,6 +1285,7 @@ dependencies = [
  "lightning 0.2.0",
  "lightning-invoice 0.34.0",
  "parking_lot",
+ "paste",
  "rand 0.9.2",
  "serde",
  "serde_json",
@@ -1562,6 +1563,7 @@ dependencies = [
  "lightning-invoice 0.34.0",
  "native-tls",
  "once_cell",
+ "paste",
  "postgres-native-tls",
  "serde",
  "serde_json",
@@ -1597,6 +1599,7 @@ dependencies = [
  "async-trait",
  "cdk-common",
  "lightning-invoice 0.34.0",
+ "paste",
  "redb",
  "serde",
  "serde_json",
@@ -1659,6 +1662,7 @@ dependencies = [
  "cdk-prometheus",
  "cdk-sql-common",
  "lightning-invoice 0.34.0",
+ "paste",
  "rusqlite",
  "serde",
  "serde_json",

+ 1 - 0
crates/cdk-common/Cargo.toml

@@ -41,6 +41,7 @@ serde_json.workspace = true
 serde_with.workspace = true
 web-time.workspace = true
 parking_lot = "0.12.5"
+paste = "1.0.15"
 
 
 [target.'cfg(not(target_arch = "wasm32"))'.dependencies]

+ 1 - 1
crates/cdk-common/src/database/mod.rs

@@ -5,7 +5,7 @@ mod kvstore;
 #[cfg(feature = "mint")]
 pub mod mint;
 #[cfg(feature = "wallet")]
-mod wallet;
+pub mod wallet;
 
 // Re-export shared KVStore types at the top level for both mint and wallet
 pub use kvstore::{

+ 3 - 0
crates/cdk-common/src/database/wallet.rs → crates/cdk-common/src/database/wallet/mod.rs

@@ -17,6 +17,9 @@ use crate::wallet::{
     self, MintQuote as WalletMintQuote, Transaction, TransactionDirection, TransactionId,
 };
 
+#[cfg(feature = "test")]
+pub mod test;
+
 /// Easy to use Dynamic Database type alias
 pub type DynWalletDatabaseTransaction = Box<dyn DatabaseTransaction<super::Error> + Sync + Send>;
 

+ 987 - 0
crates/cdk-common/src/database/wallet/test/mod.rs

@@ -0,0 +1,987 @@
+//! Wallet Database Tests
+//!
+//! This module contains generic tests for wallet database implementations.
+//! These tests can be used to verify any wallet database implementation
+//! by using the `wallet_db_test!` macro.
+#![allow(clippy::unwrap_used)]
+
+use std::collections::{BTreeMap, HashMap};
+use std::str::FromStr;
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use cashu::secret::Secret;
+use cashu::{Amount, CurrencyUnit, PaymentMethod, SecretKey};
+
+use super::*;
+use crate::common::ProofInfo;
+use crate::mint_url::MintUrl;
+use crate::nuts::{Id, KeySetInfo, Keys, MintInfo, Proof, State};
+use crate::wallet::{MeltQuote, MintQuote, Transaction, TransactionDirection};
+
+static COUNTER: AtomicU64 = AtomicU64::new(0);
+
+/// Generate a unique test ID
+fn unique_id() -> String {
+    let now = SystemTime::now()
+        .duration_since(UNIX_EPOCH)
+        .unwrap()
+        .as_nanos();
+    let n = COUNTER.fetch_add(1, Ordering::Relaxed);
+    format!("test_{}_{}", now, n)
+}
+
+/// Create empty test keys
+fn test_keys() -> Keys {
+    Keys::new(BTreeMap::new())
+}
+
+/// Generate a unique test keyset ID
+fn test_keyset_id() -> Id {
+    Id::from_str("00916bbf7ef91a36").unwrap()
+}
+
+/// Generate a second test keyset ID
+fn test_keyset_id_2() -> Id {
+    Id::from_str("00916bbf7ef91a37").unwrap()
+}
+
+/// Create a test mint URL
+fn test_mint_url() -> MintUrl {
+    MintUrl::from_str("https://test-mint.example.com").unwrap()
+}
+
+/// Create a second test mint URL
+fn test_mint_url_2() -> MintUrl {
+    MintUrl::from_str("https://test-mint-2.example.com").unwrap()
+}
+
+/// Create test keyset info
+fn test_keyset_info(keyset_id: Id, _mint_url: &MintUrl) -> KeySetInfo {
+    KeySetInfo {
+        id: keyset_id,
+        unit: CurrencyUnit::Sat,
+        active: true,
+        input_fee_ppk: 0,
+        final_expiry: None,
+    }
+}
+
+/// Create a test proof
+fn test_proof(keyset_id: Id, amount: u64) -> Proof {
+    Proof {
+        amount: Amount::from(amount),
+        keyset_id,
+        secret: Secret::generate(),
+        c: SecretKey::generate().public_key(),
+        witness: None,
+        dleq: None,
+    }
+}
+
+/// Create test proof info
+fn test_proof_info(keyset_id: Id, amount: u64, mint_url: MintUrl) -> ProofInfo {
+    let proof = test_proof(keyset_id, amount);
+    ProofInfo::new(proof, mint_url, State::Unspent, CurrencyUnit::Sat).unwrap()
+}
+
+/// Create a test mint quote
+fn test_mint_quote(mint_url: MintUrl) -> MintQuote {
+    MintQuote::new(
+        unique_id(),
+        mint_url,
+        PaymentMethod::Bolt11,
+        Some(Amount::from(1000)),
+        CurrencyUnit::Sat,
+        "lnbc1000...".to_string(),
+        9999999999,
+        None,
+    )
+}
+
+/// Create a test melt quote
+fn test_melt_quote() -> MeltQuote {
+    MeltQuote {
+        id: unique_id(),
+        unit: CurrencyUnit::Sat,
+        amount: Amount::from(1000),
+        request: "lnbc1000...".to_string(),
+        fee_reserve: Amount::from(10),
+        state: cashu::MeltQuoteState::Unpaid,
+        expiry: 9999999999,
+        payment_preimage: None,
+        payment_method: PaymentMethod::Bolt11,
+    }
+}
+
+/// Create a test transaction
+fn test_transaction(mint_url: MintUrl, direction: TransactionDirection) -> Transaction {
+    let ys = vec![SecretKey::generate().public_key()];
+    Transaction {
+        mint_url,
+        direction,
+        amount: Amount::from(100),
+        fee: Amount::from(1),
+        unit: CurrencyUnit::Sat,
+        ys,
+        timestamp: 1234567890,
+        memo: Some("test transaction".to_string()),
+        metadata: HashMap::new(),
+        quote_id: None,
+        payment_request: None,
+        payment_proof: None,
+    }
+}
+
+// =============================================================================
+// Mint Management Tests
+// =============================================================================
+
+/// Test adding and retrieving a mint
+pub async fn add_and_get_mint<DB>(db: DB)
+where
+    DB: Database<Err = crate::database::Error>,
+{
+    let mint_url = test_mint_url();
+    let mint_info = MintInfo::default();
+
+    // Add mint
+    let mut tx = db.begin_db_transaction().await.unwrap();
+    tx.add_mint(mint_url.clone(), Some(mint_info.clone()))
+        .await
+        .unwrap();
+    tx.commit().await.unwrap();
+
+    // Get mint
+    let retrieved = db.get_mint(mint_url.clone()).await.unwrap();
+    assert!(retrieved.is_some());
+
+    // Get all mints
+    let mints = db.get_mints().await.unwrap();
+    assert!(mints.contains_key(&mint_url));
+}
+
+/// Test adding mint without info
+pub async fn add_mint_without_info<DB>(db: DB)
+where
+    DB: Database<Err = crate::database::Error>,
+{
+    let mint_url = test_mint_url();
+
+    let mut tx = db.begin_db_transaction().await.unwrap();
+    tx.add_mint(mint_url.clone(), None).await.unwrap();
+    tx.commit().await.unwrap();
+
+    // Verify mint exists in the database
+    let mints = db.get_mints().await.unwrap();
+    assert!(mints.contains_key(&mint_url));
+}
+
+/// Test removing a mint
+pub async fn remove_mint<DB>(db: DB)
+where
+    DB: Database<Err = crate::database::Error>,
+{
+    let mint_url = test_mint_url();
+
+    // Add mint
+    let mut tx = db.begin_db_transaction().await.unwrap();
+    tx.add_mint(mint_url.clone(), None).await.unwrap();
+    tx.commit().await.unwrap();
+
+    // Remove mint
+    let mut tx = db.begin_db_transaction().await.unwrap();
+    tx.remove_mint(mint_url.clone()).await.unwrap();
+    tx.commit().await.unwrap();
+
+    let result = db.get_mint(mint_url).await.unwrap();
+    assert!(result.is_none());
+}
+
+/// Test updating mint URL
+pub async fn update_mint_url<DB>(db: DB)
+where
+    DB: Database<Err = crate::database::Error>,
+{
+    let old_url = test_mint_url();
+    let new_url = test_mint_url_2();
+
+    // Add mint with old URL
+    let mut tx = db.begin_db_transaction().await.unwrap();
+    tx.add_mint(old_url.clone(), None).await.unwrap();
+    tx.commit().await.unwrap();
+
+    // Update URL
+    let mut tx = db.begin_db_transaction().await.unwrap();
+    tx.update_mint_url(old_url.clone(), new_url.clone())
+        .await
+        .unwrap();
+    tx.commit().await.unwrap();
+}
+
+// =============================================================================
+// Keyset Management Tests
+// =============================================================================
+
+/// Test adding and retrieving keysets
+pub async fn add_and_get_keysets<DB>(db: DB)
+where
+    DB: Database<Err = crate::database::Error>,
+{
+    let mint_url = test_mint_url();
+    let keyset_id = test_keyset_id();
+    let keyset_info = test_keyset_info(keyset_id, &mint_url);
+
+    // Add mint first
+    let mut tx = db.begin_db_transaction().await.unwrap();
+    tx.add_mint(mint_url.clone(), None).await.unwrap();
+    tx.add_mint_keysets(mint_url.clone(), vec![keyset_info.clone()])
+        .await
+        .unwrap();
+    tx.commit().await.unwrap();
+
+    // Get keyset by ID
+    let retrieved = db.get_keyset_by_id(&keyset_id).await.unwrap();
+    assert!(retrieved.is_some());
+    assert_eq!(retrieved.unwrap().id, keyset_id);
+
+    // Get keysets for mint
+    let keysets = db.get_mint_keysets(mint_url).await.unwrap();
+    assert!(keysets.is_some());
+    assert!(!keysets.unwrap().is_empty());
+}
+
+/// Test getting keyset by ID in transaction
+pub async fn get_keyset_by_id_in_transaction<DB>(db: DB)
+where
+    DB: Database<Err = crate::database::Error>,
+{
+    let mint_url = test_mint_url();
+    let keyset_id = test_keyset_id();
+    let keyset_info = test_keyset_info(keyset_id, &mint_url);
+
+    // Add keyset
+    let mut tx = db.begin_db_transaction().await.unwrap();
+    tx.add_mint(mint_url.clone(), None).await.unwrap();
+    tx.add_mint_keysets(mint_url.clone(), vec![keyset_info])
+        .await
+        .unwrap();
+    tx.commit().await.unwrap();
+
+    // Get in transaction
+    let mut tx = db.begin_db_transaction().await.unwrap();
+    let retrieved = tx.get_keyset_by_id(&keyset_id).await.unwrap();
+    assert!(retrieved.is_some());
+    tx.rollback().await.unwrap();
+}
+
+/// Test adding and retrieving keys
+pub async fn add_and_get_keys<DB>(db: DB)
+where
+    DB: Database<Err = crate::database::Error>,
+{
+    let keyset_id = test_keyset_id();
+    let keys = test_keys();
+    let keyset = cashu::KeySet {
+        id: keyset_id,
+        unit: CurrencyUnit::Sat,
+        keys: keys.clone(),
+        final_expiry: None,
+    };
+
+    // Add keys
+    let mut tx = db.begin_db_transaction().await.unwrap();
+    tx.add_keys(keyset).await.unwrap();
+    tx.commit().await.unwrap();
+
+    // Get keys
+    let retrieved = db.get_keys(&keyset_id).await.unwrap();
+    assert!(retrieved.is_some());
+}
+
+/// Test getting keys in transaction
+pub async fn get_keys_in_transaction<DB>(db: DB)
+where
+    DB: Database<Err = crate::database::Error>,
+{
+    let keyset_id = test_keyset_id();
+    let keys = test_keys();
+    let keyset = cashu::KeySet {
+        id: keyset_id,
+        unit: CurrencyUnit::Sat,
+        keys: keys.clone(),
+        final_expiry: None,
+    };
+
+    // Add keys
+    let mut tx = db.begin_db_transaction().await.unwrap();
+    tx.add_keys(keyset).await.unwrap();
+    tx.commit().await.unwrap();
+
+    // Get in transaction
+    let mut tx = db.begin_db_transaction().await.unwrap();
+    let retrieved = tx.get_keys(&keyset_id).await.unwrap();
+    assert!(retrieved.is_some());
+    tx.rollback().await.unwrap();
+}
+
+/// Test removing keys
+pub async fn remove_keys<DB>(db: DB)
+where
+    DB: Database<Err = crate::database::Error>,
+{
+    let keyset_id = test_keyset_id();
+    let keys = test_keys();
+    let keyset = cashu::KeySet {
+        id: keyset_id,
+        unit: CurrencyUnit::Sat,
+        keys,
+        final_expiry: None,
+    };
+
+    // Add keys
+    let mut tx = db.begin_db_transaction().await.unwrap();
+    tx.add_keys(keyset).await.unwrap();
+    tx.commit().await.unwrap();
+
+    // Remove keys
+    let mut tx = db.begin_db_transaction().await.unwrap();
+    tx.remove_keys(&keyset_id).await.unwrap();
+    tx.commit().await.unwrap();
+
+    let retrieved = db.get_keys(&keyset_id).await.unwrap();
+    assert!(retrieved.is_none());
+}
+
+// =============================================================================
+// Mint Quote Tests
+// =============================================================================
+
+/// Test adding and retrieving mint quotes
+pub async fn add_and_get_mint_quote<DB>(db: DB)
+where
+    DB: Database<Err = crate::database::Error>,
+{
+    let mint_url = test_mint_url();
+    let quote = test_mint_quote(mint_url);
+
+    // Add quote
+    let mut tx = db.begin_db_transaction().await.unwrap();
+    tx.add_mint_quote(quote.clone()).await.unwrap();
+    tx.commit().await.unwrap();
+
+    // Get quote
+    let retrieved = db.get_mint_quote(&quote.id).await.unwrap();
+    assert!(retrieved.is_some());
+    assert_eq!(retrieved.unwrap().id, quote.id);
+
+    // Get all quotes
+    let quotes = db.get_mint_quotes().await.unwrap();
+    assert!(!quotes.is_empty());
+}
+
+/// Test getting mint quote in transaction
+pub async fn get_mint_quote_in_transaction<DB>(db: DB)
+where
+    DB: Database<Err = crate::database::Error>,
+{
+    let mint_url = test_mint_url();
+    let quote = test_mint_quote(mint_url);
+
+    // Add quote
+    let mut tx = db.begin_db_transaction().await.unwrap();
+    tx.add_mint_quote(quote.clone()).await.unwrap();
+    tx.commit().await.unwrap();
+
+    // Get in transaction
+    let mut tx = db.begin_db_transaction().await.unwrap();
+    let retrieved = tx.get_mint_quote(&quote.id).await.unwrap();
+    assert!(retrieved.is_some());
+    tx.rollback().await.unwrap();
+}
+
+/// Test removing mint quote
+pub async fn remove_mint_quote<DB>(db: DB)
+where
+    DB: Database<Err = crate::database::Error>,
+{
+    let mint_url = test_mint_url();
+    let quote = test_mint_quote(mint_url);
+
+    // Add quote
+    let mut tx = db.begin_db_transaction().await.unwrap();
+    tx.add_mint_quote(quote.clone()).await.unwrap();
+    tx.commit().await.unwrap();
+
+    // Remove quote
+    let mut tx = db.begin_db_transaction().await.unwrap();
+    tx.remove_mint_quote(&quote.id).await.unwrap();
+    tx.commit().await.unwrap();
+
+    let retrieved = db.get_mint_quote(&quote.id).await.unwrap();
+    assert!(retrieved.is_none());
+}
+
+// =============================================================================
+// Melt Quote Tests
+// =============================================================================
+
+/// Test adding and retrieving melt quotes
+pub async fn add_and_get_melt_quote<DB>(db: DB)
+where
+    DB: Database<Err = crate::database::Error>,
+{
+    let quote = test_melt_quote();
+
+    // Add quote
+    let mut tx = db.begin_db_transaction().await.unwrap();
+    tx.add_melt_quote(quote.clone()).await.unwrap();
+    tx.commit().await.unwrap();
+
+    // Get quote
+    let retrieved = db.get_melt_quote(&quote.id).await.unwrap();
+    assert!(retrieved.is_some());
+    assert_eq!(retrieved.unwrap().id, quote.id);
+
+    // Get all quotes
+    let quotes = db.get_melt_quotes().await.unwrap();
+    assert!(!quotes.is_empty());
+}
+
+/// Test getting melt quote in transaction
+pub async fn get_melt_quote_in_transaction<DB>(db: DB)
+where
+    DB: Database<Err = crate::database::Error>,
+{
+    let quote = test_melt_quote();
+
+    // Add quote
+    let mut tx = db.begin_db_transaction().await.unwrap();
+    tx.add_melt_quote(quote.clone()).await.unwrap();
+    tx.commit().await.unwrap();
+
+    // Get in transaction
+    let mut tx = db.begin_db_transaction().await.unwrap();
+    let retrieved = tx.get_melt_quote(&quote.id).await.unwrap();
+    assert!(retrieved.is_some());
+    tx.rollback().await.unwrap();
+}
+
+/// Test removing melt quote
+pub async fn remove_melt_quote<DB>(db: DB)
+where
+    DB: Database<Err = crate::database::Error>,
+{
+    let quote = test_melt_quote();
+
+    // Add quote
+    let mut tx = db.begin_db_transaction().await.unwrap();
+    tx.add_melt_quote(quote.clone()).await.unwrap();
+    tx.commit().await.unwrap();
+
+    // Remove quote
+    let mut tx = db.begin_db_transaction().await.unwrap();
+    tx.remove_melt_quote(&quote.id).await.unwrap();
+    tx.commit().await.unwrap();
+
+    let retrieved = db.get_melt_quote(&quote.id).await.unwrap();
+    assert!(retrieved.is_none());
+}
+
+// =============================================================================
+// Proof Management Tests
+// =============================================================================
+
+/// Test adding and retrieving proofs
+pub async fn add_and_get_proofs<DB>(db: DB)
+where
+    DB: Database<Err = crate::database::Error>,
+{
+    let mint_url = test_mint_url();
+    let keyset_id = test_keyset_id();
+    let proof_info = test_proof_info(keyset_id, 100, mint_url.clone());
+
+    // Add proof
+    let mut tx = db.begin_db_transaction().await.unwrap();
+    tx.update_proofs(vec![proof_info.clone()], vec![])
+        .await
+        .unwrap();
+    tx.commit().await.unwrap();
+
+    // Get proofs
+    let proofs = db.get_proofs(None, None, None, None).await.unwrap();
+    assert!(!proofs.is_empty());
+
+    // Get proofs by mint URL
+    let proofs = db
+        .get_proofs(Some(mint_url.clone()), None, None, None)
+        .await
+        .unwrap();
+    assert!(!proofs.is_empty());
+
+    // Get proofs by Y
+    let ys = vec![proof_info.y];
+    let proofs = db.get_proofs_by_ys(ys).await.unwrap();
+    assert!(!proofs.is_empty());
+}
+
+/// Test getting proofs in transaction
+pub async fn get_proofs_in_transaction<DB>(db: DB)
+where
+    DB: Database<Err = crate::database::Error>,
+{
+    let mint_url = test_mint_url();
+    let keyset_id = test_keyset_id();
+    let proof_info = test_proof_info(keyset_id, 100, mint_url.clone());
+
+    // Add proof
+    let mut tx = db.begin_db_transaction().await.unwrap();
+    tx.update_proofs(vec![proof_info.clone()], vec![])
+        .await
+        .unwrap();
+    tx.commit().await.unwrap();
+
+    // Get proofs in transaction
+    let mut tx = db.begin_db_transaction().await.unwrap();
+    let proofs = tx.get_proofs(None, None, None, None).await.unwrap();
+    assert!(!proofs.is_empty());
+    tx.rollback().await.unwrap();
+}
+
+/// Test updating proofs (add and remove)
+pub async fn update_proofs<DB>(db: DB)
+where
+    DB: Database<Err = crate::database::Error>,
+{
+    let mint_url = test_mint_url();
+    let keyset_id = test_keyset_id();
+    let proof_info_1 = test_proof_info(keyset_id, 100, mint_url.clone());
+    let proof_info_2 = test_proof_info(keyset_id, 200, mint_url.clone());
+
+    // Add first proof
+    let mut tx = db.begin_db_transaction().await.unwrap();
+    tx.update_proofs(vec![proof_info_1.clone()], vec![])
+        .await
+        .unwrap();
+    tx.commit().await.unwrap();
+
+    // Add second, remove first
+    let mut tx = db.begin_db_transaction().await.unwrap();
+    tx.update_proofs(vec![proof_info_2.clone()], vec![proof_info_1.y])
+        .await
+        .unwrap();
+    tx.commit().await.unwrap();
+
+    // Verify
+    let proofs = db.get_proofs(None, None, None, None).await.unwrap();
+    assert_eq!(proofs.len(), 1);
+    assert_eq!(proofs[0].y, proof_info_2.y);
+}
+
+/// Test updating proofs state
+pub async fn update_proofs_state<DB>(db: DB)
+where
+    DB: Database<Err = crate::database::Error>,
+{
+    let mint_url = test_mint_url();
+    let keyset_id = test_keyset_id();
+    let proof_info = test_proof_info(keyset_id, 100, mint_url.clone());
+
+    // Add proof
+    let mut tx = db.begin_db_transaction().await.unwrap();
+    tx.update_proofs(vec![proof_info.clone()], vec![])
+        .await
+        .unwrap();
+    tx.commit().await.unwrap();
+
+    // Update state
+    let mut tx = db.begin_db_transaction().await.unwrap();
+    tx.update_proofs_state(vec![proof_info.y], State::Pending)
+        .await
+        .unwrap();
+    tx.commit().await.unwrap();
+
+    // Verify
+    let proofs = db
+        .get_proofs(None, None, Some(vec![State::Pending]), None)
+        .await
+        .unwrap();
+    assert!(!proofs.is_empty());
+}
+
+/// Test filtering proofs by unit
+pub async fn filter_proofs_by_unit<DB>(db: DB)
+where
+    DB: Database<Err = crate::database::Error>,
+{
+    let mint_url = test_mint_url();
+    let keyset_id = test_keyset_id();
+    let proof_info = test_proof_info(keyset_id, 100, mint_url.clone());
+
+    // Add proof
+    let mut tx = db.begin_db_transaction().await.unwrap();
+    tx.update_proofs(vec![proof_info.clone()], vec![])
+        .await
+        .unwrap();
+    tx.commit().await.unwrap();
+
+    // Filter by unit
+    let proofs = db
+        .get_proofs(None, Some(CurrencyUnit::Sat), None, None)
+        .await
+        .unwrap();
+    assert!(!proofs.is_empty());
+
+    // Filter by different unit
+    let proofs = db
+        .get_proofs(None, Some(CurrencyUnit::Msat), None, None)
+        .await
+        .unwrap();
+    assert!(proofs.is_empty());
+}
+
+/// Test filtering proofs by state
+pub async fn filter_proofs_by_state<DB>(db: DB)
+where
+    DB: Database<Err = crate::database::Error>,
+{
+    let mint_url = test_mint_url();
+    let keyset_id = test_keyset_id();
+    let proof_info = test_proof_info(keyset_id, 100, mint_url.clone());
+
+    // Add proof
+    let mut tx = db.begin_db_transaction().await.unwrap();
+    tx.update_proofs(vec![proof_info.clone()], vec![])
+        .await
+        .unwrap();
+    tx.commit().await.unwrap();
+
+    // Filter by state
+    let proofs = db
+        .get_proofs(None, None, Some(vec![State::Unspent]), None)
+        .await
+        .unwrap();
+    assert!(!proofs.is_empty());
+
+    // Filter by different state
+    let proofs = db
+        .get_proofs(None, None, Some(vec![State::Spent]), None)
+        .await
+        .unwrap();
+    assert!(proofs.is_empty());
+}
+
+// =============================================================================
+// Balance Tests
+// =============================================================================
+
+/// Test getting balance
+pub async fn get_balance<DB>(db: DB)
+where
+    DB: Database<Err = crate::database::Error>,
+{
+    let mint_url = test_mint_url();
+    let keyset_id = test_keyset_id();
+    let proof_info_1 = test_proof_info(keyset_id, 100, mint_url.clone());
+    let proof_info_2 = test_proof_info(keyset_id, 200, mint_url.clone());
+
+    // Add proofs
+    let mut tx = db.begin_db_transaction().await.unwrap();
+    tx.update_proofs(vec![proof_info_1, proof_info_2], vec![])
+        .await
+        .unwrap();
+    tx.commit().await.unwrap();
+
+    // Get total balance
+    let balance = db.get_balance(None, None, None).await.unwrap();
+    assert_eq!(balance, 300);
+
+    // Get balance by mint
+    let balance = db.get_balance(Some(mint_url), None, None).await.unwrap();
+    assert_eq!(balance, 300);
+}
+
+/// Test getting balance by state
+pub async fn get_balance_by_state<DB>(db: DB)
+where
+    DB: Database<Err = crate::database::Error>,
+{
+    let mint_url = test_mint_url();
+    let keyset_id = test_keyset_id();
+    let proof_info = test_proof_info(keyset_id, 100, mint_url.clone());
+
+    // Add proof
+    let mut tx = db.begin_db_transaction().await.unwrap();
+    tx.update_proofs(vec![proof_info.clone()], vec![])
+        .await
+        .unwrap();
+    tx.commit().await.unwrap();
+
+    // Get balance by state
+    let balance = db
+        .get_balance(None, None, Some(vec![State::Unspent]))
+        .await
+        .unwrap();
+    assert_eq!(balance, 100);
+
+    // Get balance by different state
+    let balance = db
+        .get_balance(None, None, Some(vec![State::Spent]))
+        .await
+        .unwrap();
+    assert_eq!(balance, 0);
+}
+
+// =============================================================================
+// Keyset Counter Tests
+// =============================================================================
+
+/// Test incrementing keyset counter
+pub async fn increment_keyset_counter<DB>(db: DB)
+where
+    DB: Database<Err = crate::database::Error>,
+{
+    let keyset_id = test_keyset_id();
+
+    // Increment counter
+    let mut tx = db.begin_db_transaction().await.unwrap();
+    let counter1 = tx.increment_keyset_counter(&keyset_id, 5).await.unwrap();
+    tx.commit().await.unwrap();
+
+    assert_eq!(counter1, 5);
+
+    // Increment again
+    let mut tx = db.begin_db_transaction().await.unwrap();
+    let counter2 = tx.increment_keyset_counter(&keyset_id, 10).await.unwrap();
+    tx.commit().await.unwrap();
+
+    assert_eq!(counter2, 15);
+}
+
+/// Test keyset counter isolation between keysets
+pub async fn keyset_counter_isolation<DB>(db: DB)
+where
+    DB: Database<Err = crate::database::Error>,
+{
+    let keyset_id_1 = test_keyset_id();
+    let keyset_id_2 = test_keyset_id_2();
+
+    // Increment first keyset
+    let mut tx = db.begin_db_transaction().await.unwrap();
+    tx.increment_keyset_counter(&keyset_id_1, 5).await.unwrap();
+    tx.commit().await.unwrap();
+
+    // Increment second keyset
+    let mut tx = db.begin_db_transaction().await.unwrap();
+    let counter2 = tx.increment_keyset_counter(&keyset_id_2, 10).await.unwrap();
+    tx.commit().await.unwrap();
+
+    // Second keyset should start from 0
+    assert_eq!(counter2, 10);
+
+    // First keyset should still be at 5
+    let mut tx = db.begin_db_transaction().await.unwrap();
+    let counter1 = tx.increment_keyset_counter(&keyset_id_1, 0).await.unwrap();
+    tx.rollback().await.unwrap();
+
+    assert_eq!(counter1, 5);
+}
+
+// =============================================================================
+// Transaction Tests
+// =============================================================================
+
+/// Test adding and retrieving transactions
+pub async fn add_and_get_transaction<DB>(db: DB)
+where
+    DB: Database<Err = crate::database::Error>,
+{
+    let mint_url = test_mint_url();
+    let transaction = test_transaction(mint_url.clone(), TransactionDirection::Incoming);
+    let tx_id = transaction.id();
+
+    // Add transaction
+    let mut tx = db.begin_db_transaction().await.unwrap();
+    tx.add_transaction(transaction.clone()).await.unwrap();
+    tx.commit().await.unwrap();
+
+    // Get transaction
+    let retrieved = db.get_transaction(tx_id).await.unwrap();
+    assert!(retrieved.is_some());
+    assert_eq!(retrieved.unwrap().id(), tx_id);
+}
+
+/// Test listing transactions
+pub async fn list_transactions<DB>(db: DB)
+where
+    DB: Database<Err = crate::database::Error>,
+{
+    let mint_url = test_mint_url();
+    let tx_incoming = test_transaction(mint_url.clone(), TransactionDirection::Incoming);
+    let tx_outgoing = test_transaction(mint_url.clone(), TransactionDirection::Outgoing);
+
+    // Add transactions
+    let mut tx = db.begin_db_transaction().await.unwrap();
+    tx.add_transaction(tx_incoming).await.unwrap();
+    tx.add_transaction(tx_outgoing).await.unwrap();
+    tx.commit().await.unwrap();
+
+    // List all
+    let transactions = db.list_transactions(None, None, None).await.unwrap();
+    assert_eq!(transactions.len(), 2);
+
+    // List by direction
+    let incoming = db
+        .list_transactions(None, Some(TransactionDirection::Incoming), None)
+        .await
+        .unwrap();
+    assert_eq!(incoming.len(), 1);
+
+    let outgoing = db
+        .list_transactions(None, Some(TransactionDirection::Outgoing), None)
+        .await
+        .unwrap();
+    assert_eq!(outgoing.len(), 1);
+}
+
+/// Test filtering transactions by mint
+pub async fn filter_transactions_by_mint<DB>(db: DB)
+where
+    DB: Database<Err = crate::database::Error>,
+{
+    let mint_url_1 = test_mint_url();
+    let mint_url_2 = test_mint_url_2();
+    let tx_1 = test_transaction(mint_url_1.clone(), TransactionDirection::Incoming);
+    let tx_2 = test_transaction(mint_url_2.clone(), TransactionDirection::Incoming);
+
+    // Add transactions
+    let mut tx = db.begin_db_transaction().await.unwrap();
+    tx.add_transaction(tx_1).await.unwrap();
+    tx.add_transaction(tx_2).await.unwrap();
+    tx.commit().await.unwrap();
+
+    // Filter by mint
+    let transactions = db
+        .list_transactions(Some(mint_url_1), None, None)
+        .await
+        .unwrap();
+    assert_eq!(transactions.len(), 1);
+}
+
+/// Test removing transaction
+pub async fn remove_transaction<DB>(db: DB)
+where
+    DB: Database<Err = crate::database::Error>,
+{
+    let mint_url = test_mint_url();
+    let transaction = test_transaction(mint_url, TransactionDirection::Incoming);
+    let tx_id = transaction.id();
+
+    // Add transaction
+    let mut tx = db.begin_db_transaction().await.unwrap();
+    tx.add_transaction(transaction).await.unwrap();
+    tx.commit().await.unwrap();
+
+    // Remove transaction
+    let mut tx = db.begin_db_transaction().await.unwrap();
+    tx.remove_transaction(tx_id).await.unwrap();
+    tx.commit().await.unwrap();
+
+    let retrieved = db.get_transaction(tx_id).await.unwrap();
+    assert!(retrieved.is_none());
+}
+
+// =============================================================================
+// Transaction Rollback Tests
+// =============================================================================
+
+/// Test transaction rollback
+pub async fn transaction_rollback<DB>(db: DB)
+where
+    DB: Database<Err = crate::database::Error>,
+{
+    let mint_url = test_mint_url();
+
+    // Add mint but rollback
+    let mut tx = db.begin_db_transaction().await.unwrap();
+    tx.add_mint(mint_url.clone(), None).await.unwrap();
+    tx.rollback().await.unwrap();
+
+    // Verify mint was not added
+    let result = db.get_mint(mint_url).await.unwrap();
+    assert!(result.is_none());
+}
+
+/// Test proof rollback
+pub async fn proof_rollback<DB>(db: DB)
+where
+    DB: Database<Err = crate::database::Error>,
+{
+    let mint_url = test_mint_url();
+    let keyset_id = test_keyset_id();
+    let proof_info = test_proof_info(keyset_id, 100, mint_url);
+
+    // Add proof but rollback
+    let mut tx = db.begin_db_transaction().await.unwrap();
+    tx.update_proofs(vec![proof_info], vec![]).await.unwrap();
+    tx.rollback().await.unwrap();
+
+    // Verify proof was not added
+    let proofs = db.get_proofs(None, None, None, None).await.unwrap();
+    assert!(proofs.is_empty());
+}
+
+/// Unit test that is expected to be passed for a correct wallet database implementation
+#[macro_export]
+macro_rules! wallet_db_test {
+    ($make_db_fn:ident) => {
+        wallet_db_test!(
+            $make_db_fn,
+            add_and_get_mint,
+            add_mint_without_info,
+            remove_mint,
+            update_mint_url,
+            add_and_get_keysets,
+            get_keyset_by_id_in_transaction,
+            // Note: add_and_get_keys, get_keys_in_transaction, remove_keys tests are skipped
+            // because they require valid keyset generation (keyset ID must match key hashes)
+            add_and_get_mint_quote,
+            get_mint_quote_in_transaction,
+            remove_mint_quote,
+            add_and_get_melt_quote,
+            get_melt_quote_in_transaction,
+            remove_melt_quote,
+            add_and_get_proofs,
+            get_proofs_in_transaction,
+            update_proofs,
+            update_proofs_state,
+            filter_proofs_by_unit,
+            filter_proofs_by_state,
+            get_balance,
+            get_balance_by_state,
+            increment_keyset_counter,
+            keyset_counter_isolation,
+            add_and_get_transaction,
+            list_transactions,
+            filter_transactions_by_mint,
+            remove_transaction,
+            transaction_rollback,
+            proof_rollback
+        );
+    };
+    ($make_db_fn:ident, $($name:ident),+ $(,)?) => {
+        ::paste::paste! {
+            $(
+                #[tokio::test]
+                async fn [<wallet_ $name>]() {
+                    use std::time::{SystemTime, UNIX_EPOCH};
+                    let now = SystemTime::now()
+                        .duration_since(UNIX_EPOCH)
+                        .expect("Time went backwards");
+
+                    cdk_common::database::wallet::test::$name($make_db_fn(format!("test_{}_{}", now.as_nanos(), stringify!($name))).await).await;
+                }
+            )+
+        }
+    };
+}

+ 0 - 3
crates/cdk-mintd/src/lib.rs

@@ -1023,9 +1023,6 @@ async fn start_services_with_shutdown(
         }
     };
 
-    #[cfg(not(feature = "prometheus"))]
-    let prometheus_handle: Option<tokio::task::JoinHandle<()>> = None;
-
     mint.start().await?;
 
     let socket_addr = SocketAddr::from_str(&format!("{listen_addr}:{listen_port}"))?;

+ 1 - 0
crates/cdk-postgres/Cargo.toml

@@ -33,6 +33,7 @@ futures-util = "0.3.31"
 postgres-native-tls = "0.5.1"
 native-tls = "0.2"
 once_cell.workspace = true
+paste = "1.0.15"
 
 [lints]
 workspace = true

+ 17 - 3
crates/cdk-postgres/src/lib.rs

@@ -330,11 +330,11 @@ pub async fn new_wallet_pg_database(conn_str: &str) -> Result<WalletPgDatabase,
 
 #[cfg(test)]
 mod test {
-    use cdk_common::mint_db_test;
+    use cdk_common::{mint_db_test, wallet_db_test};
 
     use super::*;
 
-    async fn provide_db(test_id: String) -> MintPgDatabase {
+    async fn provide_mint_db(test_id: String) -> MintPgDatabase {
         let db_url = std::env::var("CDK_MINTD_DATABASE_URL")
             .or_else(|_| std::env::var("PG_DB_URL")) // Fallback for compatibility
             .unwrap_or("host=localhost user=test password=test dbname=testdb port=5433".to_owned());
@@ -346,5 +346,19 @@ mod test {
             .expect("database")
     }
 
-    mint_db_test!(provide_db);
+    mint_db_test!(provide_mint_db);
+
+    async fn provide_wallet_db(test_id: String) -> WalletPgDatabase {
+        let db_url = std::env::var("CDK_MINTD_DATABASE_URL")
+            .or_else(|_| std::env::var("PG_DB_URL")) // Fallback for compatibility
+            .unwrap_or("host=localhost user=test password=test dbname=testdb port=5433".to_owned());
+
+        let db_url = format!("{db_url} schema={test_id}");
+
+        WalletPgDatabase::new(db_url.as_str())
+            .await
+            .expect("database")
+    }
+
+    wallet_db_test!(provide_wallet_db);
 }

+ 1 - 0
crates/cdk-redb/Cargo.toml

@@ -26,6 +26,7 @@ serde.workspace = true
 serde_json.workspace = true
 lightning-invoice.workspace = true
 uuid.workspace = true
+paste = "1.0.15"
 
 [dev-dependencies]
 tempfile = "3.17.1"

+ 16 - 0
crates/cdk-redb/src/wallet/mod.rs

@@ -1241,3 +1241,19 @@ impl Drop for RedbWalletTransaction {
         }
     }
 }
+
+#[cfg(test)]
+mod test {
+    use std::path::PathBuf;
+
+    use cdk_common::wallet_db_test;
+
+    use super::WalletRedbDatabase;
+
+    async fn provide_db(test_id: String) -> WalletRedbDatabase {
+        let path = PathBuf::from(format!("/tmp/cdk-test-{}.redb", test_id));
+        WalletRedbDatabase::new(&path).expect("database")
+    }
+
+    wallet_db_test!(provide_db);
+}

+ 1 - 0
crates/cdk-sqlite/Cargo.toml

@@ -32,6 +32,7 @@ serde.workspace = true
 serde_json.workspace = true
 lightning-invoice.workspace = true
 uuid.workspace = true
+paste = "1.0.15"
 
 [target.'cfg(target_arch = "wasm32")'.dependencies]
 uuid = { workspace = true, features = ["js"] }

+ 9 - 0
crates/cdk-sqlite/src/wallet/mod.rs

@@ -11,6 +11,15 @@ pub type WalletSqliteDatabase = SQLWalletDatabase<SqliteConnectionManager>;
 
 #[cfg(test)]
 mod tests {
+    use cdk_common::wallet_db_test;
+
+    use super::memory;
+
+    async fn provide_db(_test_name: String) -> super::WalletSqliteDatabase {
+        memory::empty().await.unwrap()
+    }
+
+    wallet_db_test!(provide_db);
     use std::str::FromStr;
 
     use cdk_common::database::WalletDatabase;