diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2156e1413..d4a60d00e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -132,7 +132,11 @@ jobs: # FFI bindings -p cdk-ffi, - + -p cdk-ffi --no-default-features, + -p cdk-ffi --no-default-features --features postgres, + -p cdk-ffi --no-default-features --features async-trait, + -p cdk-ffi --no-default-features --features "postgres async-trait", + # Binaries --bin cdk-cli, --bin cdk-cli --features sqlcipher, diff --git a/crates/cdk-ffi/Cargo.toml b/crates/cdk-ffi/Cargo.toml index dd9758b8b..c32d70d41 100644 --- a/crates/cdk-ffi/Cargo.toml +++ b/crates/cdk-ffi/Cargo.toml @@ -26,14 +26,17 @@ serde_json = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread"] } uniffi = { version = "0.29", features = ["cli", "tokio"] } +uniffi-dart = { path = "/Users/david/Projects/uniffi-dart" } url = { workspace = true } uuid = { workspace = true, features = ["v4"] } [features] -default = ["postgres"] -# Enable Postgres-backed wallet database support in FFI -postgres = ["cdk-postgres"] +default = ["async-trait", "postgres"] +# Enable async trait exports for custom database implementations in foreign languages +async-trait = [] +# Enable Postgres-backed wallet database support in FFI (requires async-trait) +postgres = ["cdk-postgres", "async-trait"] [dev-dependencies] diff --git a/crates/cdk-ffi/README.md b/crates/cdk-ffi/README.md index 1d28ccd04..489769787 100644 --- a/crates/cdk-ffi/README.md +++ b/crates/cdk-ffi/README.md @@ -4,9 +4,10 @@ UniFFI bindings for the CDK (Cashu Development Kit), providing foreign function ## Supported Languages -- **🐍 Python** - With REPL integration for development -- **🍎 Swift** - iOS and macOS development -- **🎯 Kotlin** - Android and JVM development +- **Python** - With REPL integration for development +- **Swift** - iOS and macOS development +- **Kotlin** - Android and JVM development +- **Dart** - Flutter development ## Development Tasks @@ -24,6 +25,7 @@ just ffi-clean # Clean build artifacts just ffi-generate python just ffi-generate swift just ffi-generate kotlin +just ffi-generate dart # Generate all languages just ffi-generate-all @@ -57,5 +59,5 @@ just ffi-dev-python For production use, see language-specific repositories: - [cdk-swift](https://github.com/cashubtc/cdk-swift) - iOS/macOS packages -- [cdk-kotlin](https://github.com/cashubtc/cdk-kotlin) - Android/JVM packages -- [cdk-python](https://github.com/cashubtc/cdk-python) - PyPI packages \ No newline at end of file +- [cdk-kotlin](https://github.com/cashubtc/cdk-kotlin) - Android/JVM packages +- [cdk-dart](https://github.com/cashubtc/cdk-dart) - Flutter/Dart packages (coming soon) \ No newline at end of file diff --git a/crates/cdk-ffi/src/bin/uniffi-bindgen.rs b/crates/cdk-ffi/src/bin/uniffi-bindgen.rs index f6cff6cf1..138670547 100644 --- a/crates/cdk-ffi/src/bin/uniffi-bindgen.rs +++ b/crates/cdk-ffi/src/bin/uniffi-bindgen.rs @@ -1,3 +1,36 @@ fn main() { - uniffi::uniffi_bindgen_main() + uniffi_bindgen() +} + +fn uniffi_bindgen() { + // Manually parse command line arguments for language and library path + let args: Vec = std::env::args().collect(); + let language = args + .iter() + .position(|arg| arg == "--language") + .and_then(|idx| args.get(idx + 1)); + let library_path = args + .iter() + .position(|arg| arg == "--library") + .and_then(|idx| args.get(idx + 1)) + .expect("specify the library path with --library"); + let output_dir = args + .iter() + .position(|arg| arg == "--out-dir") + .and_then(|idx| args.get(idx + 1)) + .expect("--out-dir is required when using --library"); + + match language { + Some(lang) if lang == "dart" => { + uniffi_dart::gen::generate_dart_bindings( + "src/cdk_ffi.udl".into(), + None, + Some(output_dir.as_str().into()), + library_path.as_str().into(), + true, + ) + .expect("Failed to generate dart bindings"); + } + _ => uniffi::uniffi_bindgen_main(), + } } diff --git a/crates/cdk-ffi/src/cdk_ffi.udl b/crates/cdk-ffi/src/cdk_ffi.udl new file mode 100644 index 000000000..076077d3f --- /dev/null +++ b/crates/cdk-ffi/src/cdk_ffi.udl @@ -0,0 +1 @@ +namespace cdk_ffi {}; diff --git a/crates/cdk-ffi/src/database.rs b/crates/cdk-ffi/src/database.rs index c87b1c554..6c8d7d8cb 100644 --- a/crates/cdk-ffi/src/database.rs +++ b/crates/cdk-ffi/src/database.rs @@ -1,18 +1,22 @@ //! FFI Database bindings use std::collections::HashMap; +#[cfg(feature = "async-trait")] use std::sync::Arc; +#[cfg(feature = "async-trait")] use cdk::cdk_database::WalletDatabase as CdkWalletDatabase; use crate::error::FfiError; +#[cfg(all(feature = "postgres", feature = "async-trait"))] use crate::postgres::WalletPostgresDatabase; +#[cfg(feature = "async-trait")] use crate::sqlite::WalletSqliteDatabase; use crate::types::*; /// FFI-compatible trait for wallet database operations /// This trait mirrors the CDK WalletDatabase trait but uses FFI-compatible types -#[uniffi::export(with_foreign)] +#[cfg_attr(feature = "async-trait", uniffi::export(with_foreign))] #[async_trait::async_trait] pub trait WalletDatabase: Send + Sync { // Mint Management @@ -152,22 +156,26 @@ pub trait WalletDatabase: Send + Sync { /// Internal bridge trait to convert from the FFI trait to the CDK database trait /// This allows us to bridge between the UniFFI trait and the CDK's internal database trait +#[cfg(feature = "async-trait")] struct WalletDatabaseBridge { ffi_db: Arc, } +#[cfg(feature = "async-trait")] impl WalletDatabaseBridge { fn new(ffi_db: Arc) -> Self { Self { ffi_db } } } +#[cfg(feature = "async-trait")] impl std::fmt::Debug for WalletDatabaseBridge { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "WalletDatabaseBridge") } } +#[cfg(feature = "async-trait")] #[async_trait::async_trait] impl CdkWalletDatabase for WalletDatabaseBridge { type Err = cdk::cdk_database::Error; @@ -583,6 +591,7 @@ impl CdkWalletDatabase for WalletDatabaseBridge { } /// FFI-safe wallet database backend selection +#[cfg(feature = "async-trait")] #[derive(uniffi::Enum)] pub enum WalletDbBackend { Sqlite { @@ -595,6 +604,7 @@ pub enum WalletDbBackend { } /// Factory helpers returning a CDK wallet database behind the FFI trait +#[cfg(feature = "async-trait")] #[uniffi::export] pub fn create_wallet_db(backend: WalletDbBackend) -> Result, FfiError> { match backend { @@ -602,6 +612,7 @@ pub fn create_wallet_db(backend: WalletDbBackend) -> Result) } + #[cfg(feature = "postgres")] WalletDbBackend::Postgres { url } => { let pg = WalletPostgresDatabase::new(url)?; Ok(pg as Arc) @@ -610,6 +621,7 @@ pub fn create_wallet_db(backend: WalletDbBackend) -> Result, ) -> Arc + Send + Sync> { diff --git a/crates/cdk-ffi/src/lib.rs b/crates/cdk-ffi/src/lib.rs index 57dbb8b4c..5ceb14603 100644 --- a/crates/cdk-ffi/src/lib.rs +++ b/crates/cdk-ffi/src/lib.rs @@ -7,6 +7,7 @@ pub mod database; pub mod error; pub mod multi_mint_wallet; +#[cfg(all(feature = "postgres", feature = "async-trait"))] pub mod postgres; pub mod sqlite; pub mod token; diff --git a/crates/cdk-ffi/src/multi_mint_wallet.rs b/crates/cdk-ffi/src/multi_mint_wallet.rs index 0ca4eda56..0e281e399 100644 --- a/crates/cdk-ffi/src/multi_mint_wallet.rs +++ b/crates/cdk-ffi/src/multi_mint_wallet.rs @@ -21,7 +21,9 @@ pub struct MultiMintWallet { inner: Arc, } -#[uniffi::export(async_runtime = "tokio")] +// Conditional constructor implementations - async-trait enabled +#[cfg(feature = "async-trait")] +#[uniffi::export] impl MultiMintWallet { /// Create a new MultiMintWallet from mnemonic using WalletDatabase trait #[uniffi::constructor] @@ -105,6 +107,101 @@ impl MultiMintWallet { inner: Arc::new(wallet), }) } +} + +// Conditional constructor implementations - async-trait disabled +#[cfg(not(feature = "async-trait"))] +#[uniffi::export] +impl MultiMintWallet { + /// Create a new MultiMintWallet from mnemonic using WalletSqliteDatabase + #[uniffi::constructor] + pub fn new( + unit: CurrencyUnit, + mnemonic: String, + db: Arc, + ) -> Result { + // Parse mnemonic and generate seed without passphrase + let m = Mnemonic::parse(&mnemonic) + .map_err(|e| FfiError::InvalidMnemonic { msg: e.to_string() })?; + let seed = m.to_seed_normalized(""); + + // Convert the SQLite database to a CDK database implementation + let localstore: Arc + Send + Sync> = + db.inner.clone() as Arc + Send + Sync>; + + let wallet = match tokio::runtime::Handle::try_current() { + Ok(handle) => tokio::task::block_in_place(|| { + handle.block_on(async move { + CdkMultiMintWallet::new(localstore, seed, unit.into()).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 { + CdkMultiMintWallet::new(localstore, seed, unit.into()).await + }) + } + }?; + + Ok(Self { + inner: Arc::new(wallet), + }) + } + + /// Create a new MultiMintWallet with proxy configuration using WalletSqliteDatabase + #[uniffi::constructor] + pub fn new_with_proxy( + unit: CurrencyUnit, + mnemonic: String, + db: Arc, + proxy_url: String, + ) -> Result { + // Parse mnemonic and generate seed without passphrase + let m = Mnemonic::parse(&mnemonic) + .map_err(|e| FfiError::InvalidMnemonic { msg: e.to_string() })?; + let seed = m.to_seed_normalized(""); + + // Convert the SQLite database to a CDK database implementation + let localstore: Arc + Send + Sync> = + db.inner.clone() as Arc + Send + Sync>; + + // Parse proxy URL + let proxy_url = + url::Url::parse(&proxy_url).map_err(|e| FfiError::InvalidUrl { msg: e.to_string() })?; + + let wallet = match tokio::runtime::Handle::try_current() { + Ok(handle) => tokio::task::block_in_place(|| { + handle.block_on(async move { + CdkMultiMintWallet::new_with_proxy(localstore, seed, unit.into(), proxy_url) + .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 { + CdkMultiMintWallet::new_with_proxy(localstore, seed, unit.into(), proxy_url) + .await + }) + } + }?; + + Ok(Self { + inner: Arc::new(wallet), + }) + } +} + +// Methods impl block (shared between both feature configurations) +#[uniffi::export(async_runtime = "tokio")] +impl MultiMintWallet { /// Get the currency unit for this wallet pub fn unit(&self) -> CurrencyUnit { diff --git a/crates/cdk-ffi/src/postgres.rs b/crates/cdk-ffi/src/postgres.rs index 975bd011d..193965c76 100644 --- a/crates/cdk-ffi/src/postgres.rs +++ b/crates/cdk-ffi/src/postgres.rs @@ -1,9 +1,13 @@ +//! Postgres database backend for FFI wallet +//! This module is only available with both the "postgres" and "async-trait" features enabled. + +#![cfg(all(feature = "postgres", feature = "async-trait"))] + 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::{ @@ -20,11 +24,9 @@ pub struct WalletPostgresDatabase { // 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() @@ -385,10 +387,9 @@ impl WalletDatabase for WalletPostgresDatabase { #[uniffi::export] impl WalletPostgresDatabase { /// Create a new Postgres-backed wallet database - /// Requires cdk-ffi to be built with feature "postgres". + /// Requires cdk-ffi to be built with features "postgres" and "async-trait". /// 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() { diff --git a/crates/cdk-ffi/src/sqlite.rs b/crates/cdk-ffi/src/sqlite.rs index 945d0df31..375b93e86 100644 --- a/crates/cdk-ffi/src/sqlite.rs +++ b/crates/cdk-ffi/src/sqlite.rs @@ -12,7 +12,7 @@ use crate::{ /// FFI-compatible WalletSqliteDatabase implementation that implements the WalletDatabase trait #[derive(uniffi::Object)] pub struct WalletSqliteDatabase { - inner: Arc, + pub(crate) inner: Arc, } use cdk::cdk_database::WalletDatabase as CdkWalletDatabase; diff --git a/crates/cdk-ffi/src/wallet.rs b/crates/cdk-ffi/src/wallet.rs index 4543a461a..81d812103 100644 --- a/crates/cdk-ffi/src/wallet.rs +++ b/crates/cdk-ffi/src/wallet.rs @@ -16,7 +16,9 @@ pub struct Wallet { inner: Arc, } -#[uniffi::export(async_runtime = "tokio")] +// Conditional constructor implementations +#[cfg(feature = "async-trait")] +#[uniffi::export] impl Wallet { /// Create a new Wallet from mnemonic using WalletDatabase trait #[uniffi::constructor] @@ -51,6 +53,50 @@ impl Wallet { inner: Arc::new(wallet), }) } +} + +#[cfg(not(feature = "async-trait"))] +#[uniffi::export] +impl Wallet { + /// Create a new Wallet from mnemonic using WalletSqliteDatabase + #[uniffi::constructor] + pub fn new( + mint_url: String, + unit: CurrencyUnit, + mnemonic: String, + db: Arc, + config: WalletConfig, + ) -> Result { + // Parse mnemonic and generate seed without passphrase + let m = Mnemonic::parse(&mnemonic) + .map_err(|e| FfiError::InvalidMnemonic { msg: e.to_string() })?; + let seed = m.to_seed_normalized(""); + + // Convert the SQLite database to a CDK database implementation + let localstore: Arc + Send + Sync> = + db.inner.clone() as Arc + Send + Sync>; + + let wallet = + CdkWalletBuilder::new() + .mint_url(mint_url.parse().map_err(|e: cdk::mint_url::Error| { + FfiError::InvalidUrl { msg: e.to_string() } + })?) + .unit(unit.into()) + .localstore(localstore) + .seed(seed) + .target_proof_count(config.target_proof_count.unwrap_or(3) as usize) + .build() + .map_err(FfiError::from)?; + + Ok(Self { + inner: Arc::new(wallet), + }) + } +} + +// Methods impl block (shared between both feature configurations) +#[uniffi::export(async_runtime = "tokio")] +impl Wallet { /// Get the mint URL pub fn mint_url(&self) -> MintUrl { diff --git a/crates/cdk-ffi/uniffi.toml b/crates/cdk-ffi/uniffi.toml index 31cb7cb44..8a0595d9e 100644 --- a/crates/cdk-ffi/uniffi.toml +++ b/crates/cdk-ffi/uniffi.toml @@ -1,3 +1,7 @@ +[bindings.dart] +package_name = "cashu_dev_kit" +cdylib_name = "cdk_ffi" + [bindings.kotlin] package_name = "org.cashudevkit" cdylib_name = "cdk_ffi" diff --git a/justfile b/justfile index b52f0e5fc..a43fd1a5f 100644 --- a/justfile +++ b/justfile @@ -521,22 +521,15 @@ ffi-generate LANGUAGE *ARGS="--release": ffi-build # Validate language case "$LANG" in - python|swift|kotlin) + python|swift|kotlin|dart) ;; *) echo "❌ Unsupported language: $LANG" - echo "Supported languages: python, swift, kotlin" + echo "Supported languages: python, swift, kotlin, dart" exit 1 ;; esac - # Set emoji and build type - case "$LANG" in - python) EMOJI="🐍" ;; - swift) EMOJI="🍎" ;; - kotlin) EMOJI="🎯" ;; - esac - # Determine build type and library path if [[ "{{ARGS}}" == *"--release"* ]] || [[ "{{ARGS}}" == "" ]]; then BUILD_TYPE="release" @@ -544,10 +537,10 @@ ffi-generate LANGUAGE *ARGS="--release": ffi-build BUILD_TYPE="debug" cargo build --package cdk-ffi --features postgres fi - + LIB_EXT=$(just _ffi-lib-ext) - - echo "$EMOJI Generating $LANG bindings..." + + echo "Generating $LANG bindings..." mkdir -p target/bindings/$LANG cargo run --bin uniffi-bindgen generate \ @@ -569,12 +562,17 @@ ffi-generate-swift *ARGS="--release": ffi-generate-kotlin *ARGS="--release": just ffi-generate kotlin {{ARGS}} +# Generate Dart bindings (shorthand) +ffi-generate-dart *ARGS="--release": + just ffi-generate dart {{ARGS}} + # Generate bindings for all supported languages ffi-generate-all *ARGS="--release": ffi-build @echo "🔧 Generating UniFFI bindings for all languages..." just ffi-generate python {{ARGS}} just ffi-generate swift {{ARGS}} just ffi-generate kotlin {{ARGS}} + just ffi-generate dart {{ARGS}} @echo "✅ All bindings generated successfully!" # Build debug version and generate Python bindings quickly (for development)