Просмотр исходного кода

feat(cdk): add generic key-value store functionality for mint databases

Implements a comprehensive KV store system with transaction support,
namespace-based organization, and validation for mint databases.

- Add KVStoreDatabase and KVStoreTransaction traits for generic storage
- Include namespace and key validation with ASCII character restrictions
- Add database migrations for kv_store table in SQLite and PostgreSQL
- Implement comprehensive test suite for KV store functionality
- Integrate KV store traits into existing Database and Transaction bounds
thesimplekid 1 месяц назад
Родитель
Сommit
77437f8c08

+ 6 - 0
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]).

+ 135 - 1
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};
@@ -21,6 +81,9 @@ mod auth;
 #[cfg(feature = "test")]
 pub mod test;
 
+#[cfg(test)]
+mod test_kvstore;
+
 #[cfg(feature = "auth")]
 pub use auth::{MintAuthDatabase, MintAuthTransaction};
 
@@ -257,6 +320,42 @@ pub trait DbTransactionFinalizer {
     async fn rollback(self: Box<Self>) -> Result<(), Self::Err>;
 }
 
+/// Key-Value Store Transaction trait
+#[async_trait]
+pub trait KVStoreTransaction<'a, Error>: DbTransactionFinalizer<Err = Error> {
+    /// Read value from key-value store
+    async fn kv_read(
+        &mut self,
+        primary_namespace: &str,
+        secondary_namespace: &str,
+        key: &str,
+    ) -> Result<Option<Vec<u8>>, 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<Vec<String>, Error>;
+}
+
 /// Base database writer
 #[async_trait]
 pub trait Transaction<'a, Error>:
@@ -264,6 +363,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>;
@@ -272,10 +372,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<Error> + From<Error>;
+
+    /// Read value from key-value store
+    async fn kv_read(
+        &self,
+        primary_namespace: &str,
+        secondary_namespace: &str,
+        key: &str,
+    ) -> Result<Option<Vec<u8>>, Self::Err>;
+
+    /// List keys in a namespace
+    async fn kv_list(
+        &self,
+        primary_namespace: &str,
+        secondary_namespace: &str,
+    ) -> Result<Vec<String>, 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<Box<dyn KVStoreTransaction<'a, Self::Err> + Send + Sync + 'a>, Error>;
+}
+
 /// Mint Database trait
 #[async_trait]
 pub trait Database<Error>:
-    QuotesDatabase<Err = Error> + ProofsDatabase<Err = Error> + SignaturesDatabase<Err = Error>
+    KVStoreDatabase<Err = Error>
+    + QuotesDatabase<Err = Error>
+    + ProofsDatabase<Err = Error>
+    + SignaturesDatabase<Err = Error>
 {
     /// Beings a transaction
     async fn begin_transaction<'a>(

+ 207 - 0
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", // "
+            "test<key",  // <
+            "test>key",  // >
+            "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
+    }
+}

+ 9 - 4
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")]

+ 3 - 0
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),

+ 64 - 16
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<dyn MintDatabase<cdk_database::Error> + Send + Sync>,
     Arc<dyn MintKeysDatabase<Err = cdk_database::Error> + Send + Sync>,
+    Arc<dyn MintKVStore<Err = cdk_database::Error> + 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<dyn MintDatabase<cdk_database::Error> + Send + Sync>,
     Arc<dyn MintKeysDatabase<Err = cdk_database::Error> + Send + Sync>,
+    Arc<dyn MintKVStore<Err = cdk_database::Error> + Send + Sync>,
 )> {
     match settings.database.engine {
         #[cfg(feature = "sqlite")]
         DatabaseEngine::Sqlite => {
             let db = setup_sqlite_database(_work_dir, _db_password).await?;
             let localstore: Arc<dyn MintDatabase<cdk_database::Error> + Send + Sync> = db.clone();
+            let kv: Arc<dyn MintKVStore<Err = cdk_database::Error> + Send + Sync> = db.clone();
             let keystore: Arc<dyn MintKeysDatabase<Err = cdk_database::Error> + 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<dyn MintDatabase<cdk_database::Error> + Send + Sync> =
                 pg_db.clone();
             #[cfg(feature = "postgres")]
+            let kv: Arc<dyn MintKVStore<Err = cdk_database::Error> + Send + Sync> = pg_db.clone();
+            #[cfg(feature = "postgres")]
             let keystore: Arc<
                 dyn MintKeysDatabase<Err = cdk_database::Error> + 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<std::sync::Arc<tokio::runtime::Runtime>>,
     work_dir: &Path,
+    kv_store: Option<Arc<dyn MintKVStore<Err = cdk::cdk_database::Error> + Send + Sync>>,
 ) -> Result<(MintBuilder, Vec<Router>)> {
     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<Router>,
     _runtime: Option<std::sync::Arc<tokio::runtime::Runtime>>,
     work_dir: &Path,
+    _kv_store: Option<Arc<dyn MintKVStore<Err = cdk::cdk_database::Error> + Send + Sync>>,
 ) -> Result<MintBuilder> {
     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<String>,
     runtime: Option<std::sync::Arc<tokio::runtime::Runtime>>,
 ) -> 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?;
 

+ 9 - 0
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<std::sync::Arc<tokio::runtime::Runtime>>,
         work_dir: &Path,
+        kv_store: Option<Arc<dyn MintKVStore<Err = cdk::cdk_database::Error> + Send + Sync>>,
     ) -> anyhow::Result<impl MintPayment>;
 }
 
