From 50b47d0a06833cb22ba06c6ce4940ed9d0e21189 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Wed, 8 Oct 2025 18:45:29 -0500 Subject: [PATCH 1/5] Integrate ln addr for spark --- orange-sdk/src/ffi/orange/wallet.rs | 12 ++++++ orange-sdk/src/lib.rs | 12 +++++- orange-sdk/src/trusted_wallet/cashu/mod.rs | 16 ++++++++ orange-sdk/src/trusted_wallet/dummy.rs | 16 ++++++++ orange-sdk/src/trusted_wallet/mod.rs | 10 +++++ orange-sdk/src/trusted_wallet/spark/mod.rs | 48 +++++++++++++++++++--- 6 files changed, 107 insertions(+), 7 deletions(-) diff --git a/orange-sdk/src/ffi/orange/wallet.rs b/orange-sdk/src/ffi/orange/wallet.rs index beb8d58..e1bc074 100644 --- a/orange-sdk/src/ffi/orange/wallet.rs +++ b/orange-sdk/src/ffi/orange/wallet.rs @@ -296,4 +296,16 @@ impl Wallet { pub fn event_handled(&self) -> bool { self.inner.event_handled().is_ok() } + + /// Gets the lightning address for this wallet, if one is set. + pub async fn get_lightning_address(&self) -> Result, WalletError> { + let result = self.inner.get_lightning_address().await?; + Ok(result) + } + + /// Attempts to register the lightning address for this wallet. + pub async fn register_lightning_address(&self, name: String) -> Result<(), WalletError> { + self.inner.register_lightning_address(name).await?; + Ok(()) + } } diff --git a/orange-sdk/src/lib.rs b/orange-sdk/src/lib.rs index 27f9f74..b8aa45b 100644 --- a/orange-sdk/src/lib.rs +++ b/orange-sdk/src/lib.rs @@ -536,7 +536,7 @@ impl Wallet { ExtraConfig::Spark(sp) => Arc::new(Box::new( Spark::init( &config, - *sp, + sp.clone(), Arc::clone(&store), Arc::clone(&event_queue), tx_metadata.clone(), @@ -1371,6 +1371,16 @@ impl Wallet { res } + /// Gets the lightning address for this wallet, if one is set. + pub async fn get_lightning_address(&self) -> Result, WalletError> { + Ok(self.inner.trusted.get_lightning_address().await?) + } + + /// Attempts to register the lightning address for this wallet. + pub async fn register_lightning_address(&self, name: String) -> Result<(), WalletError> { + Ok(self.inner.trusted.register_lightning_address(name).await?) + } + /// Stops the wallet, which will stop the underlying LDK node and any background tasks. /// This will ensure that any critical tasks have completed before stopping. pub async fn stop(&self) { diff --git a/orange-sdk/src/trusted_wallet/cashu/mod.rs b/orange-sdk/src/trusted_wallet/cashu/mod.rs index d001c95..c90e124 100644 --- a/orange-sdk/src/trusted_wallet/cashu/mod.rs +++ b/orange-sdk/src/trusted_wallet/cashu/mod.rs @@ -472,6 +472,22 @@ impl TrustedWalletInterface for Cashu { }) } + fn get_lightning_address( + &self, + ) -> Pin, TrustedError>> + Send + '_>> { + Box::pin(async { Ok(None) }) + } + + fn register_lightning_address( + &self, _name: String, + ) -> Pin> + Send + '_>> { + Box::pin(async { + Err(TrustedError::UnsupportedOperation( + "register_lightning_address is not supported in Cashu Wallet".to_string(), + )) + }) + } + fn stop(&self) -> Pin + Send + '_>> { Box::pin(async move { log_info!(self.logger, "Stopping Cashu wallet"); diff --git a/orange-sdk/src/trusted_wallet/dummy.rs b/orange-sdk/src/trusted_wallet/dummy.rs index 2c8c241..b34cb11 100644 --- a/orange-sdk/src/trusted_wallet/dummy.rs +++ b/orange-sdk/src/trusted_wallet/dummy.rs @@ -412,6 +412,22 @@ impl TrustedWalletInterface for DummyTrustedWallet { }) } + fn get_lightning_address( + &self, + ) -> Pin, TrustedError>> + Send + '_>> { + Box::pin(async { Ok(None) }) + } + + fn register_lightning_address( + &self, _name: String, + ) -> Pin> + Send + '_>> { + Box::pin(async { + Err(TrustedError::UnsupportedOperation( + "register_lightning_address is not supported in DummyTrustedWallet".to_string(), + )) + }) + } + fn stop(&self) -> Pin + Send + '_>> { Box::pin(async move { let _ = self.ldk_node.stop(); diff --git a/orange-sdk/src/trusted_wallet/mod.rs b/orange-sdk/src/trusted_wallet/mod.rs index 19d9874..5f76082 100644 --- a/orange-sdk/src/trusted_wallet/mod.rs +++ b/orange-sdk/src/trusted_wallet/mod.rs @@ -89,6 +89,16 @@ pub trait TrustedWalletInterface: Send + Sync + private::Sealed { &self, payment_hash: [u8; 32], ) -> Pin> + Send + '_>>; + /// Gets the lightning address for this wallet, if one is set. + fn get_lightning_address( + &self, + ) -> Pin, TrustedError>> + Send + '_>>; + + /// Attempts to register the lightning address for this wallet. + fn register_lightning_address( + &self, name: String, + ) -> Pin> + Send + '_>>; + /// Stops the wallet, cleaning up any resources. /// This is typically used to gracefully shut down the wallet. fn stop(&self) -> Pin + Send + '_>>; diff --git a/orange-sdk/src/trusted_wallet/spark/mod.rs b/orange-sdk/src/trusted_wallet/spark/mod.rs index 17dc4f9..3a128f4 100644 --- a/orange-sdk/src/trusted_wallet/spark/mod.rs +++ b/orange-sdk/src/trusted_wallet/spark/mod.rs @@ -21,7 +21,8 @@ use bitcoin_payment_instructions::amount::Amount; use breez_sdk_spark::{ BreezSdk, EventListener, GetInfoRequest, ListPaymentsRequest, OptimizationConfig, PaymentDetails, PaymentStatus, PaymentType, PrepareSendPaymentRequest, ReceivePaymentMethod, - ReceivePaymentRequest, SdkBuilder, SdkError, SdkEvent, SendPaymentMethod, SendPaymentRequest, + ReceivePaymentRequest, RegisterLightningAddressRequest, SdkBuilder, SdkError, SdkEvent, + SendPaymentMethod, SendPaymentRequest, }; use graduated_rebalancer::ReceivedLightningPayment; @@ -37,7 +38,7 @@ use std::time::Duration; use uuid::Uuid; /// Configuration options for the Spark wallet. -#[derive(Debug, Copy, Clone)] +#[derive(Debug, Clone)] pub struct SparkWalletConfig { /// How often to sync the wallet with the blockchain, in seconds. /// Default is 60 seconds. @@ -46,11 +47,17 @@ pub struct SparkWalletConfig { /// lightning when sending and receiving. This has the benefit of lower fees /// but is at the cost of privacy. pub prefer_spark_over_lightning: bool, + /// The domain used for receiving through lnurl-pay and lightning address. + pub lnurl_domain: Option, } impl Default for SparkWalletConfig { fn default() -> Self { - SparkWalletConfig { sync_interval_secs: 60, prefer_spark_over_lightning: false } + SparkWalletConfig { + sync_interval_secs: 60, + prefer_spark_over_lightning: false, + lnurl_domain: Some("breez.tips".to_string()), + } } } @@ -59,7 +66,7 @@ impl Default for SparkWalletConfig { const BREEZ_API_KEY: &str = "MIIBajCCARygAwIBAgIHPnfOjAhBgzAFBgMrZXAwEDEOMAwGA1UEAxMFQnJlZXowHhcNMjUwOTE5MjEzNTU1WhcNMzUwOTE3MjEzNTU1WjAqMRMwEQYDVQQKEwpvcmFuZ2Utc2RrMRMwEQYDVQQDEwpvcmFuZ2Utc2RrMCowBQYDK2VwAyEA0IP1y98gPByiIMoph1P0G6cctLb864rNXw1LRLOpXXejezB5MA4GA1UdDwEB/wQEAwIFoDAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBTaOaPuXmtLDTJVv++VYBiQr9gHCTAfBgNVHSMEGDAWgBTeqtaSVvON53SSFvxMtiCyayiYazAZBgNVHREEEjAQgQ5iZW5Ac3BpcmFsLnh5ejAFBgMrZXADQQCry+1LkA3nrYa1sovS5iFI1Tkpmr/R0nM/4gJtsO93vFOkm3vBEGwjKAV7lrGzFcFbbuyM1wEJPi4Po1XCEG0D"; impl SparkWalletConfig { - fn to_breez_config(self, network: Network) -> Result { + fn into_breez_config(self, network: Network) -> Result { let network = match network { Network::Bitcoin => breez_sdk_spark::Network::Mainnet, Network::Regtest => breez_sdk_spark::Network::Regtest, @@ -75,7 +82,7 @@ impl SparkWalletConfig { real_time_sync_server_url: None, api_key: Some(BREEZ_API_KEY.to_string()), max_deposit_claim_fee: None, - lnurl_domain: None, + lnurl_domain: self.lnurl_domain, private_enabled_default: true, optimization_config: OptimizationConfig { auto_enabled: true, multiplicity: 1 }, }) @@ -253,6 +260,34 @@ impl TrustedWalletInterface for Spark { }) } + fn get_lightning_address( + &self, + ) -> Pin, TrustedError>> + Send + '_>> { + Box::pin(async move { + match self.spark_wallet.get_lightning_address().await? { + None => Ok(None), + Some(addr) => Ok(Some(addr.lightning_address)), + } + }) + } + + fn register_lightning_address( + &self, name: String, + ) -> Pin> + Send + '_>> { + Box::pin(async move { + let res = self.get_lightning_address().await?; + if res.is_some() { + return Err(TrustedError::Other( + "Wallet already has a lightning address".to_string(), + )); + } + + let params = RegisterLightningAddressRequest { username: name, description: None }; + self.spark_wallet.register_lightning_address(params).await?; + Ok(()) + }) + } + fn stop(&self) -> Pin + Send + '_>> { Box::pin(async move { log_info!(self.logger, "Stopping Spark wallet"); @@ -268,7 +303,8 @@ impl Spark { event_queue: Arc, tx_metadata: TxMetadataStore, logger: Arc, runtime: Arc, ) -> Result { - let spark_config: breez_sdk_spark::Config = spark_config.to_breez_config(config.network)?; + let spark_config: breez_sdk_spark::Config = + spark_config.into_breez_config(config.network)?; let seed = match &config.seed { Seed::Seed64(bytes) => breez_sdk_spark::Seed::Entropy(bytes.to_vec()), From 78c700939e358752be4717ace593e307f23ff41e Mon Sep 17 00:00:00 2001 From: benthecarman Date: Wed, 8 Oct 2025 18:55:19 -0500 Subject: [PATCH 2/5] Add ln addr to example cli --- examples/cli/src/main.rs | 75 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/examples/cli/src/main.rs b/examples/cli/src/main.rs index 138716b..e03920b 100644 --- a/examples/cli/src/main.rs +++ b/examples/cli/src/main.rs @@ -56,6 +56,13 @@ enum Commands { /// Amount in sats (optional) amount: Option, }, + /// Get the current lightning address + GetLightningAddress, + /// Register a lightning address + RegisterLightningAddress { + /// The lightning address name to register + name: String, + }, /// Clear the screen Clear, /// Exit the application @@ -367,6 +374,14 @@ fn parse_command(input: &str) -> Result { Ok(Commands::EstimateFee { destination, amount }) }, + "get-lightning-address" | "get-ln-addr" | "ln-addr" => Ok(Commands::GetLightningAddress), + "register-lightning-address" | "register-ln-addr" => { + if parts.len() < 2 { + return Err(anyhow::anyhow!("Usage: register-lightning-address ")); + } + let name = parts[1].to_string(); + Ok(Commands::RegisterLightningAddress { name }) + }, "clear" | "cls" => Ok(Commands::Clear), "exit" | "quit" | "q" => Ok(Commands::Exit), "help" => { @@ -590,6 +605,60 @@ async fn execute_command(command: Commands, state: &mut WalletState) -> Result<( }, } }, + Commands::GetLightningAddress => { + let wallet = state.wallet(); + + println!("{} Fetching lightning address...", "⚡".bright_yellow()); + + match wallet.get_lightning_address().await { + Ok(Some(address)) => { + println!( + "{} Lightning address: {}", + "⚡".bright_green(), + address.bright_cyan() + ); + }, + Ok(None) => { + println!("{} No lightning address registered yet.", "⚡".bright_yellow()); + println!( + "{} Use 'register-lightning-address ' to register one", + "Hint:".bright_yellow().bold() + ); + }, + Err(e) => { + return Err(anyhow::anyhow!("Failed to get lightning address: {:?}", e)); + }, + } + }, + Commands::RegisterLightningAddress { name } => { + let wallet = state.wallet(); + + println!( + "{} Registering lightning address: {}...", + "⚡".bright_yellow(), + name.bright_cyan() + ); + + match wallet.register_lightning_address(name.clone()).await { + Ok(()) => { + println!("{} Lightning address registered successfully!", "✅".bright_green()); + // Fetch and display the full address + match wallet.get_lightning_address().await { + Ok(Some(address)) => { + println!( + "{} Your lightning address: {}", + "⚡".bright_green(), + address.bright_cyan() + ); + }, + _ => {}, + } + }, + Err(e) => { + return Err(anyhow::anyhow!("Failed to register lightning address: {:?}", e)); + }, + } + }, Commands::Clear => { print!("\x1B[2J\x1B[1;1H"); std::io::stdout().flush().unwrap(); @@ -628,6 +697,12 @@ fn print_help() { println!(" {} [amount]", "estimate-fee".bright_green().bold()); println!(" Estimate the fee for a payment"); println!(); + println!(" {}", "get-lightning-address".bright_green().bold()); + println!(" Get the current lightning address"); + println!(); + println!(" {} ", "register-lightning-address".bright_green().bold()); + println!(" Register a lightning address"); + println!(); println!(" {}", "clear".bright_green().bold()); println!(" Clear the terminal screen"); println!(); From 94ec79c0be7c3c180a32ca02c3af09b1e136d2dd Mon Sep 17 00:00:00 2001 From: benthecarman Date: Wed, 25 Feb 2026 17:19:45 -0600 Subject: [PATCH 3/5] Update cdk to 0.15.1 Co-Authored-By: Claude Opus 4.6 (1M context) --- orange-sdk/Cargo.toml | 8 +- .../src/trusted_wallet/cashu/cashu_store.rs | 331 ++++++++++++++++-- orange-sdk/src/trusted_wallet/cashu/mod.rs | 58 ++- orange-sdk/tests/test_utils.rs | 15 +- 4 files changed, 353 insertions(+), 59 deletions(-) diff --git a/orange-sdk/Cargo.toml b/orange-sdk/Cargo.toml index 75b3e13..2a1998b 100644 --- a/orange-sdk/Cargo.toml +++ b/orange-sdk/Cargo.toml @@ -31,16 +31,16 @@ rand = { version = "0.8.5", optional = true } breez-sdk-spark = { git = "https://github.com/breez/spark-sdk.git", rev = "ef76a0bc517bea38fafaff8f657e82b5b52569b9", default-features = false, features = ["rustls-tls"], optional = true } tokio = { version = "1.0", default-features = false, features = ["rt-multi-thread", "sync", "macros"] } uuid = { version = "1.0", default-features = false, optional = true } -cdk = { version = "0.14.2", default-features = false, features = ["wallet"], optional = true } +cdk = { version = "0.15.1", default-features = false, features = ["wallet"], optional = true } serde_json = { version = "1.0", optional = true } async-trait = "0.1" log = "0.4.28" corepc-node = { version = "0.10.1", features = ["29_0", "download"], optional = true } electrsd = { version = "0.36.1", default-features = false, features = ["esplora_a33e97e1", "corepc-node_29_0"], optional = true } -cdk-ldk-node = { version = "0.14.2", optional = true } -cdk-sqlite = { version = "0.14.2", optional = true } -cdk-axum = { version = "0.14.2", optional = true } +cdk-ldk-node = { version = "0.15.1", optional = true } +cdk-sqlite = { version = "0.15.1", optional = true } +cdk-axum = { version = "0.15.1", optional = true } axum = { version = "0.8.1", optional = true } uniffi = { version = "0.29", default-features = false, features = ["cli"], optional = true } diff --git a/orange-sdk/src/trusted_wallet/cashu/cashu_store.rs b/orange-sdk/src/trusted_wallet/cashu/cashu_store.rs index 50f3407..bdb797e 100644 --- a/orange-sdk/src/trusted_wallet/cashu/cashu_store.rs +++ b/orange-sdk/src/trusted_wallet/cashu/cashu_store.rs @@ -5,6 +5,7 @@ use std::sync::{Arc, RwLock}; use async_trait::async_trait; use cdk::cdk_database::WalletDatabase; +use cdk::wallet::types::WalletSaga; use ldk_node::DynStore; use ldk_node::lightning::io; use ldk_node::lightning::util::persist::KVStore; @@ -37,6 +38,8 @@ const KEYSET_COUNTERS_KEY: &str = "keyset_counters"; const TRANSACTIONS_KEY: &str = "transactions"; const KEYSETS_TABLE_KEY: &str = "keysets_table"; const KEYSET_U32_MAPPING_KEY: &str = "keyset_u32_mapping"; +const SAGAS_KEY: &str = "sagas"; +const PROOF_RESERVATIONS_KEY: &str = "proof_reservations"; const HAS_RECOVERED_KEY: &str = "has_recovered"; /// Error type for database operations @@ -276,12 +279,10 @@ impl CashuKvDatabase { } #[async_trait] -impl WalletDatabase for CashuKvDatabase { - type Err = cdk::cdk_database::Error; - +impl WalletDatabase for CashuKvDatabase { async fn add_mint( &self, mint_url: MintUrl, mint_info: Option, - ) -> Result<(), Self::Err> { + ) -> Result<(), cdk::cdk_database::Error> { // Save mint URL using hashed key let mint_key = Self::generate_mint_key(&mint_url); let mint_data = serde_json::to_vec(&mint_url) @@ -305,7 +306,7 @@ impl WalletDatabase for CashuKvDatabase { Ok(()) } - async fn remove_mint(&self, mint_url: MintUrl) -> Result<(), Self::Err> { + async fn remove_mint(&self, mint_url: MintUrl) -> Result<(), cdk::cdk_database::Error> { let mint_key = Self::generate_mint_key(&mint_url); // Remove mint URL by writing empty data @@ -340,7 +341,9 @@ impl WalletDatabase for CashuKvDatabase { Ok(()) } - async fn get_mint(&self, mint_url: MintUrl) -> Result, Self::Err> { + async fn get_mint( + &self, mint_url: MintUrl, + ) -> Result, cdk::cdk_database::Error> { // Check cache first { let cache = self.mints_cache.read().unwrap(); @@ -353,14 +356,16 @@ impl WalletDatabase for CashuKvDatabase { self.load_mint_info(&mint_url).await.map_err(Into::into) } - async fn get_mints(&self) -> Result>, Self::Err> { + async fn get_mints( + &self, + ) -> Result>, cdk::cdk_database::Error> { let cache = self.mints_cache.read().unwrap(); Ok(cache.clone()) } async fn update_mint_url( &self, old_mint_url: MintUrl, new_mint_url: MintUrl, - ) -> Result<(), Self::Err> { + ) -> Result<(), cdk::cdk_database::Error> { // Get the mint info from the old URL let mint_info = self.get_mint(old_mint_url.clone()).await?; @@ -383,7 +388,7 @@ impl WalletDatabase for CashuKvDatabase { async fn add_mint_keysets( &self, mint_url: MintUrl, keysets: Vec, - ) -> Result<(), Self::Err> { + ) -> Result<(), cdk::cdk_database::Error> { let mut existing_u32 = false; let mut updated_keysets = Vec::new(); @@ -503,7 +508,7 @@ impl WalletDatabase for CashuKvDatabase { async fn get_mint_keysets( &self, mint_url: MintUrl, - ) -> Result>, Self::Err> { + ) -> Result>, cdk::cdk_database::Error> { let key = Self::generate_mint_keysets_key(&mint_url); match KVStore::read(self.store.as_ref(), CASHU_PRIMARY_KEY, MINT_KEYSETS_KEY, &key).await { @@ -520,7 +525,9 @@ impl WalletDatabase for CashuKvDatabase { } } - async fn get_keyset_by_id(&self, keyset_id: &Id) -> Result, Self::Err> { + async fn get_keyset_by_id( + &self, keyset_id: &Id, + ) -> Result, cdk::cdk_database::Error> { // Read directly from the dedicated KEYSETS_TABLE keyed by the keyset ID for efficiency let key = format!("keyset_{}", keyset_id); match KVStore::read(self.store.as_ref(), CASHU_PRIMARY_KEY, KEYSETS_TABLE_KEY, &key).await { @@ -535,7 +542,7 @@ impl WalletDatabase for CashuKvDatabase { } } - async fn add_mint_quote(&self, quote: MintQuote) -> Result<(), Self::Err> { + async fn add_mint_quote(&self, quote: MintQuote) -> Result<(), cdk::cdk_database::Error> { let key = quote.id.clone(); let data = serde_json::to_vec("e).map_err(|e| DatabaseError::Serialization(e.to_string()))?; @@ -547,7 +554,9 @@ impl WalletDatabase for CashuKvDatabase { Ok(()) } - async fn get_mint_quote(&self, quote_id: &str) -> Result, Self::Err> { + async fn get_mint_quote( + &self, quote_id: &str, + ) -> Result, cdk::cdk_database::Error> { match KVStore::read(self.store.as_ref(), CASHU_PRIMARY_KEY, MINT_QUOTES_KEY, quote_id).await { Ok(data) => { @@ -563,7 +572,7 @@ impl WalletDatabase for CashuKvDatabase { } } - async fn get_mint_quotes(&self) -> Result, Self::Err> { + async fn get_mint_quotes(&self) -> Result, cdk::cdk_database::Error> { let keys = KVStore::list(self.store.as_ref(), CASHU_PRIMARY_KEY, MINT_QUOTES_KEY) .await .map_err(DatabaseError::Io)?; @@ -584,7 +593,7 @@ impl WalletDatabase for CashuKvDatabase { Ok(quotes) } - async fn remove_mint_quote(&self, quote_id: &str) -> Result<(), Self::Err> { + async fn remove_mint_quote(&self, quote_id: &str) -> Result<(), cdk::cdk_database::Error> { // Mark as removed by writing empty data KVStore::remove(self.store.as_ref(), CASHU_PRIMARY_KEY, MINT_QUOTES_KEY, quote_id, false) .await @@ -593,7 +602,7 @@ impl WalletDatabase for CashuKvDatabase { Ok(()) } - async fn add_melt_quote(&self, quote: MeltQuote) -> Result<(), Self::Err> { + async fn add_melt_quote(&self, quote: MeltQuote) -> Result<(), cdk::cdk_database::Error> { let key = quote.id.clone(); let data = serde_json::to_vec("e).map_err(|e| DatabaseError::Serialization(e.to_string()))?; @@ -605,7 +614,9 @@ impl WalletDatabase for CashuKvDatabase { Ok(()) } - async fn get_melt_quote(&self, quote_id: &str) -> Result, Self::Err> { + async fn get_melt_quote( + &self, quote_id: &str, + ) -> Result, cdk::cdk_database::Error> { match KVStore::read(self.store.as_ref(), CASHU_PRIMARY_KEY, MELT_QUOTES_KEY, quote_id).await { Ok(data) => { @@ -621,7 +632,7 @@ impl WalletDatabase for CashuKvDatabase { } } - async fn get_melt_quotes(&self) -> Result, Self::Err> { + async fn get_melt_quotes(&self) -> Result, cdk::cdk_database::Error> { let keys = KVStore::list(self.store.as_ref(), CASHU_PRIMARY_KEY, MELT_QUOTES_KEY) .await .map_err(DatabaseError::Io)?; @@ -642,7 +653,7 @@ impl WalletDatabase for CashuKvDatabase { Ok(quotes) } - async fn remove_melt_quote(&self, quote_id: &str) -> Result<(), Self::Err> { + async fn remove_melt_quote(&self, quote_id: &str) -> Result<(), cdk::cdk_database::Error> { KVStore::remove(self.store.as_ref(), CASHU_PRIMARY_KEY, MELT_QUOTES_KEY, quote_id, false) .await .map_err(DatabaseError::Io)?; @@ -650,7 +661,7 @@ impl WalletDatabase for CashuKvDatabase { Ok(()) } - async fn add_keys(&self, keyset: KeySet) -> Result<(), Self::Err> { + async fn add_keys(&self, keyset: KeySet) -> Result<(), cdk::cdk_database::Error> { keyset.verify_id().map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?; if self.get_keys(&keyset.id).await?.is_some() { @@ -668,7 +679,7 @@ impl WalletDatabase for CashuKvDatabase { Ok(()) } - async fn get_keys(&self, id: &Id) -> Result, Self::Err> { + async fn get_keys(&self, id: &Id) -> Result, cdk::cdk_database::Error> { let key = id.to_string(); match KVStore::read(self.store.as_ref(), CASHU_PRIMARY_KEY, KEYS_KEY, &key).await { @@ -685,7 +696,7 @@ impl WalletDatabase for CashuKvDatabase { } } - async fn remove_keys(&self, id: &Id) -> Result<(), Self::Err> { + async fn remove_keys(&self, id: &Id) -> Result<(), cdk::cdk_database::Error> { let key = id.to_string(); KVStore::remove(self.store.as_ref(), CASHU_PRIMARY_KEY, KEYS_KEY, &key, false) @@ -697,7 +708,7 @@ impl WalletDatabase for CashuKvDatabase { async fn update_proofs( &self, added: Vec, removed_ys: Vec, - ) -> Result<(), Self::Err> { + ) -> Result<(), cdk::cdk_database::Error> { // Add new proofs for proof in &added { let key = Self::generate_proof_key(proof); @@ -735,7 +746,7 @@ impl WalletDatabase for CashuKvDatabase { async fn get_proofs( &self, mint_url: Option, unit: Option, state: Option>, spending_conditions: Option>, - ) -> Result, Self::Err> { + ) -> Result, cdk::cdk_database::Error> { let cache = self.proofs_cache.read().unwrap(); let mut filtered_proofs = cache.clone(); @@ -767,12 +778,14 @@ impl WalletDatabase for CashuKvDatabase { async fn get_balance( &self, mint_url: Option, unit: Option, state: Option>, - ) -> Result { + ) -> Result { let proofs = self.get_proofs(mint_url, unit, state, None).await?; Ok(proofs.iter().map(|p| u64::from(p.proof.amount)).sum()) } - async fn update_proofs_state(&self, ys: Vec, state: State) -> Result<(), Self::Err> { + async fn update_proofs_state( + &self, ys: Vec, state: State, + ) -> Result<(), cdk::cdk_database::Error> { // Update proofs in storage and cache for y in &ys { let key = format!("proof_{}", hex::encode(y.serialize())); @@ -817,7 +830,9 @@ impl WalletDatabase for CashuKvDatabase { Ok(()) } - async fn increment_keyset_counter(&self, keyset_id: &Id, count: u32) -> Result { + async fn increment_keyset_counter( + &self, keyset_id: &Id, count: u32, + ) -> Result { let key = keyset_id.to_string(); // Read current counter @@ -843,7 +858,9 @@ impl WalletDatabase for CashuKvDatabase { Ok(new_count) } - async fn add_transaction(&self, transaction: Transaction) -> Result<(), Self::Err> { + async fn add_transaction( + &self, transaction: Transaction, + ) -> Result<(), cdk::cdk_database::Error> { let key = transaction.id().to_string(); let data = serde_json::to_vec(&transaction) .map_err(|e| DatabaseError::Serialization(e.to_string()))?; @@ -857,7 +874,7 @@ impl WalletDatabase for CashuKvDatabase { async fn get_transaction( &self, transaction_id: TransactionId, - ) -> Result, Self::Err> { + ) -> Result, cdk::cdk_database::Error> { let key = transaction_id.to_string(); match KVStore::read(self.store.as_ref(), CASHU_PRIMARY_KEY, TRANSACTIONS_KEY, &key).await { @@ -877,7 +894,7 @@ impl WalletDatabase for CashuKvDatabase { async fn list_transactions( &self, mint_url: Option, direction: Option, unit: Option, - ) -> Result, Self::Err> { + ) -> Result, cdk::cdk_database::Error> { let keys = KVStore::list(self.store.as_ref(), CASHU_PRIMARY_KEY, TRANSACTIONS_KEY) .await .map_err(DatabaseError::Io)?; @@ -923,7 +940,9 @@ impl WalletDatabase for CashuKvDatabase { Ok(transactions) } - async fn remove_transaction(&self, transaction_id: TransactionId) -> Result<(), Self::Err> { + async fn remove_transaction( + &self, transaction_id: TransactionId, + ) -> Result<(), cdk::cdk_database::Error> { let key = transaction_id.to_string(); KVStore::remove(self.store.as_ref(), CASHU_PRIMARY_KEY, TRANSACTIONS_KEY, &key, false) @@ -932,6 +951,256 @@ impl WalletDatabase for CashuKvDatabase { Ok(()) } + + async fn get_unissued_mint_quotes(&self) -> Result, cdk::cdk_database::Error> { + let all_quotes = self.get_mint_quotes().await?; + Ok(all_quotes + .into_iter() + .filter(|q| q.amount_issued == cdk::Amount::ZERO || q.payment_method.is_bolt12()) + .collect()) + } + + async fn get_proofs_by_ys( + &self, ys: Vec, + ) -> Result, cdk::cdk_database::Error> { + let cache = self.proofs_cache.read().unwrap(); + Ok(cache.iter().filter(|p| ys.contains(&p.y)).cloned().collect()) + } + + async fn add_saga(&self, saga: WalletSaga) -> Result<(), cdk::cdk_database::Error> { + let key = saga.id.to_string(); + let data = + serde_json::to_vec(&saga).map_err(|e| DatabaseError::Serialization(e.to_string()))?; + KVStore::write(self.store.as_ref(), CASHU_PRIMARY_KEY, SAGAS_KEY, &key, data) + .await + .map_err(DatabaseError::Io)?; + Ok(()) + } + + async fn get_saga( + &self, id: &uuid::Uuid, + ) -> Result, cdk::cdk_database::Error> { + let key = id.to_string(); + match KVStore::read(self.store.as_ref(), CASHU_PRIMARY_KEY, SAGAS_KEY, &key).await { + Ok(data) if !data.is_empty() => { + let saga: WalletSaga = serde_json::from_slice(&data) + .map_err(|e| DatabaseError::Serialization(e.to_string()))?; + Ok(Some(saga)) + }, + Ok(_) => Ok(None), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(DatabaseError::Io(e).into()), + } + } + + async fn update_saga(&self, saga: WalletSaga) -> Result { + let key = saga.id.to_string(); + let data = + serde_json::to_vec(&saga).map_err(|e| DatabaseError::Serialization(e.to_string()))?; + KVStore::write(self.store.as_ref(), CASHU_PRIMARY_KEY, SAGAS_KEY, &key, data) + .await + .map_err(DatabaseError::Io)?; + Ok(true) + } + + async fn delete_saga(&self, id: &uuid::Uuid) -> Result<(), cdk::cdk_database::Error> { + let key = id.to_string(); + KVStore::remove(self.store.as_ref(), CASHU_PRIMARY_KEY, SAGAS_KEY, &key, false) + .await + .map_err(DatabaseError::Io)?; + Ok(()) + } + + async fn get_incomplete_sagas(&self) -> Result, cdk::cdk_database::Error> { + let keys = KVStore::list(self.store.as_ref(), CASHU_PRIMARY_KEY, SAGAS_KEY) + .await + .map_err(DatabaseError::Io)?; + let mut sagas = Vec::new(); + for key in keys { + let data = KVStore::read(self.store.as_ref(), CASHU_PRIMARY_KEY, SAGAS_KEY, &key) + .await + .map_err(DatabaseError::Io)?; + if !data.is_empty() { + if let Ok(saga) = serde_json::from_slice::(&data) { + sagas.push(saga); + } + } + } + Ok(sagas) + } + + async fn reserve_proofs( + &self, ys: Vec, operation_id: &uuid::Uuid, + ) -> Result<(), cdk::cdk_database::Error> { + // Store which Y values are reserved by this operation + let key = operation_id.to_string(); + let ys_data: Vec = ys.iter().map(|y| hex::encode(y.serialize())).collect(); + let data = serde_json::to_vec(&ys_data) + .map_err(|e| DatabaseError::Serialization(e.to_string()))?; + KVStore::write(self.store.as_ref(), CASHU_PRIMARY_KEY, PROOF_RESERVATIONS_KEY, &key, data) + .await + .map_err(DatabaseError::Io)?; + + self.update_proofs_state(ys, State::Reserved).await + } + + async fn release_proofs( + &self, operation_id: &uuid::Uuid, + ) -> Result<(), cdk::cdk_database::Error> { + let key = operation_id.to_string(); + match KVStore::read(self.store.as_ref(), CASHU_PRIMARY_KEY, PROOF_RESERVATIONS_KEY, &key) + .await + { + Ok(data) if !data.is_empty() => { + let ys_hex: Vec = serde_json::from_slice(&data) + .map_err(|e| DatabaseError::Serialization(e.to_string()))?; + let ys: Vec = ys_hex + .iter() + .filter_map(|h| { + let bytes = hex::decode(h).ok()?; + PublicKey::from_slice(&bytes).ok() + }) + .collect(); + if !ys.is_empty() { + self.update_proofs_state(ys, State::Unspent).await?; + } + KVStore::remove( + self.store.as_ref(), + CASHU_PRIMARY_KEY, + PROOF_RESERVATIONS_KEY, + &key, + false, + ) + .await + .map_err(DatabaseError::Io)?; + }, + _ => {}, + } + Ok(()) + } + + async fn get_reserved_proofs( + &self, operation_id: &uuid::Uuid, + ) -> Result, cdk::cdk_database::Error> { + let key = operation_id.to_string(); + match KVStore::read(self.store.as_ref(), CASHU_PRIMARY_KEY, PROOF_RESERVATIONS_KEY, &key) + .await + { + Ok(data) if !data.is_empty() => { + let ys_hex: Vec = serde_json::from_slice(&data) + .map_err(|e| DatabaseError::Serialization(e.to_string()))?; + let ys: Vec = ys_hex + .iter() + .filter_map(|h| { + let bytes = hex::decode(h).ok()?; + PublicKey::from_slice(&bytes).ok() + }) + .collect(); + self.get_proofs_by_ys(ys).await + }, + _ => Ok(Vec::new()), + } + } + + async fn reserve_melt_quote( + &self, quote_id: &str, operation_id: &uuid::Uuid, + ) -> Result<(), cdk::cdk_database::Error> { + if let Some(mut quote) = self.get_melt_quote(quote_id).await? { + if quote.used_by_operation.is_some() { + return Err(cdk::cdk_database::Error::Database( + "Quote already in use by another operation".to_string().into(), + )); + } + quote.used_by_operation = Some(operation_id.to_string()); + self.add_melt_quote(quote).await?; + } + Ok(()) + } + + async fn release_melt_quote( + &self, operation_id: &uuid::Uuid, + ) -> Result<(), cdk::cdk_database::Error> { + let quotes = self.get_melt_quotes().await?; + let op_str = operation_id.to_string(); + for mut quote in quotes { + if quote.used_by_operation.as_deref() == Some(&op_str) { + quote.used_by_operation = None; + self.add_melt_quote(quote).await?; + } + } + Ok(()) + } + + async fn reserve_mint_quote( + &self, quote_id: &str, operation_id: &uuid::Uuid, + ) -> Result<(), cdk::cdk_database::Error> { + if let Some(mut quote) = self.get_mint_quote(quote_id).await? { + if quote.used_by_operation.is_some() { + return Err(cdk::cdk_database::Error::Database( + "Quote already in use by another operation".to_string().into(), + )); + } + quote.used_by_operation = Some(operation_id.to_string()); + self.add_mint_quote(quote).await?; + } + Ok(()) + } + + async fn release_mint_quote( + &self, operation_id: &uuid::Uuid, + ) -> Result<(), cdk::cdk_database::Error> { + let quotes = self.get_mint_quotes().await?; + let op_str = operation_id.to_string(); + for mut quote in quotes { + if quote.used_by_operation.as_deref() == Some(&op_str) { + quote.used_by_operation = None; + self.add_mint_quote(quote).await?; + } + } + Ok(()) + } + + async fn kv_read( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, + ) -> Result>, cdk::cdk_database::Error> { + match KVStore::read(self.store.as_ref(), primary_namespace, secondary_namespace, key).await + { + Ok(data) if !data.is_empty() => Ok(Some(data.to_vec())), + Ok(_) => Ok(None), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(DatabaseError::Io(e).into()), + } + } + + async fn kv_list( + &self, primary_namespace: &str, secondary_namespace: &str, + ) -> Result, cdk::cdk_database::Error> { + KVStore::list(self.store.as_ref(), primary_namespace, secondary_namespace) + .await + .map_err(|e| DatabaseError::Io(e).into()) + } + + async fn kv_write( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, value: &[u8], + ) -> Result<(), cdk::cdk_database::Error> { + KVStore::write( + self.store.as_ref(), + primary_namespace, + secondary_namespace, + key, + value.to_vec(), + ) + .await + .map_err(|e| DatabaseError::Io(e).into()) + } + + async fn kv_remove( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, + ) -> Result<(), cdk::cdk_database::Error> { + KVStore::remove(self.store.as_ref(), primary_namespace, secondary_namespace, key, false) + .await + .map_err(|e| DatabaseError::Io(e).into()) + } } pub(super) async fn read_has_recovered(store: &Arc) -> Result { diff --git a/orange-sdk/src/trusted_wallet/cashu/mod.rs b/orange-sdk/src/trusted_wallet/cashu/mod.rs index c90e124..d8d262d 100644 --- a/orange-sdk/src/trusted_wallet/cashu/mod.rs +++ b/orange-sdk/src/trusted_wallet/cashu/mod.rs @@ -21,6 +21,7 @@ use bitcoin_payment_instructions::amount::Amount; use cdk::amount::SplitTarget; use cdk::nuts::MeltOptions; +use cdk::nuts::nut00::PaymentMethod as CdkPaymentMethod; use cdk::nuts::nut23::Amountless; use cdk::nuts::{CurrencyUnit, MeltQuoteState}; use cdk::wallet::MintQuote; @@ -92,8 +93,11 @@ impl TrustedWalletInterface for Cashu { )); } - let mint_quote = - self.cashu_wallet.mint_bolt12_quote(None, None).await.map_err(|e| { + let mint_quote = self + .cashu_wallet + .mint_quote(CdkPaymentMethod::BOLT12, None, None, None) + .await + .map_err(|e| { TrustedError::WalletOperationFailed(format!("Failed to create mint quote: {e}")) })?; @@ -134,8 +138,11 @@ impl TrustedWalletInterface for Cashu { ))); }, }; - let quote = - self.cashu_wallet.mint_quote(cdk_amount, None).await.map_err(|e| { + let quote = self + .cashu_wallet + .mint_quote(CdkPaymentMethod::BOLT11, Some(cdk_amount), None, None) + .await + .map_err(|e| { TrustedError::WalletOperationFailed(format!( "Failed to create mint quote: {e}" )) @@ -191,7 +198,12 @@ impl TrustedWalletInterface for Cashu { PaymentMethod::LightningBolt11(invoice) => { let quote = self .cashu_wallet - .melt_quote(invoice.to_string(), melt_options) + .melt_quote( + CdkPaymentMethod::BOLT11, + invoice.to_string(), + melt_options, + None, + ) .await .map_err(|e| { TrustedError::WalletOperationFailed(format!( @@ -205,7 +217,7 @@ impl TrustedWalletInterface for Cashu { PaymentMethod::LightningBolt12(offer) => { let quote = self .cashu_wallet - .melt_bolt12_quote(offer.to_string(), melt_options) + .melt_quote(CdkPaymentMethod::BOLT12, offer.to_string(), melt_options, None) .await .map_err(|e| { TrustedError::WalletOperationFailed(format!( @@ -253,7 +265,12 @@ impl TrustedWalletInterface for Cashu { Some(q) => q, None => self .cashu_wallet - .melt_quote(invoice.to_string(), melt_options) + .melt_quote( + CdkPaymentMethod::BOLT11, + invoice.to_string(), + melt_options, + None, + ) .await .map_err(|e| { TrustedError::WalletOperationFailed(format!( @@ -272,7 +289,7 @@ impl TrustedWalletInterface for Cashu { // todo probably should check for existing active quote here as well self.cashu_wallet - .melt_bolt12_quote(offer.to_string(), melt_options) + .melt_quote(CdkPaymentMethod::BOLT12, offer.to_string(), melt_options, None) .await .map_err(|e| { TrustedError::WalletOperationFailed(format!( @@ -304,9 +321,14 @@ impl TrustedWalletInterface for Cashu { metadata.insert(PAYMENT_HASH_METADATA_KEY.to_string(), hash.to_string()); } - match cashu_wallet.melt_with_metadata("e_id, metadata).await { + let melt_result = async { + let prepared = cashu_wallet.prepare_melt("e_id, metadata).await?; + prepared.confirm().await + } + .await; + match melt_result { Ok(res) => { - match res.state { + match res.state() { MeltQuoteState::Paid => { log_info!(logger, "Successfully sent for quote: {quote_id}"); @@ -321,14 +343,14 @@ impl TrustedWalletInterface for Cashu { return; } - let preimage: Option = match &res.preimage { + let preimage: Option = match res.payment_proof() { Some(str) => match FromHex::from_hex(str) { Ok(b) => Some(PaymentPreimage(b)), Err(e) => { log_error!( logger, "Failed to decode preimage ({:?}) for quote {quote_id}: {e}", - res.preimage + res.payment_proof() ); None }, @@ -379,7 +401,7 @@ impl TrustedWalletInterface for Cashu { ); } - let fee_paid_sat: u64 = res.fee_paid.into(); + let fee_paid_sat: u64 = res.fee_paid().into(); let _ = event_queue .add_event(Event::PaymentSuccessful { payment_id, @@ -551,9 +573,7 @@ impl Cashu { .await .ok() .flatten() - .map(|info| { - info.nuts.nut04.supported_methods().contains(&&cdk::nuts::PaymentMethod::Bolt12) - }) + .map(|info| info.nuts.nut04.supported_methods().contains(&&CdkPaymentMethod::BOLT12)) .unwrap_or(false); let (shutdown_sender, mut shutdown_receiver) = watch::channel::<()>(()); @@ -620,9 +640,9 @@ impl Cashu { runtime.spawn_background_task(async move { match w.restore().await { Err(e) => log_error!(l, "Failed to restore cashu mint: {e}"), - Ok(amt) => { - if amt > cdk::Amount::ZERO { - log_info!(l, "Restored cashu mint: {}, amt: {amt}", w.mint_url); + Ok(restored) => { + if restored.unspent > cdk::Amount::ZERO { + log_info!(l, "Restored cashu mint: {}: {:#?}", w.mint_url, restored); } if let Err(e) = write_has_recovered(&store, true).await { log_error!(l, "Failed to write has_recovered flag: {e:?}"); diff --git a/orange-sdk/tests/test_utils.rs b/orange-sdk/tests/test_utils.rs index 6362c12..1e6263c 100644 --- a/orange-sdk/tests/test_utils.rs +++ b/orange-sdk/tests/test_utils.rs @@ -373,7 +373,7 @@ async fn build_test_nodes() -> TestParams { t.local_addr().unwrap().port() }; let cdk_addr = SocketAddr::from_str(format!("127.0.0.1:{cdk_port}").as_str()).unwrap(); - let cdk = cdk_ldk_node::CdkLdkNode::new( + let cdk = cdk_ldk_node::CdkLdkNodeBuilder::new( Network::Regtest, cdk_ldk_node::ChainSource::BitcoinRpc(BitcoinRpcConfig { host: "127.0.0.1".to_string(), @@ -385,8 +385,8 @@ async fn build_test_nodes() -> TestParams { tmp.to_str().unwrap().to_string(), FeeReserve { min_fee_reserve: Default::default(), percent_fee_reserve: 0.0 }, vec![cdk_addr.into()], - None, ) + .build() .unwrap(); let cdk = Arc::new(cdk); @@ -408,7 +408,7 @@ async fn build_test_nodes() -> TestParams { builder .add_payment_processor( orange_sdk::CurrencyUnit::Sat, - cdk::nuts::PaymentMethod::Bolt11, + cdk::nuts::PaymentMethod::BOLT11, MintMeltLimits::new(0, u64::MAX), cdk.clone(), ) @@ -418,7 +418,7 @@ async fn build_test_nodes() -> TestParams { builder .add_payment_processor( orange_sdk::CurrencyUnit::Sat, - cdk::nuts::PaymentMethod::Bolt12, + cdk::nuts::PaymentMethod::BOLT12, MintMeltLimits::new(0, u64::MAX), cdk.clone(), ) @@ -431,7 +431,12 @@ async fn build_test_nodes() -> TestParams { let listener = tokio::net::TcpListener::bind(mint_addr).await.unwrap(); - let v1_service = cdk_axum::create_mint_router(Arc::clone(&mint), true).await.unwrap(); + let v1_service = cdk_axum::create_mint_router( + Arc::clone(&mint), + vec!["bolt11".to_string(), "bolt12".to_string()], + ) + .await + .unwrap(); let axum_result = axum::serve(listener, v1_service); From d1be2904fbecdfe79f5810bb9ea8fc5f339f88c0 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Wed, 25 Feb 2026 18:04:47 -0600 Subject: [PATCH 4/5] Add lightning address support for Cashu via npub.cash Implement get_lightning_address and register_lightning_address for the Cashu wallet using CDK's built-in npub.cash integration. When configured with a npubcash_url, the wallet enables npub.cash during init, starts background polling for incoming quotes, and returns deterministic lightning addresses derived from the wallet's Nostr keys. Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/cli/Cargo.toml | 2 +- examples/cli/src/main.rs | 35 ++++-- justfile | 3 + orange-sdk/Cargo.toml | 2 +- orange-sdk/src/ffi/cashu.rs | 18 ++- orange-sdk/src/lib.rs | 34 ++++-- orange-sdk/src/trusted_wallet/cashu/mod.rs | 128 ++++++++++++++++++--- orange-sdk/tests/test_utils.rs | 1 + 8 files changed, 183 insertions(+), 40 deletions(-) diff --git a/examples/cli/Cargo.toml b/examples/cli/Cargo.toml index fb844ed..d4ecccf 100644 --- a/examples/cli/Cargo.toml +++ b/examples/cli/Cargo.toml @@ -8,7 +8,7 @@ name = "orange-cli" path = "src/main.rs" [dependencies] -orange-sdk = { path = "../../orange-sdk" } +orange-sdk = { path = "../../orange-sdk", features = ["cashu"] } tokio = { version = "1.0", features = ["full"] } clap = { version = "4.0", features = ["derive"] } anyhow = "1.0" diff --git a/examples/cli/src/main.rs b/examples/cli/src/main.rs index e03920b..4ec093c 100644 --- a/examples/cli/src/main.rs +++ b/examples/cli/src/main.rs @@ -6,8 +6,8 @@ use rustyline::error::ReadlineError; use orange_sdk::bitcoin_payment_instructions::amount::Amount; use orange_sdk::{ - ChainSource, Event, ExtraConfig, LoggerType, Mnemonic, PaymentInfo, Seed, SparkWalletConfig, - StorageConfig, Tunables, Wallet, WalletConfig, bitcoin::Network, + CashuConfig, ChainSource, CurrencyUnit, Event, ExtraConfig, LoggerType, Mnemonic, PaymentInfo, + Seed, SparkWalletConfig, StorageConfig, Tunables, Wallet, WalletConfig, bitcoin::Network, }; use rand::RngCore; use std::fs; @@ -23,6 +23,15 @@ const NETWORK: Network = Network::Bitcoin; // Supports Bitcoin and Regtest #[derive(Parser)] #[command(author, version, about, long_about = None)] struct Cli { + /// Use Cashu wallet instead of Spark + #[arg(long)] + cashu: bool, + /// Cashu mint URL (requires --cashu) + #[arg(long, requires = "cashu")] + mint_url: String, + /// npub.cash URL for lightning address support (requires --cashu) + #[arg(long, requires = "cashu")] + npubcash_url: Option, #[command(subcommand)] command: Option, } @@ -74,12 +83,22 @@ struct WalletState { shutdown: Arc, } -fn get_config(network: Network) -> Result { +fn get_config(network: Network, cli: &Cli) -> Result { let storage_path = format!("./wallet_data/{network}"); // Generate or load seed let seed = generate_or_load_seed(&storage_path)?; + let extra_config = if cli.cashu { + ExtraConfig::Cashu(CashuConfig { + mint_url: cli.mint_url.clone(), + unit: CurrencyUnit::Sat, + npubcash_url: cli.npubcash_url.clone(), + }) + } else { + ExtraConfig::Spark(SparkWalletConfig::default()) + }; + match network { Network::Regtest => { let lsp_address = "185.150.162.100:3551" @@ -103,7 +122,7 @@ fn get_config(network: Network) -> Result { network, seed, tunables: Tunables::default(), - extra_config: ExtraConfig::Spark(SparkWalletConfig::default()), + extra_config, }) }, Network::Bitcoin => { @@ -132,7 +151,7 @@ fn get_config(network: Network) -> Result { network, seed, tunables: Tunables::default(), - extra_config: ExtraConfig::Spark(SparkWalletConfig::default()), + extra_config, }) }, _ => Err(anyhow::anyhow!("Unsupported network: {network:?}")), @@ -140,9 +159,9 @@ fn get_config(network: Network) -> Result { } impl WalletState { - async fn new() -> Result { + async fn new(cli: &Cli) -> Result { let shutdown = Arc::new(AtomicBool::new(false)); - let config = get_config(NETWORK) + let config = get_config(NETWORK, cli) .with_context(|| format!("Failed to get wallet config for network: {NETWORK:?}"))?; println!("{} Initializing wallet...", "⚡".bright_yellow()); @@ -241,7 +260,7 @@ async fn main() -> Result<()> { println!(); // Initialize wallet once at startup - let mut state = WalletState::new().await?; + let mut state = WalletState::new(&cli).await?; // Set up signal handling for graceful shutdown let shutdown_state = state.shutdown.clone(); diff --git a/justfile b/justfile index e55c86f..61317ac 100644 --- a/justfile +++ b/justfile @@ -16,6 +16,9 @@ test-cashu *args: cli: cd examples/cli && cargo run +cli-cashu *args: + cd examples/cli && cargo run -- --cashu --npubcash-url https://npubx.cash --mint-url {{ args }} + cli-logs: tail -n 50 -f examples/cli/wallet_data/bitcoin/wallet.log diff --git a/orange-sdk/Cargo.toml b/orange-sdk/Cargo.toml index 2a1998b..f16ffa6 100644 --- a/orange-sdk/Cargo.toml +++ b/orange-sdk/Cargo.toml @@ -16,7 +16,7 @@ name = "orange_sdk" default = ["spark"] uniffi = ["dep:uniffi", "spark", "cashu", "rand", "pin-project-lite"] spark = ["breez-sdk-spark", "uuid", "serde_json"] -cashu = ["cdk", "serde_json"] +cashu = ["cdk", "cdk/npubcash", "serde_json"] _test-utils = ["corepc-node", 'electrsd', "cashu", "uuid/v7", "rand"] _cashu-tests = ["_test-utils", "cdk-ldk-node", "cdk/mint", "cdk-sqlite", "cdk-axum", "axum"] diff --git a/orange-sdk/src/ffi/cashu.rs b/orange-sdk/src/ffi/cashu.rs index ab82766..68c195b 100644 --- a/orange-sdk/src/ffi/cashu.rs +++ b/orange-sdk/src/ffi/cashu.rs @@ -45,24 +45,34 @@ pub struct CashuConfig { pub mint_url: String, /// The currency unit to use (typically Sat) pub unit: CurrencyUnit, + /// Optional npub.cash URL for lightning address support + pub npubcash_url: Option, } #[uniffi::export] impl CashuConfig { #[uniffi::constructor] - pub fn new(mint_url: String, unit: CurrencyUnit) -> Self { - CashuConfig { mint_url, unit } + pub fn new(mint_url: String, unit: CurrencyUnit, npubcash_url: Option) -> Self { + CashuConfig { mint_url, unit, npubcash_url } } } impl From for OrangeCashuConfig { fn from(config: CashuConfig) -> Self { - OrangeCashuConfig { mint_url: config.mint_url, unit: config.unit.into() } + OrangeCashuConfig { + mint_url: config.mint_url, + unit: config.unit.into(), + npubcash_url: config.npubcash_url, + } } } impl From for CashuConfig { fn from(config: OrangeCashuConfig) -> Self { - CashuConfig { mint_url: config.mint_url, unit: config.unit.into() } + CashuConfig { + mint_url: config.mint_url, + unit: config.unit.into(), + npubcash_url: config.npubcash_url, + } } } diff --git a/orange-sdk/src/lib.rs b/orange-sdk/src/lib.rs index b8aa45b..3388a60 100644 --- a/orange-sdk/src/lib.rs +++ b/orange-sdk/src/lib.rs @@ -529,6 +529,27 @@ impl Wallet { let tx_metadata = TxMetadataStore::new(Arc::clone(&store)).await; + // Cashu must init before LDK Node because CashuKvDatabase does + // synchronous SQLite reads that deadlock with LDK Node's background + // store writes. Other backends can init concurrently. + #[cfg(feature = "cashu")] + let cashu_wallet = if let ExtraConfig::Cashu(cashu) = &config.extra_config { + Some( + Cashu::init( + &config, + cashu.clone(), + Arc::clone(&store), + Arc::clone(&event_queue), + tx_metadata.clone(), + Arc::clone(&logger), + Arc::clone(&runtime), + ) + .await?, + ) + } else { + None + }; + let (trusted, ln_wallet) = tokio::join!( async { let trusted: Arc> = match &config.extra_config { @@ -546,18 +567,7 @@ impl Wallet { .await?, )), #[cfg(feature = "cashu")] - ExtraConfig::Cashu(cashu) => Arc::new(Box::new( - Cashu::init( - &config, - cashu.clone(), - Arc::clone(&store), - Arc::clone(&event_queue), - tx_metadata.clone(), - Arc::clone(&logger), - Arc::clone(&runtime), - ) - .await?, - )), + ExtraConfig::Cashu(_) => Arc::new(Box::new(cashu_wallet.expect("initialized above"))), #[cfg(feature = "_test-utils")] ExtraConfig::Dummy(cfg) => Arc::new(Box::new( DummyTrustedWallet::new( diff --git a/orange-sdk/src/trusted_wallet/cashu/mod.rs b/orange-sdk/src/trusted_wallet/cashu/mod.rs index d8d262d..404791e 100644 --- a/orange-sdk/src/trusted_wallet/cashu/mod.rs +++ b/orange-sdk/src/trusted_wallet/cashu/mod.rs @@ -52,6 +52,8 @@ pub struct CashuConfig { pub mint_url: String, /// The currency unit to use (typically Sat) pub unit: CurrencyUnit, + /// Optional npub.cash URL for lightning address support (e.g., `https://npubx.cash`) + pub npubcash_url: Option, } /// A wallet implementation using the Cashu (CDK) SDK. @@ -63,11 +65,13 @@ pub struct Cashu { payment_success_sender: watch::Sender<()>, payment_success_flag: watch::Receiver<()>, logger: Arc, - supports_bolt12: bool, + supports_bolt12: Arc, mint_quote_sender: mpsc::Sender, event_queue: Arc, tx_metadata: TxMetadataStore, runtime: Arc, + npubcash_url: Option, + npub: Option, } impl TrustedWalletInterface for Cashu { @@ -87,7 +91,7 @@ impl TrustedWalletInterface for Cashu { &self, ) -> Pin> + Send + '_>> { Box::pin(async move { - if !self.supports_bolt12 { + if !self.supports_bolt12.load(std::sync::atomic::Ordering::Relaxed) { return Err(TrustedError::UnsupportedOperation( "Cashu mint does not support BOLT 12".to_owned(), )); @@ -280,7 +284,7 @@ impl TrustedWalletInterface for Cashu { } }, PaymentMethod::LightningBolt12(offer) => { - if !self.supports_bolt12 { + if !self.supports_bolt12.load(std::sync::atomic::Ordering::Relaxed) { return Err(TrustedError::UnsupportedOperation( "Cashu mint does not support BOLT 12".to_owned(), )); @@ -497,16 +501,29 @@ impl TrustedWalletInterface for Cashu { fn get_lightning_address( &self, ) -> Pin, TrustedError>> + Send + '_>> { - Box::pin(async { Ok(None) }) + Box::pin(async { + match (&self.npubcash_url, &self.npub) { + (Some(url), Some(npub)) => { + let domain = url.trim_start_matches("https://").trim_start_matches("http://"); + Ok(Some(format!("{npub}@{domain}"))) + }, + _ => Ok(None), + } + }) } fn register_lightning_address( &self, _name: String, ) -> Pin> + Send + '_>> { Box::pin(async { - Err(TrustedError::UnsupportedOperation( - "register_lightning_address is not supported in Cashu Wallet".to_string(), - )) + if self.npubcash_url.is_none() { + return Err(TrustedError::UnsupportedOperation( + "npubcash_url is not configured".to_string(), + )); + } + // npub.cash addresses are deterministic from the Nostr keys, + // and set_mint_url is called during init. Nothing to do here. + Ok(()) }) } @@ -568,13 +585,18 @@ impl Cashu { })?, ); - let supports_bolt12 = cashu_wallet - .fetch_mint_info() - .await - .ok() - .flatten() - .map(|info| info.nuts.nut04.supported_methods().contains(&&CdkPaymentMethod::BOLT12)) - .unwrap_or(false); + let supports_bolt12 = Arc::new(std::sync::atomic::AtomicBool::new(false)); + { + let w = Arc::clone(&cashu_wallet); + let flag = Arc::clone(&supports_bolt12); + runtime.spawn_cancellable_background_task(async move { + if let Some(info) = w.fetch_mint_info().await.ok().flatten() { + if info.nuts.nut04.supported_methods().contains(&&CdkPaymentMethod::BOLT12) { + flag.store(true, std::sync::atomic::Ordering::Relaxed); + } + } + }); + } let (shutdown_sender, mut shutdown_receiver) = watch::channel::<()>(()); let (payment_success_sender, payment_success_flag) = watch::channel(()); @@ -652,6 +674,66 @@ impl Cashu { }); } + // Initialize npub.cash if configured + let npubcash_url = cashu_config.npubcash_url.clone(); + let mut npub: Option = None; + + if let Some(ref url) = npubcash_url { + npub = Some(Self::derive_npub(&seed).map_err(|e| { + InitFailure::TrustedFailure(TrustedError::WalletOperationFailed(format!( + "Failed to derive npub: {e}" + ))) + })?); + + // Enable npub.cash and start polling in background to avoid blocking init + let wallet_for_npubcash = Arc::clone(&cashu_wallet); + let sender_for_npubcash = mint_quote_sender.clone(); + let logger_for_npubcash = Arc::clone(&logger); + let mut shutdown_for_npubcash = shutdown_sender.subscribe(); + let url = url.clone(); + runtime.spawn_cancellable_background_task(async move { + if let Err(e) = wallet_for_npubcash.enable_npubcash(url.clone()).await { + log_error!(logger_for_npubcash, "Failed to enable npub.cash: {e}"); + return; + } + log_info!(logger_for_npubcash, "npub.cash enabled with URL: {url}"); + + let poll_interval = Duration::from_secs(30); + let mut interval = tokio::time::interval(poll_interval); + loop { + tokio::select! { + _ = shutdown_for_npubcash.changed() => { + log_info!(logger_for_npubcash, "npub.cash polling shutdown"); + return; + } + _ = interval.tick() => { + match wallet_for_npubcash.sync_npubcash_quotes().await { + Ok(quotes) => { + for quote in quotes { + if matches!(quote.state, cdk::nuts::MintQuoteState::Paid) { + let id = quote.id.clone(); + if let Err(e) = sender_for_npubcash.send(quote).await { + log_error!( + logger_for_npubcash, + "Failed to send npub.cash quote {id} for monitoring: {e}" + ); + } + } + } + }, + Err(e) => { + log_error!( + logger_for_npubcash, + "Failed to sync npub.cash quotes: {e}" + ); + }, + } + } + } + } + }); + } + Ok(Cashu { cashu_wallet, unit: cashu_config.unit, @@ -664,9 +746,27 @@ impl Cashu { event_queue, tx_metadata, runtime, + npubcash_url, + npub, }) } + /// Derive the npub (bech32-encoded Nostr public key) from the wallet seed. + /// + /// Uses the same derivation as CDK's `derive_npubcash_keys`: the first 32 bytes + /// of the seed as a secp256k1 secret key, then bech32-encodes the x-only public key. + fn derive_npub(seed: &[u8; 64]) -> Result { + use ldk_node::bitcoin::bech32::{Bech32, Hrp, encode}; + use ldk_node::bitcoin::secp256k1::{Secp256k1, SecretKey}; + + let sk = + SecretKey::from_slice(&seed[..32]).map_err(|e| format!("Invalid secret key: {e}"))?; + let secp = Secp256k1::new(); + let (xonly, _) = sk.public_key(&secp).x_only_public_key(); + let hrp = Hrp::parse("npub").expect("valid hrp"); + encode::(hrp, &xonly.serialize()).map_err(|e| format!("bech32 encode: {e}")) + } + /// Convert an ID string to a 32-byte array /// /// This is a helper function to avoid code duplication when converting various ID types diff --git a/orange-sdk/tests/test_utils.rs b/orange-sdk/tests/test_utils.rs index 1e6263c..a95c251 100644 --- a/orange-sdk/tests/test_utils.rs +++ b/orange-sdk/tests/test_utils.rs @@ -503,6 +503,7 @@ async fn build_test_nodes() -> TestParams { extra_config: ExtraConfig::Cashu(orange_sdk::CashuConfig { mint_url: format!("http://127.0.0.1:{}", mint_addr.port()), unit: orange_sdk::CurrencyUnit::Sat, + npubcash_url: None, }), }; let wallet = Arc::new(Wallet::new(wallet_config).await.unwrap()); From 7a52c4dfd8dd4d925b46951e80c93ac6f4cdaa96 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Thu, 26 Feb 2026 14:26:13 -0600 Subject: [PATCH 5/5] Timeout test teardown --- orange-sdk/tests/test_utils.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/orange-sdk/tests/test_utils.rs b/orange-sdk/tests/test_utils.rs index a95c251..6942807 100644 --- a/orange-sdk/tests/test_utils.rs +++ b/orange-sdk/tests/test_utils.rs @@ -289,7 +289,10 @@ where test(params.clone()).await; // Always clean up - params.stop().await; + let timeout = Duration::from_secs(10); + if tokio::time::timeout(timeout, params.stop()).await.is_err() { + eprintln!("Warning: parms stop timed out after {timeout:?}"); + } } async fn build_test_nodes() -> TestParams {