From cb2e534f81bdda30d9d59150c603b3c966ddc7bf Mon Sep 17 00:00:00 2001 From: asmo Date: Tue, 30 Sep 2025 15:14:28 +0200 Subject: [PATCH] feat: added postgres to ffi (#1117) * feat: added postgres to ffi --- crates/cdk-ffi/Cargo.toml | 6 + crates/cdk-ffi/src/database.rs | 406 +----------------- crates/cdk-ffi/src/lib.rs | 2 + crates/cdk-ffi/src/postgres.rs | 401 +++++++++++++++++ crates/cdk-ffi/src/sqlite.rs | 402 +++++++++++++++++ .../tests/ffi_minting_integration.rs | 2 +- crates/cdk-postgres/src/lib.rs | 8 +- .../postgres/20250729111701_keyset_v2_u32.sql | 11 + justfile | 4 +- 9 files changed, 853 insertions(+), 389 deletions(-) create mode 100644 crates/cdk-ffi/src/postgres.rs create mode 100644 crates/cdk-ffi/src/sqlite.rs create mode 100644 crates/cdk-sql-common/src/wallet/migrations/postgres/20250729111701_keyset_v2_u32.sql diff --git a/crates/cdk-ffi/Cargo.toml b/crates/cdk-ffi/Cargo.toml index 80382a08..b37b47b4 100644 --- a/crates/cdk-ffi/Cargo.toml +++ b/crates/cdk-ffi/Cargo.toml @@ -17,6 +17,7 @@ async-trait = { workspace = true } bip39 = { workspace = true } cdk = { workspace = true, default-features = false, features = ["wallet", "auth", "bip353"] } cdk-sqlite = { workspace = true } +cdk-postgres = { workspace = true, optional = true } ctor = "0.2" futures = { workspace = true } once_cell = { workspace = true } @@ -30,6 +31,11 @@ url = { workspace = true } uuid = { workspace = true, features = ["v4"] } +[features] +default = ["postgres"] +# Enable Postgres-backed wallet database support in FFI +postgres = ["cdk-postgres"] + [dev-dependencies] [[bin]] diff --git a/crates/cdk-ffi/src/database.rs b/crates/cdk-ffi/src/database.rs index b1110aed..578681b6 100644 --- a/crates/cdk-ffi/src/database.rs +++ b/crates/cdk-ffi/src/database.rs @@ -4,9 +4,10 @@ use std::collections::HashMap; use std::sync::Arc; use cdk::cdk_database::WalletDatabase as CdkWalletDatabase; -use cdk_sqlite::wallet::WalletSqliteDatabase as CdkWalletSqliteDatabase; use crate::error::FfiError; +use crate::postgres::WalletPostgresDatabase; +use crate::sqlite::WalletSqliteDatabase; use crate::types::*; /// FFI-compatible trait for wallet database operations @@ -171,7 +172,6 @@ impl CdkWalletDatabase for WalletDatabaseBridge { ) -> Result<(), Self::Err> { let ffi_mint_url = mint_url.into(); let ffi_mint_info = mint_info.map(Into::into); - self.ffi_db .add_mint(ffi_mint_url, ffi_mint_info) .await @@ -556,394 +556,30 @@ impl CdkWalletDatabase for WalletDatabaseBridge { } } -/// FFI-compatible WalletSqliteDatabase implementation that implements the WalletDatabase trait -#[derive(uniffi::Object)] -pub struct WalletSqliteDatabase { - inner: Arc, -} - -impl WalletSqliteDatabase { - // No additional methods needed beyond the trait implementation +/// FFI-safe wallet database backend selection +#[derive(uniffi::Enum)] +pub enum WalletDbBackend { + Sqlite { + path: String, + }, + #[cfg(feature = "postgres")] + Postgres { + url: String, + }, } +/// Factory helpers returning a CDK wallet database behind the FFI trait #[uniffi::export] -impl WalletSqliteDatabase { - /// Create a new WalletSqliteDatabase with the given work directory - #[uniffi::constructor] - pub fn new(file_path: String) -> Result, FfiError> { - let db = match tokio::runtime::Handle::try_current() { - Ok(handle) => tokio::task::block_in_place(|| { - handle - .block_on(async move { CdkWalletSqliteDatabase::new(file_path.as_str()).await }) - }), - Err(_) => { - // No current runtime, create a new one - tokio::runtime::Runtime::new() - .map_err(|e| FfiError::Database { - msg: format!("Failed to create runtime: {}", e), - })? - .block_on(async move { CdkWalletSqliteDatabase::new(file_path.as_str()).await }) - } +pub fn create_wallet_db(backend: WalletDbBackend) -> Result, FfiError> { + match backend { + WalletDbBackend::Sqlite { path } => { + let sqlite = WalletSqliteDatabase::new(path)?; + Ok(sqlite as Arc) } - .map_err(|e| FfiError::Database { msg: e.to_string() })?; - Ok(Arc::new(Self { - inner: Arc::new(db), - })) - } - - /// Create an in-memory database - #[uniffi::constructor] - pub fn new_in_memory() -> Result, FfiError> { - let db = match tokio::runtime::Handle::try_current() { - Ok(handle) => tokio::task::block_in_place(|| { - handle.block_on(async move { cdk_sqlite::wallet::memory::empty().await }) - }), - Err(_) => { - // No current runtime, create a new one - tokio::runtime::Runtime::new() - .map_err(|e| FfiError::Database { - msg: format!("Failed to create runtime: {}", e), - })? - .block_on(async move { cdk_sqlite::wallet::memory::empty().await }) - } + WalletDbBackend::Postgres { url } => { + let pg = WalletPostgresDatabase::new(url)?; + Ok(pg as Arc) } - .map_err(|e| FfiError::Database { msg: e.to_string() })?; - Ok(Arc::new(Self { - inner: Arc::new(db), - })) - } -} - -#[uniffi::export(async_runtime = "tokio")] -#[async_trait::async_trait] -impl WalletDatabase for WalletSqliteDatabase { - // Mint Management - async fn add_mint( - &self, - mint_url: MintUrl, - mint_info: Option, - ) -> Result<(), FfiError> { - let cdk_mint_url = mint_url.try_into()?; - let cdk_mint_info = mint_info.map(Into::into); - self.inner - .add_mint(cdk_mint_url, cdk_mint_info) - .await - .map_err(|e| FfiError::Database { msg: e.to_string() }) - } - - async fn remove_mint(&self, mint_url: MintUrl) -> Result<(), FfiError> { - let cdk_mint_url = mint_url.try_into()?; - self.inner - .remove_mint(cdk_mint_url) - .await - .map_err(|e| FfiError::Database { msg: e.to_string() }) - } - - async fn get_mint(&self, mint_url: MintUrl) -> Result, FfiError> { - let cdk_mint_url = mint_url.try_into()?; - let result = self - .inner - .get_mint(cdk_mint_url) - .await - .map_err(|e| FfiError::Database { msg: e.to_string() })?; - Ok(result.map(Into::into)) - } - - async fn get_mints(&self) -> Result>, FfiError> { - let result = self - .inner - .get_mints() - .await - .map_err(|e| FfiError::Database { msg: e.to_string() })?; - Ok(result - .into_iter() - .map(|(k, v)| (k.into(), v.map(Into::into))) - .collect()) - } - - async fn update_mint_url( - &self, - old_mint_url: MintUrl, - new_mint_url: MintUrl, - ) -> Result<(), FfiError> { - let cdk_old_mint_url = old_mint_url.try_into()?; - let cdk_new_mint_url = new_mint_url.try_into()?; - self.inner - .update_mint_url(cdk_old_mint_url, cdk_new_mint_url) - .await - .map_err(|e| FfiError::Database { msg: e.to_string() }) - } - - // Keyset Management - async fn add_mint_keysets( - &self, - mint_url: MintUrl, - keysets: Vec, - ) -> Result<(), FfiError> { - let cdk_mint_url = mint_url.try_into()?; - let cdk_keysets: Vec = keysets.into_iter().map(Into::into).collect(); - self.inner - .add_mint_keysets(cdk_mint_url, cdk_keysets) - .await - .map_err(|e| FfiError::Database { msg: e.to_string() }) - } - - async fn get_mint_keysets( - &self, - mint_url: MintUrl, - ) -> Result>, FfiError> { - let cdk_mint_url = mint_url.try_into()?; - let result = self - .inner - .get_mint_keysets(cdk_mint_url) - .await - .map_err(|e| FfiError::Database { msg: e.to_string() })?; - Ok(result.map(|keysets| keysets.into_iter().map(Into::into).collect())) - } - - async fn get_keyset_by_id(&self, keyset_id: Id) -> Result, FfiError> { - let cdk_id = keyset_id.into(); - let result = self - .inner - .get_keyset_by_id(&cdk_id) - .await - .map_err(|e| FfiError::Database { msg: e.to_string() })?; - Ok(result.map(Into::into)) - } - - // Mint Quote Management - async fn add_mint_quote(&self, quote: MintQuote) -> Result<(), FfiError> { - let cdk_quote = quote.try_into()?; - self.inner - .add_mint_quote(cdk_quote) - .await - .map_err(|e| FfiError::Database { msg: e.to_string() }) - } - - async fn get_mint_quote(&self, quote_id: String) -> Result, FfiError> { - let result = self - .inner - .get_mint_quote("e_id) - .await - .map_err(|e| FfiError::Database { msg: e.to_string() })?; - Ok(result.map(|q| q.into())) - } - - async fn get_mint_quotes(&self) -> Result, FfiError> { - let result = self - .inner - .get_mint_quotes() - .await - .map_err(|e| FfiError::Database { msg: e.to_string() })?; - Ok(result.into_iter().map(|q| q.into()).collect()) - } - - async fn remove_mint_quote(&self, quote_id: String) -> Result<(), FfiError> { - self.inner - .remove_mint_quote("e_id) - .await - .map_err(|e| FfiError::Database { msg: e.to_string() }) - } - - // Melt Quote Management - async fn add_melt_quote(&self, quote: MeltQuote) -> Result<(), FfiError> { - let cdk_quote = quote.try_into()?; - self.inner - .add_melt_quote(cdk_quote) - .await - .map_err(|e| FfiError::Database { msg: e.to_string() }) - } - - async fn get_melt_quote(&self, quote_id: String) -> Result, FfiError> { - let result = self - .inner - .get_melt_quote("e_id) - .await - .map_err(|e| FfiError::Database { msg: e.to_string() })?; - Ok(result.map(|q| q.into())) - } - - async fn get_melt_quotes(&self) -> Result, FfiError> { - let result = self - .inner - .get_melt_quotes() - .await - .map_err(|e| FfiError::Database { msg: e.to_string() })?; - Ok(result.into_iter().map(|q| q.into()).collect()) - } - - async fn remove_melt_quote(&self, quote_id: String) -> Result<(), FfiError> { - self.inner - .remove_melt_quote("e_id) - .await - .map_err(|e| FfiError::Database { msg: e.to_string() }) - } - - // Keys Management - async fn add_keys(&self, keyset: KeySet) -> Result<(), FfiError> { - // Convert FFI KeySet to cdk::nuts::KeySet - let cdk_keyset: cdk::nuts::KeySet = keyset.try_into()?; - self.inner - .add_keys(cdk_keyset) - .await - .map_err(|e| FfiError::Database { msg: e.to_string() }) - } - - async fn get_keys(&self, id: Id) -> Result, FfiError> { - let cdk_id = id.into(); - let result = self - .inner - .get_keys(&cdk_id) - .await - .map_err(|e| FfiError::Database { msg: e.to_string() })?; - Ok(result.map(Into::into)) - } - - async fn remove_keys(&self, id: Id) -> Result<(), FfiError> { - let cdk_id = id.into(); - self.inner - .remove_keys(&cdk_id) - .await - .map_err(|e| FfiError::Database { msg: e.to_string() }) - } - - // Proof Management - async fn update_proofs( - &self, - added: Vec, - removed_ys: Vec, - ) -> Result<(), FfiError> { - // Convert FFI types to CDK types - let cdk_added: Result, FfiError> = added - .into_iter() - .map(|info| { - Ok::(cdk::types::ProofInfo { - proof: info.proof.inner.clone(), - y: info.y.try_into()?, - mint_url: info.mint_url.try_into()?, - state: info.state.into(), - spending_condition: info - .spending_condition - .map(|sc| sc.try_into()) - .transpose()?, - unit: info.unit.into(), - }) - }) - .collect(); - let cdk_added = cdk_added?; - - let cdk_removed_ys: Result, FfiError> = - removed_ys.into_iter().map(|pk| pk.try_into()).collect(); - let cdk_removed_ys = cdk_removed_ys?; - - self.inner - .update_proofs(cdk_added, cdk_removed_ys) - .await - .map_err(|e| FfiError::Database { msg: e.to_string() }) - } - - async fn get_proofs( - &self, - mint_url: Option, - unit: Option, - state: Option>, - spending_conditions: Option>, - ) -> Result, FfiError> { - let cdk_mint_url = mint_url.map(|u| u.try_into()).transpose()?; - let cdk_unit = unit.map(Into::into); - let cdk_state = state.map(|s| s.into_iter().map(Into::into).collect()); - let cdk_spending_conditions: Option> = - spending_conditions - .map(|sc| { - sc.into_iter() - .map(|c| c.try_into()) - .collect::, FfiError>>() - }) - .transpose()?; - - let result = self - .inner - .get_proofs(cdk_mint_url, cdk_unit, cdk_state, cdk_spending_conditions) - .await - .map_err(|e| FfiError::Database { msg: e.to_string() })?; - - Ok(result.into_iter().map(Into::into).collect()) - } - - async fn update_proofs_state( - &self, - ys: Vec, - state: ProofState, - ) -> Result<(), FfiError> { - let cdk_ys: Result, FfiError> = - ys.into_iter().map(|pk| pk.try_into()).collect(); - let cdk_ys = cdk_ys?; - let cdk_state = state.into(); - - self.inner - .update_proofs_state(cdk_ys, cdk_state) - .await - .map_err(|e| FfiError::Database { msg: e.to_string() }) - } - - // Keyset Counter Management - async fn increment_keyset_counter(&self, keyset_id: Id, count: u32) -> Result { - let cdk_id = keyset_id.into(); - self.inner - .increment_keyset_counter(&cdk_id, count) - .await - .map_err(|e| FfiError::Database { msg: e.to_string() }) - } - - // Transaction Management - async fn add_transaction(&self, transaction: Transaction) -> Result<(), FfiError> { - // Convert FFI Transaction to CDK Transaction using TryFrom - let cdk_transaction: cdk::wallet::types::Transaction = transaction.try_into()?; - - self.inner - .add_transaction(cdk_transaction) - .await - .map_err(|e| FfiError::Database { msg: e.to_string() }) - } - - async fn get_transaction( - &self, - transaction_id: TransactionId, - ) -> Result, FfiError> { - let cdk_id = transaction_id.try_into()?; - let result = self - .inner - .get_transaction(cdk_id) - .await - .map_err(|e| FfiError::Database { msg: e.to_string() })?; - Ok(result.map(Into::into)) - } - - async fn list_transactions( - &self, - mint_url: Option, - direction: Option, - unit: Option, - ) -> Result, FfiError> { - let cdk_mint_url = mint_url.map(|u| u.try_into()).transpose()?; - let cdk_direction = direction.map(Into::into); - let cdk_unit = unit.map(Into::into); - - let result = self - .inner - .list_transactions(cdk_mint_url, cdk_direction, cdk_unit) - .await - .map_err(|e| FfiError::Database { msg: e.to_string() })?; - - Ok(result.into_iter().map(Into::into).collect()) - } - - async fn remove_transaction(&self, transaction_id: TransactionId) -> Result<(), FfiError> { - let cdk_id = transaction_id.try_into()?; - self.inner - .remove_transaction(cdk_id) - .await - .map_err(|e| FfiError::Database { msg: e.to_string() }) } } diff --git a/crates/cdk-ffi/src/lib.rs b/crates/cdk-ffi/src/lib.rs index beb5046f..57dbb8b4 100644 --- a/crates/cdk-ffi/src/lib.rs +++ b/crates/cdk-ffi/src/lib.rs @@ -7,6 +7,8 @@ pub mod database; pub mod error; pub mod multi_mint_wallet; +pub mod postgres; +pub mod sqlite; pub mod token; pub mod types; pub mod wallet; diff --git a/crates/cdk-ffi/src/postgres.rs b/crates/cdk-ffi/src/postgres.rs new file mode 100644 index 00000000..f128b53f --- /dev/null +++ b/crates/cdk-ffi/src/postgres.rs @@ -0,0 +1,401 @@ +use std::collections::HashMap; +use std::sync::Arc; + +// Bring the CDK wallet database trait into scope so trait methods resolve on the inner DB +use cdk::cdk_database::WalletDatabase as CdkWalletDatabase; +#[cfg(feature = "postgres")] +use cdk_postgres::WalletPgDatabase as CdkWalletPgDatabase; + +use crate::{ + CurrencyUnit, FfiError, Id, KeySet, KeySetInfo, Keys, MeltQuote, MintInfo, MintQuote, MintUrl, + ProofInfo, ProofState, PublicKey, SpendingConditions, Transaction, TransactionDirection, + TransactionId, WalletDatabase, +}; + +#[derive(uniffi::Object)] +pub struct WalletPostgresDatabase { + inner: Arc, +} + +// Keep a long-lived Tokio runtime for Postgres-created resources so that +// background tasks (e.g., tokio-postgres connection drivers spawned during +// construction) are not tied to a short-lived, ad-hoc runtime. +#[cfg(feature = "postgres")] +static PG_RUNTIME: once_cell::sync::OnceCell = + once_cell::sync::OnceCell::new(); + +#[cfg(feature = "postgres")] +fn pg_runtime() -> &'static tokio::runtime::Runtime { + PG_RUNTIME.get_or_init(|| { + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .thread_name("cdk-ffi-pg") + .build() + .expect("failed to build pg runtime") + }) +} + +// Implement the local WalletDatabase trait (simple trait path required by uniffi) +#[uniffi::export(async_runtime = "tokio")] +#[async_trait::async_trait] +impl WalletDatabase for WalletPostgresDatabase { + // Forward all trait methods to inner CDK database via the bridge adapter + async fn add_mint( + &self, + mint_url: MintUrl, + mint_info: Option, + ) -> Result<(), FfiError> { + let cdk_mint_url = mint_url.try_into()?; + let cdk_mint_info = mint_info.map(Into::into); + println!("adding new mint"); + self.inner + .add_mint(cdk_mint_url, cdk_mint_info) + .await + .map_err(|e| { + println!("ffi error {:?}", e); + FfiError::Database { msg: e.to_string() } + }) + } + async fn remove_mint(&self, mint_url: MintUrl) -> Result<(), FfiError> { + let cdk_mint_url = mint_url.try_into()?; + self.inner + .remove_mint(cdk_mint_url) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + async fn get_mint(&self, mint_url: MintUrl) -> Result, FfiError> { + let cdk_mint_url = mint_url.try_into()?; + let result = self + .inner + .get_mint(cdk_mint_url) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result.map(Into::into)) + } + async fn get_mints(&self) -> Result>, FfiError> { + let result = self + .inner + .get_mints() + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result + .into_iter() + .map(|(k, v)| (k.into(), v.map(Into::into))) + .collect()) + } + async fn update_mint_url( + &self, + old_mint_url: MintUrl, + new_mint_url: MintUrl, + ) -> Result<(), FfiError> { + let cdk_old_mint_url = old_mint_url.try_into()?; + let cdk_new_mint_url = new_mint_url.try_into()?; + self.inner + .update_mint_url(cdk_old_mint_url, cdk_new_mint_url) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + async fn add_mint_keysets( + &self, + mint_url: MintUrl, + keysets: Vec, + ) -> Result<(), FfiError> { + let cdk_mint_url = mint_url.try_into()?; + let cdk_keysets: Vec = keysets.into_iter().map(Into::into).collect(); + self.inner + .add_mint_keysets(cdk_mint_url, cdk_keysets) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + async fn get_mint_keysets( + &self, + mint_url: MintUrl, + ) -> Result>, FfiError> { + let cdk_mint_url = mint_url.try_into()?; + let result = self + .inner + .get_mint_keysets(cdk_mint_url) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result.map(|keysets| keysets.into_iter().map(Into::into).collect())) + } + + async fn get_keyset_by_id(&self, keyset_id: Id) -> Result, FfiError> { + let cdk_id = keyset_id.into(); + let result = self + .inner + .get_keyset_by_id(&cdk_id) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result.map(Into::into)) + } + + // Mint Quote Management + async fn add_mint_quote(&self, quote: MintQuote) -> Result<(), FfiError> { + let cdk_quote = quote.try_into()?; + self.inner + .add_mint_quote(cdk_quote) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + async fn get_mint_quote(&self, quote_id: String) -> Result, FfiError> { + let result = self + .inner + .get_mint_quote("e_id) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result.map(|q| q.into())) + } + + async fn get_mint_quotes(&self) -> Result, FfiError> { + let result = self + .inner + .get_mint_quotes() + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result.into_iter().map(|q| q.into()).collect()) + } + + async fn remove_mint_quote(&self, quote_id: String) -> Result<(), FfiError> { + self.inner + .remove_mint_quote("e_id) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + // Melt Quote Management + async fn add_melt_quote(&self, quote: MeltQuote) -> Result<(), FfiError> { + let cdk_quote = quote.try_into()?; + self.inner + .add_melt_quote(cdk_quote) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + async fn get_melt_quote(&self, quote_id: String) -> Result, FfiError> { + let result = self + .inner + .get_melt_quote("e_id) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result.map(|q| q.into())) + } + + async fn get_melt_quotes(&self) -> Result, FfiError> { + let result = self + .inner + .get_melt_quotes() + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result.into_iter().map(|q| q.into()).collect()) + } + + async fn remove_melt_quote(&self, quote_id: String) -> Result<(), FfiError> { + self.inner + .remove_melt_quote("e_id) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + // Keys Management + async fn add_keys(&self, keyset: KeySet) -> Result<(), FfiError> { + // Convert FFI KeySet to cdk::nuts::KeySet + let cdk_keyset: cdk::nuts::KeySet = keyset.try_into()?; + self.inner + .add_keys(cdk_keyset) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + async fn get_keys(&self, id: Id) -> Result, FfiError> { + let cdk_id = id.into(); + let result = self + .inner + .get_keys(&cdk_id) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result.map(Into::into)) + } + + async fn remove_keys(&self, id: Id) -> Result<(), FfiError> { + let cdk_id = id.into(); + self.inner + .remove_keys(&cdk_id) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + // Proof Management + async fn update_proofs( + &self, + added: Vec, + removed_ys: Vec, + ) -> Result<(), FfiError> { + // Convert FFI types to CDK types + let cdk_added: Result, FfiError> = added + .into_iter() + .map(|info| { + Ok::(cdk::types::ProofInfo { + proof: info.proof.inner.clone(), + y: info.y.try_into()?, + mint_url: info.mint_url.try_into()?, + state: info.state.into(), + spending_condition: info + .spending_condition + .map(|sc| sc.try_into()) + .transpose()?, + unit: info.unit.into(), + }) + }) + .collect(); + let cdk_added = cdk_added?; + + let cdk_removed_ys: Result, FfiError> = + removed_ys.into_iter().map(|pk| pk.try_into()).collect(); + let cdk_removed_ys = cdk_removed_ys?; + + self.inner + .update_proofs(cdk_added, cdk_removed_ys) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + async fn get_proofs( + &self, + mint_url: Option, + unit: Option, + state: Option>, + spending_conditions: Option>, + ) -> Result, FfiError> { + let cdk_mint_url = mint_url.map(|u| u.try_into()).transpose()?; + let cdk_unit = unit.map(Into::into); + let cdk_state = state.map(|s| s.into_iter().map(Into::into).collect()); + let cdk_spending_conditions: Option> = + spending_conditions + .map(|sc| { + sc.into_iter() + .map(|c| c.try_into()) + .collect::, FfiError>>() + }) + .transpose()?; + + let result = self + .inner + .get_proofs(cdk_mint_url, cdk_unit, cdk_state, cdk_spending_conditions) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + + Ok(result.into_iter().map(Into::into).collect()) + } + + async fn update_proofs_state( + &self, + ys: Vec, + state: ProofState, + ) -> Result<(), FfiError> { + let cdk_ys: Result, FfiError> = + ys.into_iter().map(|pk| pk.try_into()).collect(); + let cdk_ys = cdk_ys?; + let cdk_state = state.into(); + + self.inner + .update_proofs_state(cdk_ys, cdk_state) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + // Keyset Counter Management + async fn increment_keyset_counter(&self, keyset_id: Id, count: u32) -> Result { + let cdk_id = keyset_id.into(); + self.inner + .increment_keyset_counter(&cdk_id, count) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + // Transaction Management + async fn add_transaction(&self, transaction: Transaction) -> Result<(), FfiError> { + // Convert FFI Transaction to CDK Transaction using TryFrom + let cdk_transaction: cdk::wallet::types::Transaction = transaction.try_into()?; + + self.inner + .add_transaction(cdk_transaction) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + async fn get_transaction( + &self, + transaction_id: TransactionId, + ) -> Result, FfiError> { + let cdk_id = transaction_id.try_into()?; + let result = self + .inner + .get_transaction(cdk_id) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result.map(Into::into)) + } + + async fn list_transactions( + &self, + mint_url: Option, + direction: Option, + unit: Option, + ) -> Result, FfiError> { + let cdk_mint_url = mint_url.map(|u| u.try_into()).transpose()?; + let cdk_direction = direction.map(Into::into); + let cdk_unit = unit.map(Into::into); + + let result = self + .inner + .list_transactions(cdk_mint_url, cdk_direction, cdk_unit) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + + Ok(result.into_iter().map(Into::into).collect()) + } + + async fn remove_transaction(&self, transaction_id: TransactionId) -> Result<(), FfiError> { + let cdk_id = transaction_id.try_into()?; + self.inner + .remove_transaction(cdk_id) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } +} + +#[uniffi::export] +impl WalletPostgresDatabase { + /// Create a new Postgres-backed wallet database + /// Requires cdk-ffi to be built with feature "postgres". + /// Example URL: + /// "host=localhost user=test password=test dbname=testdb port=5433 schema=wallet sslmode=prefer" + #[cfg(feature = "postgres")] + #[uniffi::constructor] + pub fn new(url: String) -> Result, FfiError> { + let inner = match tokio::runtime::Handle::try_current() { + Ok(handle) => tokio::task::block_in_place(|| { + handle.block_on( + async move { cdk_postgres::new_wallet_pg_database(url.as_str()).await }, + ) + }), + // Important: use a process-long runtime so background connection tasks stay alive. + Err(_) => pg_runtime() + .block_on(async move { cdk_postgres::new_wallet_pg_database(url.as_str()).await }), + } + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(Arc::new(WalletPostgresDatabase { + inner: Arc::new(inner), + })) + } + + fn clone_as_trait(&self) -> Arc { + // Safety: UniFFI objects are reference counted and Send+Sync via Arc + let obj: Arc = Arc::new(WalletPostgresDatabase { + inner: self.inner.clone(), + }); + obj + } +} diff --git a/crates/cdk-ffi/src/sqlite.rs b/crates/cdk-ffi/src/sqlite.rs new file mode 100644 index 00000000..8feae6e5 --- /dev/null +++ b/crates/cdk-ffi/src/sqlite.rs @@ -0,0 +1,402 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use cdk_sqlite::wallet::WalletSqliteDatabase as CdkWalletSqliteDatabase; + +use crate::{ + CurrencyUnit, FfiError, Id, KeySet, KeySetInfo, Keys, MeltQuote, MintInfo, MintQuote, MintUrl, + ProofInfo, ProofState, PublicKey, SpendingConditions, Transaction, TransactionDirection, + TransactionId, WalletDatabase, +}; + +/// FFI-compatible WalletSqliteDatabase implementation that implements the WalletDatabase trait +#[derive(uniffi::Object)] +pub struct WalletSqliteDatabase { + inner: Arc, +} +use cdk::cdk_database::WalletDatabase as CdkWalletDatabase; + +impl WalletSqliteDatabase { + // No additional methods needed beyond the trait implementation +} + +#[uniffi::export] +impl WalletSqliteDatabase { + /// Create a new WalletSqliteDatabase with the given work directory + #[uniffi::constructor] + pub fn new(file_path: String) -> Result, FfiError> { + let db = match tokio::runtime::Handle::try_current() { + Ok(handle) => tokio::task::block_in_place(|| { + handle + .block_on(async move { CdkWalletSqliteDatabase::new(file_path.as_str()).await }) + }), + Err(_) => { + // No current runtime, create a new one + tokio::runtime::Runtime::new() + .map_err(|e| FfiError::Database { + msg: format!("Failed to create runtime: {}", e), + })? + .block_on(async move { CdkWalletSqliteDatabase::new(file_path.as_str()).await }) + } + } + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(Arc::new(Self { + inner: Arc::new(db), + })) + } + + /// Create an in-memory database + #[uniffi::constructor] + pub fn new_in_memory() -> Result, FfiError> { + let db = match tokio::runtime::Handle::try_current() { + Ok(handle) => tokio::task::block_in_place(|| { + handle.block_on(async move { cdk_sqlite::wallet::memory::empty().await }) + }), + Err(_) => { + // No current runtime, create a new one + tokio::runtime::Runtime::new() + .map_err(|e| FfiError::Database { + msg: format!("Failed to create runtime: {}", e), + })? + .block_on(async move { cdk_sqlite::wallet::memory::empty().await }) + } + } + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(Arc::new(Self { + inner: Arc::new(db), + })) + } +} + +#[uniffi::export(async_runtime = "tokio")] +#[async_trait::async_trait] +impl WalletDatabase for WalletSqliteDatabase { + // Mint Management + async fn add_mint( + &self, + mint_url: MintUrl, + mint_info: Option, + ) -> Result<(), FfiError> { + let cdk_mint_url = mint_url.try_into()?; + let cdk_mint_info = mint_info.map(Into::into); + self.inner + .add_mint(cdk_mint_url, cdk_mint_info) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + async fn remove_mint(&self, mint_url: MintUrl) -> Result<(), FfiError> { + let cdk_mint_url = mint_url.try_into()?; + self.inner + .remove_mint(cdk_mint_url) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + async fn get_mint(&self, mint_url: MintUrl) -> Result, FfiError> { + let cdk_mint_url = mint_url.try_into()?; + let result = self + .inner + .get_mint(cdk_mint_url) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result.map(Into::into)) + } + + async fn get_mints(&self) -> Result>, FfiError> { + let result = self + .inner + .get_mints() + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result + .into_iter() + .map(|(k, v)| (k.into(), v.map(Into::into))) + .collect()) + } + + async fn update_mint_url( + &self, + old_mint_url: MintUrl, + new_mint_url: MintUrl, + ) -> Result<(), FfiError> { + let cdk_old_mint_url = old_mint_url.try_into()?; + let cdk_new_mint_url = new_mint_url.try_into()?; + self.inner + .update_mint_url(cdk_old_mint_url, cdk_new_mint_url) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + // Keyset Management + async fn add_mint_keysets( + &self, + mint_url: MintUrl, + keysets: Vec, + ) -> Result<(), FfiError> { + let cdk_mint_url = mint_url.try_into()?; + let cdk_keysets: Vec = keysets.into_iter().map(Into::into).collect(); + self.inner + .add_mint_keysets(cdk_mint_url, cdk_keysets) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + async fn get_mint_keysets( + &self, + mint_url: MintUrl, + ) -> Result>, FfiError> { + let cdk_mint_url = mint_url.try_into()?; + let result = self + .inner + .get_mint_keysets(cdk_mint_url) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result.map(|keysets| keysets.into_iter().map(Into::into).collect())) + } + + async fn get_keyset_by_id(&self, keyset_id: Id) -> Result, FfiError> { + let cdk_id = keyset_id.into(); + let result = self + .inner + .get_keyset_by_id(&cdk_id) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result.map(Into::into)) + } + + // Mint Quote Management + async fn add_mint_quote(&self, quote: MintQuote) -> Result<(), FfiError> { + let cdk_quote = quote.try_into()?; + self.inner + .add_mint_quote(cdk_quote) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + async fn get_mint_quote(&self, quote_id: String) -> Result, FfiError> { + let result = self + .inner + .get_mint_quote("e_id) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result.map(|q| q.into())) + } + + async fn get_mint_quotes(&self) -> Result, FfiError> { + let result = self + .inner + .get_mint_quotes() + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result.into_iter().map(|q| q.into()).collect()) + } + + async fn remove_mint_quote(&self, quote_id: String) -> Result<(), FfiError> { + self.inner + .remove_mint_quote("e_id) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + // Melt Quote Management + async fn add_melt_quote(&self, quote: MeltQuote) -> Result<(), FfiError> { + let cdk_quote = quote.try_into()?; + self.inner + .add_melt_quote(cdk_quote) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + async fn get_melt_quote(&self, quote_id: String) -> Result, FfiError> { + let result = self + .inner + .get_melt_quote("e_id) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result.map(|q| q.into())) + } + + async fn get_melt_quotes(&self) -> Result, FfiError> { + let result = self + .inner + .get_melt_quotes() + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result.into_iter().map(|q| q.into()).collect()) + } + + async fn remove_melt_quote(&self, quote_id: String) -> Result<(), FfiError> { + self.inner + .remove_melt_quote("e_id) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + // Keys Management + async fn add_keys(&self, keyset: KeySet) -> Result<(), FfiError> { + // Convert FFI KeySet to cdk::nuts::KeySet + let cdk_keyset: cdk::nuts::KeySet = keyset.try_into()?; + self.inner + .add_keys(cdk_keyset) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + async fn get_keys(&self, id: Id) -> Result, FfiError> { + let cdk_id = id.into(); + let result = self + .inner + .get_keys(&cdk_id) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result.map(Into::into)) + } + + async fn remove_keys(&self, id: Id) -> Result<(), FfiError> { + let cdk_id = id.into(); + self.inner + .remove_keys(&cdk_id) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + // Proof Management + async fn update_proofs( + &self, + added: Vec, + removed_ys: Vec, + ) -> Result<(), FfiError> { + // Convert FFI types to CDK types + let cdk_added: Result, FfiError> = added + .into_iter() + .map(|info| { + Ok::(cdk::types::ProofInfo { + proof: info.proof.inner.clone(), + y: info.y.try_into()?, + mint_url: info.mint_url.try_into()?, + state: info.state.into(), + spending_condition: info + .spending_condition + .map(|sc| sc.try_into()) + .transpose()?, + unit: info.unit.into(), + }) + }) + .collect(); + let cdk_added = cdk_added?; + + let cdk_removed_ys: Result, FfiError> = + removed_ys.into_iter().map(|pk| pk.try_into()).collect(); + let cdk_removed_ys = cdk_removed_ys?; + + self.inner + .update_proofs(cdk_added, cdk_removed_ys) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + async fn get_proofs( + &self, + mint_url: Option, + unit: Option, + state: Option>, + spending_conditions: Option>, + ) -> Result, FfiError> { + let cdk_mint_url = mint_url.map(|u| u.try_into()).transpose()?; + let cdk_unit = unit.map(Into::into); + let cdk_state = state.map(|s| s.into_iter().map(Into::into).collect()); + let cdk_spending_conditions: Option> = + spending_conditions + .map(|sc| { + sc.into_iter() + .map(|c| c.try_into()) + .collect::, FfiError>>() + }) + .transpose()?; + + let result = self + .inner + .get_proofs(cdk_mint_url, cdk_unit, cdk_state, cdk_spending_conditions) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + + Ok(result.into_iter().map(Into::into).collect()) + } + + async fn update_proofs_state( + &self, + ys: Vec, + state: ProofState, + ) -> Result<(), FfiError> { + let cdk_ys: Result, FfiError> = + ys.into_iter().map(|pk| pk.try_into()).collect(); + let cdk_ys = cdk_ys?; + let cdk_state = state.into(); + + self.inner + .update_proofs_state(cdk_ys, cdk_state) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + // Keyset Counter Management + async fn increment_keyset_counter(&self, keyset_id: Id, count: u32) -> Result { + let cdk_id = keyset_id.into(); + self.inner + .increment_keyset_counter(&cdk_id, count) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + // Transaction Management + async fn add_transaction(&self, transaction: Transaction) -> Result<(), FfiError> { + // Convert FFI Transaction to CDK Transaction using TryFrom + let cdk_transaction: cdk::wallet::types::Transaction = transaction.try_into()?; + + self.inner + .add_transaction(cdk_transaction) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + async fn get_transaction( + &self, + transaction_id: TransactionId, + ) -> Result, FfiError> { + let cdk_id = transaction_id.try_into()?; + let result = self + .inner + .get_transaction(cdk_id) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result.map(Into::into)) + } + + async fn list_transactions( + &self, + mint_url: Option, + direction: Option, + unit: Option, + ) -> Result, FfiError> { + let cdk_mint_url = mint_url.map(|u| u.try_into()).transpose()?; + let cdk_direction = direction.map(Into::into); + let cdk_unit = unit.map(Into::into); + + let result = self + .inner + .list_transactions(cdk_mint_url, cdk_direction, cdk_unit) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + + Ok(result.into_iter().map(Into::into).collect()) + } + + async fn remove_transaction(&self, transaction_id: TransactionId) -> Result<(), FfiError> { + let cdk_id = transaction_id.try_into()?; + self.inner + .remove_transaction(cdk_id) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } +} diff --git a/crates/cdk-integration-tests/tests/ffi_minting_integration.rs b/crates/cdk-integration-tests/tests/ffi_minting_integration.rs index 8d3b3204..71ade057 100644 --- a/crates/cdk-integration-tests/tests/ffi_minting_integration.rs +++ b/crates/cdk-integration-tests/tests/ffi_minting_integration.rs @@ -17,7 +17,7 @@ use std::str::FromStr; use std::time::Duration; use bip39::Mnemonic; -use cdk_ffi::database::WalletSqliteDatabase; +use cdk_ffi::sqlite::WalletSqliteDatabase; use cdk_ffi::types::{Amount, CurrencyUnit, QuoteState, SplitTarget}; use cdk_ffi::wallet::Wallet as FfiWallet; use cdk_ffi::WalletConfig; diff --git a/crates/cdk-postgres/src/lib.rs b/crates/cdk-postgres/src/lib.rs index 0d89c06c..bea6ac1f 100644 --- a/crates/cdk-postgres/src/lib.rs +++ b/crates/cdk-postgres/src/lib.rs @@ -319,9 +319,15 @@ pub type MintPgDatabase = SQLMintDatabase; #[cfg(feature = "auth")] pub type MintPgAuthDatabase = SQLMintAuthDatabase; -/// Mint DB implementation with PostgresSQL +/// Wallet DB implementation with PostgreSQL pub type WalletPgDatabase = SQLWalletDatabase; +/// Convenience free functions (cannot add inherent impls for a foreign type). +/// These mirror the Mint patterns and call through to the generic constructors. +pub async fn new_wallet_pg_database(conn_str: &str) -> Result { + >::new(conn_str).await +} + #[cfg(test)] mod test { use cdk_common::mint_db_test; diff --git a/crates/cdk-sql-common/src/wallet/migrations/postgres/20250729111701_keyset_v2_u32.sql b/crates/cdk-sql-common/src/wallet/migrations/postgres/20250729111701_keyset_v2_u32.sql new file mode 100644 index 00000000..2192f415 --- /dev/null +++ b/crates/cdk-sql-common/src/wallet/migrations/postgres/20250729111701_keyset_v2_u32.sql @@ -0,0 +1,11 @@ +-- Add u32 representation column to key table with unique constraint +ALTER TABLE key ADD COLUMN keyset_u32 INTEGER; + +-- Add unique constraint on the new column +CREATE UNIQUE INDEX IF NOT EXISTS keyset_u32_unique ON key(keyset_u32); + +-- Add u32 representation column to keyset table with unique constraint +ALTER TABLE keyset ADD COLUMN keyset_u32 INTEGER; + +-- Add unique constraint on the new column +CREATE UNIQUE INDEX IF NOT EXISTS keyset_u32_unique_keyset ON keyset(keyset_u32); diff --git a/justfile b/justfile index 0a1f4828..2ed4316f 100644 --- a/justfile +++ b/justfile @@ -429,7 +429,7 @@ _ffi-lib-ext: # Build the FFI library ffi-build *ARGS="--release": - cargo build {{ARGS}} --package cdk-ffi + cargo build {{ARGS}} --package cdk-ffi --features postgres # Generate bindings for a specific language ffi-generate LANGUAGE *ARGS="--release": ffi-build @@ -460,7 +460,7 @@ ffi-generate LANGUAGE *ARGS="--release": ffi-build BUILD_TYPE="release" else BUILD_TYPE="debug" - cargo build --package cdk-ffi + cargo build --package cdk-ffi --features postgres fi LIB_EXT=$(just _ffi-lib-ext)