@@ -47,6 +50,7 @@ impl LnBackendSetup for config::Cln {
         _unit: CurrencyUnit,
         _runtime: Option<std::sync::Arc<tokio::runtime::Runtime>>,
         _work_dir: &Path,
+        _kv_store: Option<Arc<dyn MintKVStore<Err = cdk::cdk_database::Error> + Send + Sync>>,
     ) -> anyhow::Result<cdk_cln::Cln> {
         let cln_socket = expand_path(
             self.rpc_path
@@ -76,6 +80,7 @@ impl LnBackendSetup for config::LNbits {
         _unit: CurrencyUnit,
         _runtime: Option<std::sync::Arc<tokio::runtime::Runtime>>,
         _work_dir: &Path,
+        _kv_store: Option<Arc<dyn MintKVStore<Err = cdk::cdk_database::Error> + Send + Sync>>,
     ) -> anyhow::Result<cdk_lnbits::LNbits> {
         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<std::sync::Arc<tokio::runtime::Runtime>>,
         _work_dir: &Path,
+        _kv_store: Option<Arc<dyn MintKVStore<Err = cdk::cdk_database::Error> + Send + Sync>>,
     ) -> anyhow::Result<cdk_lnd::Lnd> {
         let address = &self.address;
         let cert_file = &self.cert_file;
@@ -142,6 +148,7 @@ impl LnBackendSetup for config::FakeWallet {
         unit: CurrencyUnit,
         _runtime: Option<std::sync::Arc<tokio::runtime::Runtime>>,
         _work_dir: &Path,
+        _kv_store: Option<Arc<dyn MintKVStore<Err = cdk::cdk_database::Error> + Send + Sync>>,
     ) -> anyhow::Result<cdk_fake_wallet::FakeWallet> {
         let fee_reserve = FeeReserve {
             min_fee_reserve: self.reserve_fee_min,
@@ -174,6 +181,7 @@ impl LnBackendSetup for config::GrpcProcessor {
         _unit: CurrencyUnit,
         _runtime: Option<std::sync::Arc<tokio::runtime::Runtime>>,
         _work_dir: &Path,
+        _kv_store: Option<Arc<dyn MintKVStore<Err = cdk::cdk_database::Error> + Send + Sync>>,
     ) -> anyhow::Result<cdk_payment_processor::PaymentProcessorClient> {
         let payment_processor = cdk_payment_processor::PaymentProcessorClient::new(
             &self.addr,
@@ -196,6 +204,7 @@ impl LnBackendSetup for config::LdkNode {
         _unit: CurrencyUnit,
         runtime: Option<std::sync::Arc<tokio::runtime::Runtime>>,
         work_dir: &Path,
+        _kv_store: Option<Arc<dyn MintKVStore<Err = cdk::cdk_database::Error> + Send + Sync>>,
     ) -> anyhow::Result<cdk_ldk_node::CdkLdkNode> {
         use std::net::SocketAddr;
 

+ 4 - 1
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,

+ 2 - 0
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"#)),
 ];

+ 18 - 0
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);

+ 18 - 0
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);

+ 219 - 0
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,
@@ -1677,6 +1678,224 @@ where
 }
 
 #[async_trait]
+impl<RM> database::MintKVStoreTransaction<'_, Error> for SQLTransaction<RM>
+where
+    RM: DatabasePool + 'static,
+{
+    async fn kv_read(
+        &mut self,
+        primary_namespace: &str,
+        secondary_namespace: &str,
+        key: &str,
+    ) -> Result<Option<Vec<u8>>, 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<Vec<String>, 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::<Result<Vec<_>, Error>>()?)
+    }
+}
+
+#[async_trait]
+impl<RM> database::MintKVStoreDatabase for SQLMintDatabase<RM>
+where
+    RM: DatabasePool + 'static,
+{
+    type Err = Error;
+
+    async fn kv_read(
+        &self,
+        primary_namespace: &str,
+        secondary_namespace: &str,
+        key: &str,
+    ) -> Result<Option<Vec<u8>>, 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<Vec<String>, 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::<Result<Vec<_>, Error>>()?)
+    }
+}
+
+#[async_trait]
+impl<RM> database::MintKVStore for SQLMintDatabase<RM>
+where
+    RM: DatabasePool + 'static,
+{
+    async fn begin_transaction<'a>(
+        &'a self,
+    ) -> Result<Box<dyn database::MintKVStoreTransaction<'a, Self::Err> + 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<RM> MintDatabase<Error> for SQLMintDatabase<RM>
 where
     RM: DatabasePool + 'static,

+ 87 - 0
crates/cdk-sqlite/src/mint/mod.rs

@@ -32,6 +32,93 @@ mod test {
     mint_db_test!(provide_db);
 
     #[tokio::test]
+    async fn test_kvstore_functionality() {
+        use cdk_common::database::{MintDatabase, MintKVStoreDatabase};
+
+        let db = provide_db().await;
+
+        // Test basic read/write operations in transaction
+        {
+            let mut tx = db.begin_transaction().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 = db.begin_transaction().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"]);
+        }
+    }
+
+    #[tokio::test]
     async fn open_legacy_and_migrate() {
         let file = format!(
             "{}/db.sqlite",

+ 2 - 2
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,
     };
 }