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

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