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

* 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
This commit is contained in:
thesimplekid
2025-09-05 13:58:48 +01:00
committed by GitHub
parent ce3d25dfb9
commit c6cff3f6f4
15 changed files with 796 additions and 37 deletions

View File

@@ -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]).

View File

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

View File

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

View File

@@ -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: &DB) -> Id
where
DB: KeysDatabase<Err = database::Error>,
DB: KeysDatabase<Err = crate::database::Error>,
{
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: DB)
where
DB: Database<database::Error> + KeysDatabase<Err = database::Error>,
DB: Database<crate::database::Error> + KeysDatabase<Err = crate::database::Error>,
{
let keyset_id = setup_keyset(&db).await;
@@ -83,7 +85,7 @@ where
/// other tests
pub async fn add_and_find_proofs<DB>(db: DB)
where
DB: Database<database::Error> + KeysDatabase<Err = database::Error>,
DB: Database<crate::database::Error> + KeysDatabase<Err = crate::database::Error>,
{
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: DB)
where
DB: Database<crate::database::Error> + MintKVStoreDatabase<Err = crate::database::Error>,
{
// 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]

View File

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

View File

@@ -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")]

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"#)),
];

View File

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

View File

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

View File

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

View File

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