diff --git a/CHANGELOG.md b/CHANGELOG.md index 738b33c5..45be50e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ - cdk-common: New `Event` enum for payment event handling with `PaymentReceived` variant ([thesimplekid]). - cdk-common: Added `payment_method` field to `MeltQuote` struct for tracking payment method type ([thesimplekid]). - cdk-sql-common: Database migration to add `payment_method` column to melt_quote table for SQLite and PostgreSQL ([thesimplekid]). +- cdk-common: New `MintKVStoreDatabase` trait providing generic key-value storage functionality for mint databases ([thesimplekid]). +- cdk-common: Added `KVStoreTransaction` trait for transactional key-value operations with read, write, remove, and list capabilities ([thesimplekid]). +- cdk-common: Added validation functions for KV store namespace and key parameters with ASCII character and length restrictions ([thesimplekid]). +- cdk-common: Added comprehensive test module for KV store functionality with transaction and isolation testing ([thesimplekid]). +- cdk-sql-common: Database migration to add `kv_store` table for generic key-value storage in SQLite and PostgreSQL ([thesimplekid]). +- cdk-sql-common: Implementation of `MintKVStoreDatabase` trait for SQL-based databases with namespace support ([thesimplekid]). ### Changed - cdk-common: Refactored `MintPayment` trait method `wait_any_incoming_payment` to `wait_payment_event` with event-driven architecture ([thesimplekid]). diff --git a/crates/cashu/src/nuts/nut01/public_key.rs b/crates/cashu/src/nuts/nut01/public_key.rs index ab6b862e..7e0023d8 100644 --- a/crates/cashu/src/nuts/nut01/public_key.rs +++ b/crates/cashu/src/nuts/nut01/public_key.rs @@ -142,19 +142,17 @@ mod tests { #[test] pub fn test_public_key_from_hex() { // Compressed - assert!( - (PublicKey::from_hex( - "02194603ffa36356f4a56b7df9371fc3192472351453ec7398b8da8117e7c3e104" - ) - .is_ok()) - ); + assert!(PublicKey::from_hex( + "02194603ffa36356f4a56b7df9371fc3192472351453ec7398b8da8117e7c3e104" + ) + .is_ok()); } #[test] pub fn test_invalid_public_key_from_hex() { // Uncompressed (is valid but is cashu must be compressed?) - assert!((PublicKey::from_hex("04fd4ce5a16b65576145949e6f99f445f8249fee17c606b688b504a849cdc452de3625246cb2c27dac965cb7200a5986467eee92eb7d496bbf1453b074e223e481") - .is_err())) + assert!(PublicKey::from_hex("04fd4ce5a16b65576145949e6f99f445f8249fee17c606b688b504a849cdc452de3625246cb2c27dac965cb7200a5986467eee92eb7d496bbf1453b074e223e481") + .is_err()) } } diff --git a/crates/cdk-common/src/database/mint/mod.rs b/crates/cdk-common/src/database/mint/mod.rs index a48d29c3..3451e6b5 100644 --- a/crates/cdk-common/src/database/mint/mod.rs +++ b/crates/cdk-common/src/database/mint/mod.rs @@ -2,6 +2,66 @@ use std::collections::HashMap; +/// Valid ASCII characters for namespace and key strings in KV store +pub const KVSTORE_NAMESPACE_KEY_ALPHABET: &str = + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-"; + +/// Maximum length for namespace and key strings in KV store +pub const KVSTORE_NAMESPACE_KEY_MAX_LEN: usize = 120; + +/// Validates that a string contains only valid KV store characters and is within length limits +pub fn validate_kvstore_string(s: &str) -> Result<(), Error> { + if s.len() > KVSTORE_NAMESPACE_KEY_MAX_LEN { + return Err(Error::KVStoreInvalidKey(format!( + "{} exceeds maximum length of key characters", + KVSTORE_NAMESPACE_KEY_MAX_LEN + ))); + } + + if !s + .chars() + .all(|c| KVSTORE_NAMESPACE_KEY_ALPHABET.contains(c)) + { + return Err(Error::KVStoreInvalidKey("key contains invalid characters. Only ASCII letters, numbers, underscore, and hyphen are allowed".to_string())); + } + + Ok(()) +} + +/// Validates namespace and key parameters for KV store operations +pub fn validate_kvstore_params( + primary_namespace: &str, + secondary_namespace: &str, + key: &str, +) -> Result<(), Error> { + // Validate primary namespace + validate_kvstore_string(primary_namespace)?; + + // Validate secondary namespace + validate_kvstore_string(secondary_namespace)?; + + // Validate key + validate_kvstore_string(key)?; + + // Check empty namespace rules + if primary_namespace.is_empty() && !secondary_namespace.is_empty() { + return Err(Error::KVStoreInvalidKey( + "If primary_namespace is empty, secondary_namespace must also be empty".to_string(), + )); + } + + // Check for potential collisions between keys and namespaces in the same namespace + let namespace_key = format!("{}/{}", primary_namespace, secondary_namespace); + if key == primary_namespace || key == secondary_namespace || key == namespace_key { + return Err(Error::KVStoreInvalidKey(format!( + "Key '{}' conflicts with namespace names", + key + ))); + } + + Ok(()) +} + use async_trait::async_trait; use cashu::quote_id::QuoteId; use cashu::{Amount, MintInfo}; @@ -20,6 +80,9 @@ mod auth; #[cfg(feature = "test")] pub mod test; +#[cfg(test)] +mod test_kvstore; + #[cfg(feature = "auth")] pub use auth::{MintAuthDatabase, MintAuthTransaction}; @@ -263,6 +326,42 @@ pub trait DbTransactionFinalizer { async fn rollback(self: Box) -> Result<(), Self::Err>; } +/// Key-Value Store Transaction trait +#[async_trait] +pub trait KVStoreTransaction<'a, Error>: DbTransactionFinalizer { + /// Read value from key-value store + async fn kv_read( + &mut self, + primary_namespace: &str, + secondary_namespace: &str, + key: &str, + ) -> Result>, Error>; + + /// Write value to key-value store + async fn kv_write( + &mut self, + primary_namespace: &str, + secondary_namespace: &str, + key: &str, + value: &[u8], + ) -> Result<(), Error>; + + /// Remove value from key-value store + async fn kv_remove( + &mut self, + primary_namespace: &str, + secondary_namespace: &str, + key: &str, + ) -> Result<(), Error>; + + /// List keys in a namespace + async fn kv_list( + &mut self, + primary_namespace: &str, + secondary_namespace: &str, + ) -> Result, Error>; +} + /// Base database writer #[async_trait] pub trait Transaction<'a, Error>: @@ -270,6 +369,7 @@ pub trait Transaction<'a, Error>: + QuotesTransaction<'a, Err = Error> + SignaturesTransaction<'a, Err = Error> + ProofsTransaction<'a, Err = Error> + + KVStoreTransaction<'a, Error> { /// Set [`QuoteTTL`] async fn set_quote_ttl(&mut self, quote_ttl: QuoteTTL) -> Result<(), Error>; @@ -278,10 +378,44 @@ pub trait Transaction<'a, Error>: async fn set_mint_info(&mut self, mint_info: MintInfo) -> Result<(), Error>; } +/// Key-Value Store Database trait +#[async_trait] +pub trait KVStoreDatabase { + /// KV Store Database Error + type Err: Into + From; + + /// Read value from key-value store + async fn kv_read( + &self, + primary_namespace: &str, + secondary_namespace: &str, + key: &str, + ) -> Result>, Self::Err>; + + /// List keys in a namespace + async fn kv_list( + &self, + primary_namespace: &str, + secondary_namespace: &str, + ) -> Result, Self::Err>; +} + +/// Key-Value Store Database trait +#[async_trait] +pub trait KVStore: KVStoreDatabase { + /// Beings a KV transaction + async fn begin_transaction<'a>( + &'a self, + ) -> Result + Send + Sync + 'a>, Error>; +} + /// Mint Database trait #[async_trait] pub trait Database: - QuotesDatabase + ProofsDatabase + SignaturesDatabase + KVStoreDatabase + + QuotesDatabase + + ProofsDatabase + + SignaturesDatabase { /// Beings a transaction async fn begin_transaction<'a>( diff --git a/crates/cdk-common/src/database/mint/test.rs b/crates/cdk-common/src/database/mint/test.rs index e7455fd2..de401104 100644 --- a/crates/cdk-common/src/database/mint/test.rs +++ b/crates/cdk-common/src/database/mint/test.rs @@ -4,17 +4,19 @@ //! implementation use std::str::FromStr; +// For derivation path parsing +use bitcoin::bip32::DerivationPath; use cashu::secret::Secret; use cashu::{Amount, CurrencyUnit, SecretKey}; use super::*; -use crate::database; +use crate::database::MintKVStoreDatabase; use crate::mint::MintKeySetInfo; #[inline] async fn setup_keyset(db: &DB) -> Id where - DB: KeysDatabase, + DB: KeysDatabase, { let keyset_id = Id::from_str("00916bbf7ef91a36").unwrap(); let keyset_info = MintKeySetInfo { @@ -23,7 +25,7 @@ where active: true, valid_from: 0, final_expiry: None, - derivation_path: bitcoin::bip32::DerivationPath::from_str("m/0'/0'/0'").unwrap(), + derivation_path: DerivationPath::from_str("m/0'/0'/0'").unwrap(), derivation_path_index: Some(0), max_order: 32, input_fee_ppk: 0, @@ -37,7 +39,7 @@ where /// State transition test pub async fn state_transition(db: DB) where - DB: Database + KeysDatabase, + DB: Database + KeysDatabase, { let keyset_id = setup_keyset(&db).await; @@ -83,7 +85,7 @@ where /// other tests pub async fn add_and_find_proofs(db: DB) where - DB: Database + KeysDatabase, + DB: Database + KeysDatabase, { let keyset_id = setup_keyset(&db).await; @@ -124,12 +126,99 @@ where assert_eq!(proofs_from_db.unwrap().len(), 2); } +/// Test KV store functionality including write, read, list, update, and remove operations +pub async fn kvstore_functionality(db: DB) +where + DB: Database + MintKVStoreDatabase, +{ + // Test basic read/write operations in transaction + { + let mut tx = Database::begin_transaction(&db).await.unwrap(); + + // Write some test data + tx.kv_write("test_namespace", "sub_namespace", "key1", b"value1") + .await + .unwrap(); + tx.kv_write("test_namespace", "sub_namespace", "key2", b"value2") + .await + .unwrap(); + tx.kv_write("test_namespace", "other_sub", "key3", b"value3") + .await + .unwrap(); + + // Read back the data in the transaction + let value1 = tx + .kv_read("test_namespace", "sub_namespace", "key1") + .await + .unwrap(); + assert_eq!(value1, Some(b"value1".to_vec())); + + // List keys in namespace + let keys = tx.kv_list("test_namespace", "sub_namespace").await.unwrap(); + assert_eq!(keys, vec!["key1", "key2"]); + + // Commit transaction + tx.commit().await.unwrap(); + } + + // Test read operations after commit + { + let value1 = db + .kv_read("test_namespace", "sub_namespace", "key1") + .await + .unwrap(); + assert_eq!(value1, Some(b"value1".to_vec())); + + let keys = db.kv_list("test_namespace", "sub_namespace").await.unwrap(); + assert_eq!(keys, vec!["key1", "key2"]); + + let other_keys = db.kv_list("test_namespace", "other_sub").await.unwrap(); + assert_eq!(other_keys, vec!["key3"]); + } + + // Test update and remove operations + { + let mut tx = Database::begin_transaction(&db).await.unwrap(); + + // Update existing key + tx.kv_write("test_namespace", "sub_namespace", "key1", b"updated_value1") + .await + .unwrap(); + + // Remove a key + tx.kv_remove("test_namespace", "sub_namespace", "key2") + .await + .unwrap(); + + tx.commit().await.unwrap(); + } + + // Verify updates + { + let value1 = db + .kv_read("test_namespace", "sub_namespace", "key1") + .await + .unwrap(); + assert_eq!(value1, Some(b"updated_value1".to_vec())); + + let value2 = db + .kv_read("test_namespace", "sub_namespace", "key2") + .await + .unwrap(); + assert_eq!(value2, None); + + let keys = db.kv_list("test_namespace", "sub_namespace").await.unwrap(); + assert_eq!(keys, vec!["key1"]); + } +} + /// Unit test that is expected to be passed for a correct database implementation #[macro_export] macro_rules! mint_db_test { ($make_db_fn:ident) => { mint_db_test!(state_transition, $make_db_fn); mint_db_test!(add_and_find_proofs, $make_db_fn); + mint_db_test!(kvstore_functionality, $make_db_fn); }; ($name:ident, $make_db_fn:ident) => { #[tokio::test] diff --git a/crates/cdk-common/src/database/mint/test_kvstore.rs b/crates/cdk-common/src/database/mint/test_kvstore.rs new file mode 100644 index 00000000..6c516d91 --- /dev/null +++ b/crates/cdk-common/src/database/mint/test_kvstore.rs @@ -0,0 +1,207 @@ +//! Tests for KV store validation requirements + +#[cfg(test)] +mod tests { + use crate::database::mint::{ + validate_kvstore_params, validate_kvstore_string, KVSTORE_NAMESPACE_KEY_ALPHABET, + KVSTORE_NAMESPACE_KEY_MAX_LEN, + }; + + #[test] + fn test_validate_kvstore_string_valid_inputs() { + // Test valid strings + assert!(validate_kvstore_string("").is_ok()); + assert!(validate_kvstore_string("abc").is_ok()); + assert!(validate_kvstore_string("ABC").is_ok()); + assert!(validate_kvstore_string("123").is_ok()); + assert!(validate_kvstore_string("test_key").is_ok()); + assert!(validate_kvstore_string("test-key").is_ok()); + assert!(validate_kvstore_string("test_KEY-123").is_ok()); + + // Test max length string + let max_length_str = "a".repeat(KVSTORE_NAMESPACE_KEY_MAX_LEN); + assert!(validate_kvstore_string(&max_length_str).is_ok()); + } + + #[test] + fn test_validate_kvstore_string_invalid_length() { + // Test string too long + let too_long_str = "a".repeat(KVSTORE_NAMESPACE_KEY_MAX_LEN + 1); + let result = validate_kvstore_string(&too_long_str); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("exceeds maximum length")); + } + + #[test] + fn test_validate_kvstore_string_invalid_characters() { + // Test invalid characters + let invalid_chars = vec![ + "test@key", // @ + "test key", // space + "test.key", // . + "test/key", // / + "test\\key", // \ + "test+key", // + + "test=key", // = + "test!key", // ! + "test#key", // # + "test$key", // $ + "test%key", // % + "test&key", // & + "test*key", // * + "test(key", // ( + "test)key", // ) + "test[key", // [ + "test]key", // ] + "test{key", // { + "test}key", // } + "test|key", // | + "test;key", // ; + "test:key", // : + "test'key", // ' + "test\"key", // " + "testkey", // > + "test,key", // , + "test?key", // ? + "test~key", // ~ + "test`key", // ` + ]; + + for invalid_str in invalid_chars { + let result = validate_kvstore_string(invalid_str); + assert!(result.is_err(), "Expected '{}' to be invalid", invalid_str); + assert!(result + .unwrap_err() + .to_string() + .contains("invalid characters")); + } + } + + #[test] + fn test_validate_kvstore_params_valid() { + // Test valid parameter combinations + assert!(validate_kvstore_params("primary", "secondary", "key").is_ok()); + assert!(validate_kvstore_params("primary", "", "key").is_ok()); + assert!(validate_kvstore_params("", "", "key").is_ok()); + assert!(validate_kvstore_params("p1", "s1", "different_key").is_ok()); + } + + #[test] + fn test_validate_kvstore_params_empty_namespace_rules() { + // Test empty namespace rules: if primary is empty, secondary must be empty too + let result = validate_kvstore_params("", "secondary", "key"); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("If primary_namespace is empty")); + } + + #[test] + fn test_validate_kvstore_params_collision_prevention() { + // Test collision prevention between keys and namespaces + let test_cases = vec![ + ("primary", "secondary", "primary"), // key matches primary namespace + ("primary", "secondary", "secondary"), // key matches secondary namespace + ]; + + for (primary, secondary, key) in test_cases { + let result = validate_kvstore_params(primary, secondary, key); + assert!( + result.is_err(), + "Expected collision for key '{}' with namespaces '{}'/'{}'", + key, + primary, + secondary + ); + let error_msg = result.unwrap_err().to_string(); + assert!(error_msg.contains("conflicts with namespace")); + } + + // Test that a combined namespace string would be invalid due to the slash character + let result = validate_kvstore_params("primary", "secondary", "primary_secondary"); + assert!(result.is_ok(), "This should be valid - no actual collision"); + } + + #[test] + fn test_validate_kvstore_params_invalid_strings() { + // Test invalid characters in any parameter + let result = validate_kvstore_params("primary@", "secondary", "key"); + assert!(result.is_err()); + + let result = validate_kvstore_params("primary", "secondary!", "key"); + assert!(result.is_err()); + + let result = validate_kvstore_params("primary", "secondary", "key with space"); + assert!(result.is_err()); + } + + #[test] + fn test_alphabet_constants() { + // Verify the alphabet constant is as expected + assert_eq!( + KVSTORE_NAMESPACE_KEY_ALPHABET, + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-" + ); + assert_eq!(KVSTORE_NAMESPACE_KEY_MAX_LEN, 120); + } + + #[test] + fn test_alphabet_coverage() { + // Test that all valid characters are actually accepted + for ch in KVSTORE_NAMESPACE_KEY_ALPHABET.chars() { + let test_str = ch.to_string(); + assert!( + validate_kvstore_string(&test_str).is_ok(), + "Character '{}' should be valid", + ch + ); + } + } + + #[test] + fn test_namespace_segmentation_examples() { + // Test realistic namespace segmentation scenarios + + // Valid segmentation examples + let valid_examples = vec![ + ("wallets", "user123", "balance"), + ("quotes", "mint", "quote_12345"), + ("keysets", "", "active_keyset"), + ("", "", "global_config"), + ("auth", "session_456", "token"), + ("mint_info", "", "version"), + ]; + + for (primary, secondary, key) in valid_examples { + assert!( + validate_kvstore_params(primary, secondary, key).is_ok(), + "Valid example should pass: '{}'/'{}'/'{}'", + primary, + secondary, + key + ); + } + } + + #[test] + fn test_per_namespace_uniqueness() { + // This test documents the requirement that implementations should ensure + // per-namespace key uniqueness. The validation function doesn't enforce + // database-level uniqueness (that's handled by the database schema), + // but ensures naming conflicts don't occur between keys and namespaces. + + // These should be valid (different namespaces) + assert!(validate_kvstore_params("ns1", "sub1", "key1").is_ok()); + assert!(validate_kvstore_params("ns2", "sub1", "key1").is_ok()); // same key, different primary namespace + assert!(validate_kvstore_params("ns1", "sub2", "key1").is_ok()); // same key, different secondary namespace + + // These should fail (collision within namespace) + assert!(validate_kvstore_params("ns1", "sub1", "ns1").is_err()); // key conflicts with primary namespace + assert!(validate_kvstore_params("ns1", "sub1", "sub1").is_err()); // key conflicts with secondary namespace + } +} diff --git a/crates/cdk-common/src/database/mod.rs b/crates/cdk-common/src/database/mod.rs index 51b20118..6abcfe28 100644 --- a/crates/cdk-common/src/database/mod.rs +++ b/crates/cdk-common/src/database/mod.rs @@ -8,10 +8,11 @@ mod wallet; #[cfg(feature = "mint")] pub use mint::{ Database as MintDatabase, DbTransactionFinalizer as MintDbWriterFinalizer, - KeysDatabase as MintKeysDatabase, KeysDatabaseTransaction as MintKeyDatabaseTransaction, - ProofsDatabase as MintProofsDatabase, ProofsTransaction as MintProofsTransaction, - QuotesDatabase as MintQuotesDatabase, QuotesTransaction as MintQuotesTransaction, - SignaturesDatabase as MintSignaturesDatabase, + KVStore as MintKVStore, KVStoreDatabase as MintKVStoreDatabase, + KVStoreTransaction as MintKVStoreTransaction, KeysDatabase as MintKeysDatabase, + KeysDatabaseTransaction as MintKeyDatabaseTransaction, ProofsDatabase as MintProofsDatabase, + ProofsTransaction as MintProofsTransaction, QuotesDatabase as MintQuotesDatabase, + QuotesTransaction as MintQuotesTransaction, SignaturesDatabase as MintSignaturesDatabase, SignaturesTransaction as MintSignatureTransaction, Transaction as MintTransaction, }; #[cfg(all(feature = "mint", feature = "auth"))] @@ -187,6 +188,10 @@ pub enum Error { /// QuoteNotFound #[error("Quote not found")] QuoteNotFound, + + /// KV Store invalid key or namespace + #[error("Invalid KV store key or namespace: {0}")] + KVStoreInvalidKey(String), } #[cfg(feature = "mint")] diff --git a/crates/cdk-common/src/error.rs b/crates/cdk-common/src/error.rs index b4dc1014..0b782f99 100644 --- a/crates/cdk-common/src/error.rs +++ b/crates/cdk-common/src/error.rs @@ -271,6 +271,9 @@ pub enum Error { /// Transaction not found #[error("Transaction not found")] TransactionNotFound, + /// KV Store invalid key or namespace + #[error("Invalid KV store key or namespace: {0}")] + KVStoreInvalidKey(String), /// Custom Error #[error("`{0}`")] Custom(String), diff --git a/crates/cdk-mintd/src/lib.rs b/crates/cdk-mintd/src/lib.rs index 1df62599..ec6df6f8 100644 --- a/crates/cdk-mintd/src/lib.rs +++ b/crates/cdk-mintd/src/lib.rs @@ -14,7 +14,7 @@ use anyhow::{anyhow, bail, Result}; use axum::Router; use bip39::Mnemonic; // internal crate modules -use cdk::cdk_database::{self, MintDatabase, MintKeysDatabase}; +use cdk::cdk_database::{self, MintDatabase, MintKVStore, MintKeysDatabase}; use cdk::cdk_payment; use cdk::cdk_payment::MintPayment; use cdk::mint::{Mint, MintBuilder, MintMeltLimits}; @@ -94,9 +94,10 @@ async fn initial_setup( ) -> Result<( Arc + Send + Sync>, Arc + Send + Sync>, + Arc + Send + Sync>, )> { - let (localstore, keystore) = setup_database(settings, work_dir, db_password).await?; - Ok((localstore, keystore)) + let (localstore, keystore, kv) = setup_database(settings, work_dir, db_password).await?; + Ok((localstore, keystore, kv)) } /// Sets up and initializes a tracing subscriber with custom log filtering. @@ -253,14 +254,16 @@ async fn setup_database( ) -> Result<( Arc + Send + Sync>, Arc + Send + Sync>, + Arc + Send + Sync>, )> { match settings.database.engine { #[cfg(feature = "sqlite")] DatabaseEngine::Sqlite => { let db = setup_sqlite_database(_work_dir, _db_password).await?; let localstore: Arc + Send + Sync> = db.clone(); + let kv: Arc + Send + Sync> = db.clone(); let keystore: Arc + Send + Sync> = db; - Ok((localstore, keystore)) + Ok((localstore, keystore, kv)) } #[cfg(feature = "postgres")] DatabaseEngine::Postgres => { @@ -279,11 +282,13 @@ async fn setup_database( let localstore: Arc + Send + Sync> = pg_db.clone(); #[cfg(feature = "postgres")] + let kv: Arc + Send + Sync> = pg_db.clone(); + #[cfg(feature = "postgres")] let keystore: Arc< dyn MintKeysDatabase + Send + Sync, > = pg_db; #[cfg(feature = "postgres")] - return Ok((localstore, keystore)); + return Ok((localstore, keystore, kv)); #[cfg(not(feature = "postgres"))] bail!("PostgreSQL support not compiled in. Enable the 'postgres' feature to use PostgreSQL database.") @@ -326,6 +331,7 @@ async fn configure_mint_builder( mint_builder: MintBuilder, runtime: Option>, work_dir: &Path, + kv_store: Option + Send + Sync>>, ) -> Result<(MintBuilder, Vec)> { let mut ln_routers = vec![]; @@ -333,9 +339,15 @@ async fn configure_mint_builder( let mint_builder = configure_basic_info(settings, mint_builder); // Configure lightning backend - let mint_builder = - configure_lightning_backend(settings, mint_builder, &mut ln_routers, runtime, work_dir) - .await?; + let mint_builder = configure_lightning_backend( + settings, + mint_builder, + &mut ln_routers, + runtime, + work_dir, + kv_store, + ) + .await?; // Configure caching let mint_builder = configure_cache(settings, mint_builder); @@ -400,6 +412,7 @@ async fn configure_lightning_backend( ln_routers: &mut Vec, _runtime: Option>, work_dir: &Path, + _kv_store: Option + Send + Sync>>, ) -> Result { let mint_melt_limits = MintMeltLimits { mint_min: settings.ln.min_mint, @@ -418,7 +431,14 @@ async fn configure_lightning_backend( .clone() .expect("Config checked at load that cln is some"); let cln = cln_settings - .setup(ln_routers, settings, CurrencyUnit::Msat, None, work_dir) + .setup( + ln_routers, + settings, + CurrencyUnit::Msat, + None, + work_dir, + None, + ) .await?; mint_builder = configure_backend_for_unit( @@ -434,7 +454,14 @@ async fn configure_lightning_backend( LnBackend::LNbits => { let lnbits_settings = settings.clone().lnbits.expect("Checked on config load"); let lnbits = lnbits_settings - .setup(ln_routers, settings, CurrencyUnit::Sat, None, work_dir) + .setup( + ln_routers, + settings, + CurrencyUnit::Sat, + None, + work_dir, + None, + ) .await?; mint_builder = configure_backend_for_unit( @@ -450,7 +477,14 @@ async fn configure_lightning_backend( LnBackend::Lnd => { let lnd_settings = settings.clone().lnd.expect("Checked at config load"); let lnd = lnd_settings - .setup(ln_routers, settings, CurrencyUnit::Msat, None, work_dir) + .setup( + ln_routers, + settings, + CurrencyUnit::Msat, + None, + work_dir, + None, + ) .await?; mint_builder = configure_backend_for_unit( @@ -469,7 +503,14 @@ async fn configure_lightning_backend( for unit in fake_wallet.clone().supported_units { let fake = fake_wallet - .setup(ln_routers, settings, unit.clone(), None, work_dir) + .setup( + ln_routers, + settings, + unit.clone(), + None, + work_dir, + _kv_store.clone(), + ) .await?; mint_builder = configure_backend_for_unit( @@ -498,7 +539,7 @@ async fn configure_lightning_backend( for unit in grpc_processor.clone().supported_units { tracing::debug!("Adding unit: {:?}", unit); let processor = grpc_processor - .setup(ln_routers, settings, unit.clone(), None, work_dir) + .setup(ln_routers, settings, unit.clone(), None, work_dir, None) .await?; mint_builder = configure_backend_for_unit( @@ -517,7 +558,14 @@ async fn configure_lightning_backend( tracing::info!("Using LDK Node backend: {:?}", ldk_node_settings); let ldk_node = ldk_node_settings - .setup(ln_routers, settings, CurrencyUnit::Sat, _runtime, work_dir) + .setup( + ln_routers, + settings, + CurrencyUnit::Sat, + _runtime, + work_dir, + None, + ) .await?; mint_builder = configure_backend_for_unit( @@ -1015,12 +1063,12 @@ pub async fn run_mintd_with_shutdown( db_password: Option, runtime: Option>, ) -> Result<()> { - let (localstore, keystore) = initial_setup(work_dir, settings, db_password.clone()).await?; + let (localstore, keystore, kv) = initial_setup(work_dir, settings, db_password.clone()).await?; let mint_builder = MintBuilder::new(localstore); let (mint_builder, ln_routers) = - configure_mint_builder(settings, mint_builder, runtime, work_dir).await?; + configure_mint_builder(settings, mint_builder, runtime, work_dir, Some(kv)).await?; #[cfg(feature = "auth")] let mint_builder = setup_authentication(settings, work_dir, mint_builder, db_password).await?; diff --git a/crates/cdk-mintd/src/setup.rs b/crates/cdk-mintd/src/setup.rs index ec03fd33..dfaeddf8 100644 --- a/crates/cdk-mintd/src/setup.rs +++ b/crates/cdk-mintd/src/setup.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; #[cfg(feature = "fakewallet")] use std::collections::HashSet; use std::path::Path; +use std::sync::Arc; #[cfg(feature = "cln")] use anyhow::anyhow; @@ -10,6 +11,7 @@ use async_trait::async_trait; use axum::Router; #[cfg(feature = "fakewallet")] use bip39::rand::{thread_rng, Rng}; +use cdk::cdk_database::MintKVStore; use cdk::cdk_payment::MintPayment; use cdk::nuts::CurrencyUnit; #[cfg(any( @@ -34,6 +36,7 @@ pub trait LnBackendSetup { unit: CurrencyUnit, runtime: Option>, work_dir: &Path, + kv_store: Option + Send + Sync>>, ) -> anyhow::Result; } @@ -47,6 +50,7 @@ impl LnBackendSetup for config::Cln { _unit: CurrencyUnit, _runtime: Option>, _work_dir: &Path, + _kv_store: Option + Send + Sync>>, ) -> anyhow::Result { let cln_socket = expand_path( self.rpc_path @@ -76,6 +80,7 @@ impl LnBackendSetup for config::LNbits { _unit: CurrencyUnit, _runtime: Option>, _work_dir: &Path, + _kv_store: Option + Send + Sync>>, ) -> anyhow::Result { let admin_api_key = &self.admin_api_key; let invoice_api_key = &self.invoice_api_key; @@ -110,6 +115,7 @@ impl LnBackendSetup for config::Lnd { _unit: CurrencyUnit, _runtime: Option>, _work_dir: &Path, + _kv_store: Option + Send + Sync>>, ) -> anyhow::Result { let address = &self.address; let cert_file = &self.cert_file; @@ -142,6 +148,7 @@ impl LnBackendSetup for config::FakeWallet { unit: CurrencyUnit, _runtime: Option>, _work_dir: &Path, + _kv_store: Option + Send + Sync>>, ) -> anyhow::Result { let fee_reserve = FeeReserve { min_fee_reserve: self.reserve_fee_min, @@ -174,6 +181,7 @@ impl LnBackendSetup for config::GrpcProcessor { _unit: CurrencyUnit, _runtime: Option>, _work_dir: &Path, + _kv_store: Option + Send + Sync>>, ) -> anyhow::Result { let payment_processor = cdk_payment_processor::PaymentProcessorClient::new( &self.addr, @@ -196,6 +204,7 @@ impl LnBackendSetup for config::LdkNode { _unit: CurrencyUnit, runtime: Option>, work_dir: &Path, + _kv_store: Option + Send + Sync>>, ) -> anyhow::Result { use std::net::SocketAddr; diff --git a/crates/cdk-payment-processor/src/bin/payment_processor.rs b/crates/cdk-payment-processor/src/bin/payment_processor.rs index 1381198c..d110583d 100644 --- a/crates/cdk-payment-processor/src/bin/payment_processor.rs +++ b/crates/cdk-payment-processor/src/bin/payment_processor.rs @@ -1,5 +1,5 @@ #[cfg(feature = "fake")] -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; use std::env; use std::path::PathBuf; #[cfg(any(feature = "cln", feature = "lnd", feature = "fake"))] @@ -110,6 +110,9 @@ async fn main() -> anyhow::Result<()> { } #[cfg(feature = "fake")] "FAKEWALLET" => { + use std::collections::HashMap; + use std::sync::Arc; + let fee_reserve = FeeReserve { min_fee_reserve: 1.into(), percent_fee_reserve: 0.0, diff --git a/crates/cdk-sql-common/src/mint/migrations.rs b/crates/cdk-sql-common/src/mint/migrations.rs index ed14f171..0f4a9429 100644 --- a/crates/cdk-sql-common/src/mint/migrations.rs +++ b/crates/cdk-sql-common/src/mint/migrations.rs @@ -3,6 +3,7 @@ pub static MIGRATIONS: &[(&str, &str, &str)] = &[ ("postgres", "1_initial.sql", include_str!(r#"./migrations/postgres/1_initial.sql"#)), ("postgres", "2_remove_request_lookup_kind_constraints.sql", include_str!(r#"./migrations/postgres/2_remove_request_lookup_kind_constraints.sql"#)), + ("postgres", "3_add_kv_store.sql", include_str!(r#"./migrations/postgres/3_add_kv_store.sql"#)), ("sqlite", "1_fix_sqlx_migration.sql", include_str!(r#"./migrations/sqlite/1_fix_sqlx_migration.sql"#)), ("sqlite", "20240612124932_init.sql", include_str!(r#"./migrations/sqlite/20240612124932_init.sql"#)), ("sqlite", "20240618195700_quote_state.sql", include_str!(r#"./migrations/sqlite/20240618195700_quote_state.sql"#)), @@ -27,4 +28,5 @@ pub static MIGRATIONS: &[(&str, &str, &str)] = &[ ("sqlite", "20250706101057_bolt12.sql", include_str!(r#"./migrations/sqlite/20250706101057_bolt12.sql"#)), ("sqlite", "20250812132015_drop_melt_request.sql", include_str!(r#"./migrations/sqlite/20250812132015_drop_melt_request.sql"#)), ("sqlite", "20250819200000_remove_request_lookup_kind_constraints.sql", include_str!(r#"./migrations/sqlite/20250819200000_remove_request_lookup_kind_constraints.sql"#)), + ("sqlite", "20250901090000_add_kv_store.sql", include_str!(r#"./migrations/sqlite/20250901090000_add_kv_store.sql"#)), ]; diff --git a/crates/cdk-sql-common/src/mint/migrations/postgres/3_add_kv_store.sql b/crates/cdk-sql-common/src/mint/migrations/postgres/3_add_kv_store.sql new file mode 100644 index 00000000..a46ef9f2 --- /dev/null +++ b/crates/cdk-sql-common/src/mint/migrations/postgres/3_add_kv_store.sql @@ -0,0 +1,18 @@ +-- Add kv_store table for generic key-value storage +CREATE TABLE IF NOT EXISTS kv_store ( + primary_namespace TEXT NOT NULL, + secondary_namespace TEXT NOT NULL, + key TEXT NOT NULL, + value BYTEA NOT NULL, + created_time BIGINT NOT NULL, + updated_time BIGINT NOT NULL, + PRIMARY KEY (primary_namespace, secondary_namespace, key) +); + +-- Index for efficient listing of keys by namespace +CREATE INDEX IF NOT EXISTS idx_kv_store_namespaces +ON kv_store (primary_namespace, secondary_namespace); + +-- Index for efficient querying by update time +CREATE INDEX IF NOT EXISTS idx_kv_store_updated_time +ON kv_store (updated_time); diff --git a/crates/cdk-sql-common/src/mint/migrations/sqlite/20250901090000_add_kv_store.sql b/crates/cdk-sql-common/src/mint/migrations/sqlite/20250901090000_add_kv_store.sql new file mode 100644 index 00000000..f073826c --- /dev/null +++ b/crates/cdk-sql-common/src/mint/migrations/sqlite/20250901090000_add_kv_store.sql @@ -0,0 +1,18 @@ +-- Add kv_store table for generic key-value storage +CREATE TABLE IF NOT EXISTS kv_store ( + primary_namespace TEXT NOT NULL, + secondary_namespace TEXT NOT NULL, + key TEXT NOT NULL, + value BLOB NOT NULL, + created_time INTEGER NOT NULL, + updated_time INTEGER NOT NULL, + PRIMARY KEY (primary_namespace, secondary_namespace, key) +); + +-- Index for efficient listing of keys by namespace +CREATE INDEX IF NOT EXISTS idx_kv_store_namespaces +ON kv_store (primary_namespace, secondary_namespace); + +-- Index for efficient querying by update time +CREATE INDEX IF NOT EXISTS idx_kv_store_updated_time +ON kv_store (updated_time); diff --git a/crates/cdk-sql-common/src/mint/mod.rs b/crates/cdk-sql-common/src/mint/mod.rs index ec7ae447..8e1fcdc3 100644 --- a/crates/cdk-sql-common/src/mint/mod.rs +++ b/crates/cdk-sql-common/src/mint/mod.rs @@ -16,6 +16,7 @@ use std::sync::Arc; use async_trait::async_trait; use bitcoin::bip32::DerivationPath; use cdk_common::common::QuoteTTL; +use cdk_common::database::mint::validate_kvstore_params; use cdk_common::database::{ self, ConversionError, Error, MintDatabase, MintDbWriterFinalizer, MintKeyDatabaseTransaction, MintKeysDatabase, MintProofsDatabase, MintQuotesDatabase, MintQuotesTransaction, @@ -1582,6 +1583,224 @@ where } } +#[async_trait] +impl database::MintKVStoreTransaction<'_, Error> for SQLTransaction +where + RM: DatabasePool + 'static, +{ + async fn kv_read( + &mut self, + primary_namespace: &str, + secondary_namespace: &str, + key: &str, + ) -> Result>, Error> { + // Validate parameters according to KV store requirements + validate_kvstore_params(primary_namespace, secondary_namespace, key)?; + Ok(query( + r#" + SELECT value + FROM kv_store + WHERE primary_namespace = :primary_namespace + AND secondary_namespace = :secondary_namespace + AND key = :key + "#, + )? + .bind("primary_namespace", primary_namespace.to_owned()) + .bind("secondary_namespace", secondary_namespace.to_owned()) + .bind("key", key.to_owned()) + .pluck(&self.inner) + .await? + .and_then(|col| match col { + Column::Blob(data) => Some(data), + _ => None, + })) + } + + async fn kv_write( + &mut self, + primary_namespace: &str, + secondary_namespace: &str, + key: &str, + value: &[u8], + ) -> Result<(), Error> { + // Validate parameters according to KV store requirements + validate_kvstore_params(primary_namespace, secondary_namespace, key)?; + + let current_time = unix_time(); + + query( + r#" + INSERT INTO kv_store + (primary_namespace, secondary_namespace, key, value, created_time, updated_time) + VALUES (:primary_namespace, :secondary_namespace, :key, :value, :created_time, :updated_time) + ON CONFLICT(primary_namespace, secondary_namespace, key) + DO UPDATE SET + value = excluded.value, + updated_time = excluded.updated_time + "#, + )? + .bind("primary_namespace", primary_namespace.to_owned()) + .bind("secondary_namespace", secondary_namespace.to_owned()) + .bind("key", key.to_owned()) + .bind("value", value.to_vec()) + .bind("created_time", current_time as i64) + .bind("updated_time", current_time as i64) + .execute(&self.inner) + .await?; + + Ok(()) + } + + async fn kv_remove( + &mut self, + primary_namespace: &str, + secondary_namespace: &str, + key: &str, + ) -> Result<(), Error> { + // Validate parameters according to KV store requirements + validate_kvstore_params(primary_namespace, secondary_namespace, key)?; + query( + r#" + DELETE FROM kv_store + WHERE primary_namespace = :primary_namespace + AND secondary_namespace = :secondary_namespace + AND key = :key + "#, + )? + .bind("primary_namespace", primary_namespace.to_owned()) + .bind("secondary_namespace", secondary_namespace.to_owned()) + .bind("key", key.to_owned()) + .execute(&self.inner) + .await?; + + Ok(()) + } + + async fn kv_list( + &mut self, + primary_namespace: &str, + secondary_namespace: &str, + ) -> Result, Error> { + // Validate namespace parameters according to KV store requirements + cdk_common::database::mint::validate_kvstore_string(primary_namespace)?; + cdk_common::database::mint::validate_kvstore_string(secondary_namespace)?; + + // Check empty namespace rules + if primary_namespace.is_empty() && !secondary_namespace.is_empty() { + return Err(Error::KVStoreInvalidKey( + "If primary_namespace is empty, secondary_namespace must also be empty".to_string(), + )); + } + Ok(query( + r#" + SELECT key + FROM kv_store + WHERE primary_namespace = :primary_namespace + AND secondary_namespace = :secondary_namespace + ORDER BY key + "#, + )? + .bind("primary_namespace", primary_namespace.to_owned()) + .bind("secondary_namespace", secondary_namespace.to_owned()) + .fetch_all(&self.inner) + .await? + .into_iter() + .map(|row| Ok(column_as_string!(&row[0]))) + .collect::, Error>>()?) + } +} + +#[async_trait] +impl database::MintKVStoreDatabase for SQLMintDatabase +where + RM: DatabasePool + 'static, +{ + type Err = Error; + + async fn kv_read( + &self, + primary_namespace: &str, + secondary_namespace: &str, + key: &str, + ) -> Result>, Error> { + // Validate parameters according to KV store requirements + validate_kvstore_params(primary_namespace, secondary_namespace, key)?; + + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + Ok(query( + r#" + SELECT value + FROM kv_store + WHERE primary_namespace = :primary_namespace + AND secondary_namespace = :secondary_namespace + AND key = :key + "#, + )? + .bind("primary_namespace", primary_namespace.to_owned()) + .bind("secondary_namespace", secondary_namespace.to_owned()) + .bind("key", key.to_owned()) + .pluck(&*conn) + .await? + .and_then(|col| match col { + Column::Blob(data) => Some(data), + _ => None, + })) + } + + async fn kv_list( + &self, + primary_namespace: &str, + secondary_namespace: &str, + ) -> Result, Error> { + // Validate namespace parameters according to KV store requirements + cdk_common::database::mint::validate_kvstore_string(primary_namespace)?; + cdk_common::database::mint::validate_kvstore_string(secondary_namespace)?; + + // Check empty namespace rules + if primary_namespace.is_empty() && !secondary_namespace.is_empty() { + return Err(Error::KVStoreInvalidKey( + "If primary_namespace is empty, secondary_namespace must also be empty".to_string(), + )); + } + + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + Ok(query( + r#" + SELECT key + FROM kv_store + WHERE primary_namespace = :primary_namespace + AND secondary_namespace = :secondary_namespace + ORDER BY key + "#, + )? + .bind("primary_namespace", primary_namespace.to_owned()) + .bind("secondary_namespace", secondary_namespace.to_owned()) + .fetch_all(&*conn) + .await? + .into_iter() + .map(|row| Ok(column_as_string!(&row[0]))) + .collect::, Error>>()?) + } +} + +#[async_trait] +impl database::MintKVStore for SQLMintDatabase +where + RM: DatabasePool + 'static, +{ + async fn begin_transaction<'a>( + &'a self, + ) -> Result + Send + Sync + 'a>, Error> + { + Ok(Box::new(SQLTransaction { + inner: ConnectionWithTransaction::new( + self.pool.get().map_err(|e| Error::Database(Box::new(e)))?, + ) + .await?, + })) + } +} + #[async_trait] impl MintDatabase for SQLMintDatabase where diff --git a/crates/cdk/src/lib.rs b/crates/cdk/src/lib.rs index ae18ae3c..babe1ecb 100644 --- a/crates/cdk/src/lib.rs +++ b/crates/cdk/src/lib.rs @@ -12,8 +12,8 @@ pub mod cdk_database { pub use cdk_common::database::WalletDatabase; #[cfg(feature = "mint")] pub use cdk_common::database::{ - MintDatabase, MintKeysDatabase, MintProofsDatabase, MintQuotesDatabase, - MintSignaturesDatabase, MintTransaction, + MintDatabase, MintKVStore, MintKVStoreDatabase, MintKVStoreTransaction, MintKeysDatabase, + MintProofsDatabase, MintQuotesDatabase, MintSignaturesDatabase, MintTransaction, }; }