From 33af0cd3c1747f8ec3d13ab6453fd35bb3bab251 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Fri, 19 Sep 2025 16:04:45 -0500 Subject: [PATCH 1/8] WIP: change from spark-wallet to breez-sdk-spark --- examples/cli/src/main.rs | 26 +- orange-sdk/Cargo.toml | 4 +- orange-sdk/src/lib.rs | 4 +- orange-sdk/src/trusted_wallet/mod.rs | 2 +- orange-sdk/src/trusted_wallet/spark.rs | 967 ++++++++++--------------- 5 files changed, 408 insertions(+), 595 deletions(-) diff --git a/examples/cli/src/main.rs b/examples/cli/src/main.rs index 117243d..efa3978 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, Mnemonic, PaymentInfo, Seed, SparkWalletConfig, StorageConfig, - Tunables, Wallet, WalletConfig, bitcoin::Network, + ChainSource, Event, ExtraConfig, Mnemonic, PaymentInfo, Seed, SparkNetwork, SparkWalletConfig, + StorageConfig, Tunables, Wallet, WalletConfig, bitcoin::Network, }; use rand::RngCore; use std::fs; @@ -89,9 +89,14 @@ fn get_config(network: Network) -> Result { network, seed, tunables: Tunables::default(), - extra_config: ExtraConfig::Spark(SparkWalletConfig::default_config( - network.try_into().expect("valid network"), - )), + extra_config: ExtraConfig::Spark(SparkWalletConfig { + api_key: None, + network: SparkNetwork::Regtest, + sync_interval_secs: 60, + max_deposit_claim_fee: None, + lnurl_domain: None, + prefer_spark_over_lightning: false, + }), }) }, Network::Bitcoin => { @@ -118,9 +123,14 @@ fn get_config(network: Network) -> Result { network, seed, tunables: Tunables::default(), - extra_config: ExtraConfig::Spark(SparkWalletConfig::default_config( - network.try_into().expect("valid network"), - )), + extra_config: ExtraConfig::Spark(SparkWalletConfig { + api_key: None, + network: SparkNetwork::Mainnet, + sync_interval_secs: 60, + max_deposit_claim_fee: None, + lnurl_domain: None, + prefer_spark_over_lightning: false, + }), }) }, _ => Err(anyhow::anyhow!("Unsupported network: {network:?}")), diff --git a/orange-sdk/Cargo.toml b/orange-sdk/Cargo.toml index 7498fa0..9e03116 100644 --- a/orange-sdk/Cargo.toml +++ b/orange-sdk/Cargo.toml @@ -10,7 +10,7 @@ name = "orange_sdk" [features] default = ["spark"] uniffi = ["dep:uniffi", "spark", "cashu"] -spark = ["spark-wallet", "uuid"] +spark = ["breez-sdk-spark", "uuid", "serde_json"] cashu = ["cdk", "serde_json"] _test-utils = ["corepc-node", "cashu", "uuid/v7", "rand"] _cashu-tests = ["_test-utils", "cdk-ldk-node", "cdk/mint", "cdk-sqlite", "cdk-axum", "axum"] @@ -23,7 +23,7 @@ bitcoin-payment-instructions = { workspace = true } chrono = { version = "0.4", default-features = false } rand = { version = "0.8.5", optional = true } reqwest = { version = "0.12.23", default-features = false, features = ["rustls-tls"] } -spark-wallet = { git = "https://github.com/breez/spark-sdk.git", rev = "6e12f98be3f100fca0411e3209a610decfa32279", default-features = false, features = ["rustls-tls"], optional = true } +breez-sdk-spark = { git = "https://github.com/breez/spark-sdk.git", rev = "6994785e98c13191e6dc26146be6ce87c5c8d987", default-features = false, features = ["rustls-tls"], optional = true } tokio = { version = "1.0", default-features = false, features = ["rt-multi-thread", "sync"] } uuid = { version = "1.0", default-features = false, optional = true } cdk = { git = "https://github.com/benthecarman/cdk.git", rev = "7d25e9ae5ed7f47f9ae7e87d8a9ee16797fee8cd", default-features = false, features = ["wallet"], optional = true } diff --git a/orange-sdk/src/lib.rs b/orange-sdk/src/lib.rs index 881583f..a363dff 100644 --- a/orange-sdk/src/lib.rs +++ b/orange-sdk/src/lib.rs @@ -66,14 +66,14 @@ use trusted_wallet::TrustedError; #[cfg(feature = "cashu")] pub use crate::trusted_wallet::cashu::CashuConfig; pub use bitcoin_payment_instructions; +#[cfg(feature = "spark")] +pub use breez_sdk_spark::{Config as SparkWalletConfig, Fee, Network as SparkNetwork}; #[cfg(feature = "cashu")] pub use cdk::nuts::nut00::CurrencyUnit; pub use event::{Event, EventQueue}; pub use ldk_node::bip39::Mnemonic; pub use ldk_node::bitcoin; pub use ldk_node::payment::ConfirmationStatus; -#[cfg(feature = "spark")] -pub use spark_wallet::{OperatorPoolConfig, ServiceProviderConfig, SparkWalletConfig}; pub use store::{PaymentId, PaymentType, Transaction, TxStatus}; pub use trusted_wallet::ExtraConfig; diff --git a/orange-sdk/src/trusted_wallet/mod.rs b/orange-sdk/src/trusted_wallet/mod.rs index edfccb0..1323a9c 100644 --- a/orange-sdk/src/trusted_wallet/mod.rs +++ b/orange-sdk/src/trusted_wallet/mod.rs @@ -125,7 +125,7 @@ impl graduated_rebalancer::TrustedWallet for pub enum ExtraConfig { /// Configuration for Spark wallet. #[cfg(feature = "spark")] - Spark(crate::SparkWalletConfig), + Spark(crate::SparkWalletConfig), // todo make my own reduced version /// Configuration for Cashu wallet. #[cfg(feature = "cashu")] Cashu(cashu::CashuConfig), diff --git a/orange-sdk/src/trusted_wallet/spark.rs b/orange-sdk/src/trusted_wallet/spark.rs index 495d397..713b528 100644 --- a/orange-sdk/src/trusted_wallet/spark.rs +++ b/orange-sdk/src/trusted_wallet/spark.rs @@ -1,28 +1,25 @@ //! An implementation of `TrustedWalletInterface` using the Spark SDK. use crate::bitcoin::hex::FromHex; -use crate::bitcoin::{Txid, io}; +use crate::bitcoin::io; use crate::logging::Logger; -use crate::store::{PaymentId, StoreTransaction, TxMetadataStore, TxStatus}; +use crate::store::{PaymentId, TxMetadataStore, TxStatus}; use crate::trusted_wallet::{Payment, TrustedError, TrustedWalletInterface}; -use crate::{Event, EventQueue, InitFailure, PaymentType, Seed, WalletConfig}; +use crate::{Event, EventQueue, InitFailure, Mnemonic, Seed, WalletConfig}; -use ldk_node::bitcoin::hashes::Hash; -use ldk_node::bitcoin::hashes::sha256::Hash as Sha256; use ldk_node::lightning::util::logger::Logger as _; use ldk_node::lightning::util::persist::KVStore; -use ldk_node::lightning::util::ser::{Readable, Writeable}; -use ldk_node::lightning::{log_debug, log_error, log_info, log_trace}; +use ldk_node::lightning::{log_debug, log_error, log_info, log_warn}; use ldk_node::lightning_invoice::Bolt11Invoice; use ldk_node::lightning_types::payment::{PaymentHash, PaymentPreimage}; -use ldk_node::payment::ConfirmationStatus; use bitcoin_payment_instructions::PaymentMethod; use bitcoin_payment_instructions::amount::Amount; -use spark_wallet::{ - DefaultSigner, LightningSendStatus, Order, PagingFilter, PayLightningInvoiceResult, Signer, - SparkWallet, SparkWalletConfig, SparkWalletError, SspUserRequest, TransferStatus, WalletEvent, - WalletTransfer, +use breez_sdk_spark::{ + BreezSdk, DepositInfo, EventListener, GetInfoRequest, ListPaymentsRequest, PaymentDetails, + PaymentMetadata, PaymentType, PrepareSendPaymentRequest, ReceivePaymentMethod, + ReceivePaymentRequest, SdkBuilder, SdkError, SdkEvent, SendPaymentMethod, SendPaymentRequest, + StorageError, UpdateDepositPayload, }; use tokio::sync::watch; @@ -31,14 +28,14 @@ use std::future::Future; use std::pin::Pin; use std::str::FromStr; use std::sync::Arc; -use std::time::{Duration, SystemTime}; +use std::time::Duration; use tokio::runtime::Runtime; use uuid::Uuid; /// A wallet implementation using the Breez Spark SDK. #[derive(Clone)] pub(crate) struct Spark { - spark_wallet: Arc, + spark_wallet: Arc, store: Arc, event_queue: Arc, tx_metadata: TxMetadataStore, @@ -53,8 +50,8 @@ impl TrustedWalletInterface for Spark { &self, ) -> Pin> + Send + '_>> { Box::pin(async move { - let sats = self.spark_wallet.get_balance().await?; - Amount::from_sats(sats).map_err(|_| TrustedError::AmountError) + let info = self.spark_wallet.get_info(GetInfoRequest {}).await?; + Amount::from_sats(info.balance_sats).map_err(|_| TrustedError::AmountError) }) } @@ -70,7 +67,6 @@ impl TrustedWalletInterface for Spark { &self, amount: Option, ) -> Pin> + Send + '_>> { Box::pin(async move { - // TODO: get upstream to let us be amount-less match amount { None => Err(TrustedError::UnsupportedOperation( "Spark does not support amount-less invoices".to_owned(), @@ -85,9 +81,16 @@ impl TrustedWalletInterface for Spark { ) })?; - let res = self.spark_wallet.create_lightning_invoice(sats, None, None).await?; + let params = ReceivePaymentRequest { + payment_method: ReceivePaymentMethod::Bolt11Invoice { + description: "".to_string(), + // TODO: can we do amountless now? + amount_sats: Some(sats), + }, + }; + let res = self.spark_wallet.receive_payment(params).await?; - Bolt11Invoice::from_str(&res.invoice) + Bolt11Invoice::from_str(&res.payment_request) .map_err(|e| TrustedError::Other(format!("Failed to parse invoice: {e}"))) }, } @@ -98,54 +101,27 @@ impl TrustedWalletInterface for Spark { &self, ) -> Pin, TrustedError>> + Send + '_>> { Box::pin(async move { - let keys = self.store.list(SPARK_PRIMARY_NAMESPACE, SPARK_PAYMENTS_NAMESPACE)?; - - let mut res = Vec::with_capacity(keys.len()); - for key in keys { - let data = - self.store.read(SPARK_PRIMARY_NAMESPACE, SPARK_PAYMENTS_NAMESPACE, &key)?; - let mut cursor = io::Cursor::new(data); - let store_tx: StoreTransaction = - StoreTransaction::read(&mut cursor).map_err(|e| { - TrustedError::Other(format!("Failed to decode payment {key}: {e}")) - })?; - let uuid = Uuid::from_str(&key).map_err(|e| { - TrustedError::Other(format!("Failed to parse payment id {key}: {e}")) - })?; - - // skip any payment without an amount yet - let amount = match store_tx.amount_msats { - Some(amount) => { - Amount::from_milli_sats(amount).map_err(|_| TrustedError::AmountError)? - }, - None => { - debug_assert_ne!( - store_tx.status, - TxStatus::Completed, - "Completed payments should have an amount" - ); - continue; - }, - }; + let resp = self + .spark_wallet + .list_payments(ListPaymentsRequest { limit: None, offset: None }) + .await?; - // if we have no fee, assume zero - let fee = match store_tx.fee_msats { - Some(fee) => { - Amount::from_milli_sats(fee).map_err(|_| TrustedError::AmountError)? - }, - None => Amount::ZERO, - }; + let payments = resp + .payments + .into_iter() + .map(|p| { + parse_payment_id(&p.id).map(|id| Payment { + id, + amount: Amount::from_sats(p.amount).unwrap(), + fee: Amount::from_sats(p.fees).unwrap(), + status: p.status.into(), + outbound: p.payment_type == PaymentType::Send, + time_since_epoch: Duration::from_secs(p.timestamp), + }) + }) + .collect::>()?; - res.push(Payment { - status: store_tx.status, - id: convert_from_transfer_id(uuid.into_bytes()), - amount, - outbound: store_tx.outbound, - fee, - time_since_epoch: Duration::from_secs(store_tx.time_since_epoch), - }); - } - Ok(res) + Ok(payments) }) } @@ -160,12 +136,17 @@ impl TrustedWalletInterface for Spark { ) })?; - let fee_sats = self - .spark_wallet - .fetch_lightning_send_fee_estimate(&invoice.to_string(), Some(sats)) - .await?; - - Amount::from_sats(fee_sats).map_err(|_| TrustedError::AmountError) + let params = PrepareSendPaymentRequest { + payment_request: invoice.to_string(), + amount_sats: Some(sats), + }; + let prepare = self.spark_wallet.prepare_send_payment(params).await?; + match prepare.payment_method { + SendPaymentMethod::Bolt11Invoice { lightning_fee_sats, .. } => { + Amount::from_sats(lightning_fee_sats).map_err(|_| TrustedError::AmountError) + }, + _ => unreachable!("we only asked for bolt11"), + } } else { log_error!(self.logger, "Only BOLT 11 is currently supported for fee estimation"); Err(TrustedError::UnsupportedOperation( @@ -186,71 +167,19 @@ impl TrustedWalletInterface for Spark { ) })?; + let params = PrepareSendPaymentRequest { + payment_request: invoice.to_string(), + amount_sats: Some(sats), + }; + let prepare = self.spark_wallet.prepare_send_payment(params).await?; + let res = self .spark_wallet - .pay_lightning_invoice( - &invoice.to_string(), - Some(sats), - None, - false, // do not prefer spark for better privacy - ) + .send_payment(SendPaymentRequest { prepare_response: prepare, options: None }) .await?; - match res { - PayLightningInvoiceResult::LightningPayment(pay) => { - // Spark uses UUIDs for payment IDs, so we need to convert them - // to our format. Spark uses a UUID in the format `SparkLightningSendRequest:` - // We only need the UUID part, so we split by ':' and take the last part. - // If the format is invalid, we return an error. - if let Some(id) = pay.id.split(':').next_back() { - let uuid = Uuid::from_str(id).map_err(|_| { - TrustedError::Other(format!("Failed to parse payment id: {id}")) - })?; - - let id = convert_from_transfer_id(uuid.into_bytes()); - - let payment_id = PaymentId::Trusted(id); - let is_rebalance = { - let map = self.tx_metadata.read(); - map.get(&payment_id).is_some_and(|m| m.ty.is_rebalance()) - }; - - // Poll the payment status in the background if it's not a rebalance - // as rebalances are internal and we don't need to notify the user - // about their status. - if !is_rebalance { - self.poll_lightning_payment( - pay.id, - id, - PaymentHash(invoice.payment_hash().to_byte_array()), - ); - } - - Ok(id) - } else { - log_error!(self.logger, "Invalid payment id format: {}", pay.id); - Err(TrustedError::Other(format!( - "Invalid payment id format: {}", - pay.id - ))) - } - }, - PayLightningInvoiceResult::Transfer(transfer) => { - let id = convert_from_transfer_id(transfer.id.to_bytes()); - // transfers will never be used for rebalances, so no need to check - // transfers just work, no need to poll - self.event_queue - .add_event(Event::PaymentSuccessful { - payment_id: PaymentId::Trusted(id), - payment_hash: PaymentHash(invoice.payment_hash().to_byte_array()), - payment_preimage: PaymentPreimage([0; 32]), // we don't get the preimage here - fee_paid_msat: Some(0), - }) - .unwrap(); - - Ok(id) - }, - } + let id = parse_payment_id(&res.payment.id)?; + Ok(id) } else { Err(TrustedError::UnsupportedOperation( "Only BOLT 11 is currently supported".to_owned(), @@ -267,145 +196,50 @@ impl TrustedWalletInterface for Spark { } } -const SPARK_PRIMARY_NAMESPACE: &str = "spark"; -const SPARK_SYNC_NAMESPACE: &str = "sync_info"; -const SPARK_PAYMENTS_NAMESPACE: &str = "payment"; -const SPARK_SYNC_OFFSET_KEY: &str = "sync_offset"; - impl Spark { /// Initialize a new Spark wallet instance with the given configuration. pub(crate) async fn init( - config: &WalletConfig, spark_config: SparkWalletConfig, + config: &WalletConfig, spark_config: breez_sdk_spark::Config, store: Arc, event_queue: Arc, tx_metadata: TxMetadataStore, logger: Arc, runtime: Arc, ) -> Result { - if config.network != spark_config.network.into() { - Err(TrustedError::InvalidNetwork)? + match (config.network, spark_config.network) { + (crate::bitcoin::Network::Bitcoin, breez_sdk_spark::Network::Mainnet) => {}, + (crate::bitcoin::Network::Regtest, breez_sdk_spark::Network::Regtest) => {}, + _ => Err(TrustedError::InvalidNetwork)?, } - let signer = match &config.seed { + let (mnemonic, passphrase) = match &config.seed { Seed::Seed64(bytes) => { - // hash the seed to make sure it does not conflict with the lightning keys - let seed = Sha256::hash(bytes); - DefaultSigner::new(&seed[..], spark_config.network) - .map_err(|e| TrustedError::Other(format!("Failed to create signer: {e}")))? - }, - Seed::Mnemonic { mnemonic, passphrase } => { - // We don't hash the seed here, as mnemonics are meant to be easily recoverable - // and if we hashed them, then you could not recover your spark coins from the mnemonic - // in separate wallets. - let seed = mnemonic.to_seed(passphrase.as_deref().unwrap_or("")); - DefaultSigner::new(&seed[..], spark_config.network) - .map_err(|e| TrustedError::Other(format!("Failed to create signer: {e}")))? + let mnemonic = Mnemonic::from_entropy(bytes.as_slice()).expect("valid length"); + (mnemonic.to_string(), None) }, + Seed::Mnemonic { mnemonic, passphrase } => (mnemonic.to_string(), passphrase.clone()), }; - let pk = - signer.get_identity_public_key().map_err(|e| TrustedError::Other(format!("{e:?}")))?; - log_info!(logger, "Starting Spark wallet with public key: {pk}"); + let spark_store = SparkStore(Arc::clone(&store)); + let builder = SdkBuilder::new(spark_config, mnemonic, passphrase, Arc::new(spark_store)); - let spark_wallet = - Arc::new(SparkWallet::connect(spark_config, Arc::new(signer)).await.map_err(|e| { - log_error!(logger, "Failed to connect to Spark wallet: {e:?}"); - InitFailure::TrustedFailure(e.into()) - })?); + let spark_wallet = Arc::new(builder.build().await.map_err(|e| { + log_error!(logger, "Failed to initialize Spark wallet: {e:?}"); + InitFailure::TrustedFailure(e.into()) + })?); + + log_info!(logger, "Started Spark wallet!"); let (shutdown_sender, shutdown_receiver) = watch::channel::<()>(()); - let mut events = spark_wallet.subscribe_events(); - let l = Arc::clone(&logger); + let listener = SparkEventHandler { + event_queue: Arc::clone(&event_queue), + logger: Arc::clone(&logger), + }; + + let listener_id = spark_wallet.add_event_listener(Box::new(listener)); + log_info!(logger, "Added Spark event listener with ID: {}", listener_id); let w = Arc::clone(&spark_wallet); - let s = Arc::clone(&store); - let eq = Arc::clone(&event_queue); let mut shutdown_recv = shutdown_receiver.clone(); runtime.spawn(async move { - loop { - tokio::select! { - _ = shutdown_recv.changed() => { - log_info!(l, "Deposit tracking loop shutdown signal received"); - return; - } - event = events.recv() => { - match event { - Ok(event) => { - log_debug!(l, "Spark event: {event:?}"); - match event { - WalletEvent::DepositConfirmed(node_id) => { - if let Ok(transfers) = w.list_transfers(None).await { - if let Some(transfer) = transfers - .into_iter() - .find(|t| t.leaves.iter().any(|l| l.leaf.id == node_id)) - { - eq.add_event(Event::OnchainPaymentReceived { - payment_id: PaymentId::Trusted( - convert_from_transfer_id(transfer.id.to_bytes()), - ), - // todo this is kinda hacky, maybe we should make this optional - txid: transfer - .leaves - .iter() - .find(|t| t.leaf.id == node_id) - .map(|t| t.leaf - .node_tx - .compute_txid()) - .unwrap_or(Txid::all_zeros()), - amount_sat: transfer.total_value_sat, - status: ConfirmationStatus::Unconfirmed, // fixme dont have block height - }) - .unwrap(); - } - } - }, - WalletEvent::StreamConnected => { - log_debug!(l, "Spark wallet stream connected"); - }, - WalletEvent::StreamDisconnected => { - log_debug!(l, "Spark wallet stream connected"); - }, - WalletEvent::Synced => { - log_debug!(l, "Spark wallet synced"); - if let Err(e) = Self::sync_payments_to_storage(w.as_ref(), &s, l.as_ref()).await { - log_error!(l, "Failed to sync payments to storage: {e:?}"); - } else { - log_info!(l, "Payments synced to storage"); - } - }, - WalletEvent::TransferClaimed(transfer) => { - if let Err(e) = Self::sync_payments_to_storage(w.as_ref(), &s, l.as_ref()).await { - log_error!(l, "Failed to sync payments to storage: {e:?}"); - } else { - log_info!(l, "Payments synced to storage"); - } - - match transfer.user_request { - None => { - log_debug!(l, "Transfer claimed without user request: {transfer:?}"); - }, - Some(SspUserRequest::LightningReceiveRequest(req)) => { - if let Ok(hash) = FromHex::from_hex(&req.invoice.payment_hash) { - eq.add_event(Event::PaymentReceived { - payment_id: PaymentId::Trusted(convert_from_transfer_id(transfer.id.to_bytes())), - payment_hash: PaymentHash(hash), - amount_msat: transfer.total_value_sat * 1_000, // convert to msats - custom_records: vec![], - lsp_fee_msats: None, - }) - .unwrap(); - } - }, - Some(req) => { - log_debug!(l, "Transfer claimed with user request: {req:?}"); - } - } - }, - } - }, - Err(e) => { - log_debug!(l, "Spark event error: {e:?}"); - }, - } - } - } - } + let _ = shutdown_recv.changed().await; + w.remove_event_listener(&listener_id); }); log_info!(logger, "Spark wallet initialized"); @@ -421,368 +255,337 @@ impl Spark { runtime, }) } +} - /// Synchronizes payments from transfers to persistent storage - async fn sync_payments_to_storage( - spark_wallet: &SparkWallet, store: &Arc, logger: &Logger, - ) -> Result<(), TrustedError> { - // sync payments - const BATCH_SIZE: u64 = 50; - - // Get the last offset we processed from storage - let current_offset = match store.read( - SPARK_PRIMARY_NAMESPACE, - SPARK_SYNC_NAMESPACE, - SPARK_SYNC_OFFSET_KEY, - ) { - Ok(data) => u64::from_be_bytes(data.try_into().map_err(|e| { - TrustedError::Other(format!("Failed to convert sync offset: {e:?}")) - })?), - Err(e) => { - if e.kind() == io::ErrorKind::NotFound { - // If not found, start from the beginning - log_info!(logger, "No sync info found, starting from offset 0"); - 0 - } else { - log_error!(logger, "Failed to read sync info: {e:?}"); - return Err(TrustedError::IOError(e)); +struct SparkEventHandler { + event_queue: Arc, + logger: Arc, +} + +impl EventListener for SparkEventHandler { + fn on_event(&self, event: SdkEvent) { + match event { + SdkEvent::Synced => { + log_debug!(self.logger, "Spark wallet synced"); + }, + SdkEvent::ClaimDepositsFailed { unclaimed_deposits } => { + log_warn!( + self.logger, + "Spark wallet failed to claim deposits! {unclaimed_deposits:?}" + ); + }, + SdkEvent::ClaimDepositsSucceeded { claimed_deposits } => { + log_info!(self.logger, "Spark wallet claimed deposits! {claimed_deposits:?}"); + }, + SdkEvent::PaymentSucceeded { payment } => { + if let Err(e) = self.handle_payment_succeeded(payment) { + log_error!(self.logger, "Failed to handle payment succeeded: {e:?}"); } }, - }; + } + } +} - // We'll keep querying in batches until we have all transfers - let mut next_offset = current_offset; - let mut has_more = true; - log_info!(logger, "Syncing payments to storage, offset = {next_offset}"); - let mut pending_payments = 0; - while has_more { - // Get batch of transfers starting from current offset - let transfers_response = spark_wallet - .list_transfers(Some(PagingFilter::new( - Some(next_offset), - Some(BATCH_SIZE), - Some(Order::Ascending), - ))) - .await?; +impl SparkEventHandler { + fn handle_payment_succeeded( + &self, payment: breez_sdk_spark::Payment, + ) -> Result<(), TrustedError> { + log_info!(self.logger, "Spark payment succeeded: {payment:?}"); + + let id = parse_payment_id(&payment.id)?; + + match payment.payment_type { + PaymentType::Send => { + match payment.details { + Some(PaymentDetails::Lightning { preimage, payment_hash, .. }) => { + let preimage = preimage.ok_or_else(|| { + TrustedError::Other( + "Payment succeeded but preimage is missing".to_string(), + ) + })?; + + let preimage: [u8; 32] = FromHex::from_hex(&preimage).map_err(|e| { + TrustedError::Other(format!("Invalid preimage hex: {e:?}")) + })?; + let payment_hash: [u8; 32] = + FromHex::from_hex(&payment_hash).map_err(|e| { + TrustedError::Other(format!("Invalid payment_hash hex: {e:?}")) + })?; - log_info!( - logger, - "Syncing payments to storage, offset = {next_offset}, transfers = {}", - transfers_response.len() - ); - - // Process transfers in this batch - for transfer in &transfers_response { - // Create a payment record - let id = get_id_from_wallet_transfer(transfer)?; - let store_tx: StoreTransaction = StoreTransaction::try_from(transfer)?; - - // Insert payment into storage - if let Err(err) = store.write( - SPARK_PRIMARY_NAMESPACE, - SPARK_PAYMENTS_NAMESPACE, - id.as_str(), - &store_tx.encode(), - ) { - log_error!(logger, "Failed to insert payment: {err:?}"); - } - if store_tx.status == TxStatus::Pending { - pending_payments += 1; + self.event_queue.add_event(Event::PaymentSuccessful { + payment_id: PaymentId::Trusted(id), + payment_hash: PaymentHash(payment_hash), + payment_preimage: PaymentPreimage(preimage), + fee_paid_msat: Some(payment.fees * 1_000), // convert to msats + })?; + }, + _ => { + log_debug!(self.logger, "Unsupported payment details for Send: {payment:?}") + }, } - log_info!(logger, "Inserted payment: {store_tx:?}"); - } + }, + PaymentType::Receive => { + match payment.details { + Some(PaymentDetails::Lightning { payment_hash, .. }) => { + let payment_hash: [u8; 32] = + FromHex::from_hex(&payment_hash).map_err(|e| { + TrustedError::Other(format!("Invalid payment_hash hex: {e:?}")) + })?; - // Check if we have more transfers to fetch - next_offset = next_offset.saturating_add(transfers_response.len() as u64); - // Update our last processed offset in the storage. We should remove pending payments - // from the offset as they might be removed from the list later. - let saved_offset = next_offset - pending_payments; - let save_res = store.write( - SPARK_PRIMARY_NAMESPACE, - SPARK_SYNC_NAMESPACE, - SPARK_SYNC_OFFSET_KEY, - &saved_offset.to_be_bytes(), - ); - - if let Err(err) = save_res { - log_error!(logger, "Failed to update last sync offset: {err:?}"); - } - has_more = transfers_response.len() as u64 == BATCH_SIZE; + let lsp_fee_msats = if payment.fees == 0 { + None + } else { + Some(payment.fees * 1_000) // convert to msats + }; + + self.event_queue.add_event(Event::PaymentReceived { + payment_id: PaymentId::Trusted(id), + payment_hash: PaymentHash(payment_hash), + amount_msat: payment.amount * 1_000, // convert to msats + custom_records: vec![], + lsp_fee_msats, + })?; + }, + _ => { + log_debug!( + self.logger, + "Unsupported payment details for Receive: {payment:?}" + ) + }, + } + }, } Ok(()) } +} - /// Pools the lightning payment until it is in completed state. - fn poll_lightning_payment( - &self, spark_id: String, payment_id: [u8; 32], payment_hash: PaymentHash, - ) { - const MAX_POLL_ATTEMPTS: u64 = 10; - log_info!(self.logger, "Polling lightning send payment {spark_id}"); - - let mut shutdown = self.shutdown_receiver.clone(); - let spark_wallet = Arc::clone(&self.spark_wallet); - let event_queue = Arc::clone(&self.event_queue); - let store = Arc::clone(&self.store); - let logger = Arc::clone(&self.logger); - self.runtime.spawn(async move { - for i in 0..MAX_POLL_ATTEMPTS { - log_info!(logger, "Polling lightning send payment {spark_id} attempt {i}",); - tokio::select! { - _ = shutdown.changed() => { - log_info!(logger, "Shutdown signal received"); - return; - }, - p = spark_wallet.fetch_lightning_send_payment(&spark_id) => { - if let Ok(Some(p)) = p { - let status: TxStatus = p.status.into(); - match status { - TxStatus::Pending => { - // do nothing / wait - log_trace!(logger, "Polling payment still pending, status: {:?}, preimage: {:?}", p.status, p.payment_preimage); - } - TxStatus::Completed => { - // wait for preimage - if p.payment_preimage.is_some() || i == MAX_POLL_ATTEMPTS - 1 { - log_info!(logger, "Polling payment preimage found"); - let preimage: [u8; 32] = p.payment_preimage.as_ref().map(|p| FromHex::from_hex(p).unwrap()).unwrap_or([0; 32]); - event_queue - .add_event(Event::PaymentSuccessful { - payment_id: PaymentId::Trusted(payment_id), - payment_hash, - payment_preimage: PaymentPreimage(preimage), - fee_paid_msat: Some(p.fee_sat * 1_000), // convert to msats - }) - .unwrap(); - - if let Err(e) = Self::sync_payments_to_storage(spark_wallet.as_ref(), &store, logger.as_ref()).await { - log_error!(logger, "Failed to sync payments to storage: {e:?}"); - } else { - log_info!(logger, "Payments synced to storage"); - } - return; - } else { - log_debug!(logger, "Polling payment completed but no preimage yet"); - } - } - TxStatus::Failed => { - log_info!(logger, "Polling payment failed"); - event_queue - .add_event(Event::PaymentFailed { - payment_id: PaymentId::Trusted(payment_id), - payment_hash: Some(payment_hash), - reason: None, - }) - .unwrap(); - return; - } - } - } else { - log_debug!(logger, "Polling payment not found yet"); - } - let sleep_time = if i < 5 { Duration::from_secs(1) } else { Duration::from_secs(i) }; - tokio::time::sleep(sleep_time).await; - } - } - } - // todo what if we never get a final state? - log_info!(logger, "Polling payment timed out"); - }); - } +fn parse_payment_id(id: &str) -> Result<[u8; 32], TrustedError> { + // Spark uses UUIDs for payment IDs, so we need to convert them + // to our format. Spark uses a UUID in the format `SparkLightningSendRequest:` + // We only need the UUID part, so we split by ':' and take the last part. + // If the format is invalid, we return an error. + let uuid = if let Some(id) = id.split(':').next_back() { + Uuid::from_str(id) + .map_err(|_| TrustedError::Other(format!("Failed to parse payment id: {id}")))? + } else { + // if it's not in the expected format, try to parse the whole thing as a uuid + Uuid::from_str(&id) + .map_err(|_| TrustedError::Other(format!("Failed to parse payment id: {id}")))? + }; + Ok(convert_from_uuid_id(uuid.into_bytes())) } -impl TryFrom<&WalletTransfer> for StoreTransaction { - type Error = TrustedError; +// spark uses uuid which are only 16 bytes, just pad 0 bytes to the back for ease +fn convert_from_uuid_id(uuid: [u8; 16]) -> [u8; 32] { + let mut bytes = [0; 32]; + bytes[..16].copy_from_slice(&uuid); + bytes +} - fn try_from(transfer: &WalletTransfer) -> Result { - let fee_sats: u64 = match &transfer.user_request { - Some(user_request) => match user_request { - SspUserRequest::LightningSendRequest(r) => { - r.fee.as_sats().map_err(|e| TrustedError::Other(format!("{e:?}")))? - }, - SspUserRequest::CoopExitRequest(r) => { - r.fee.as_sats().map_err(|e| TrustedError::Other(format!("{e:?}")))? - }, - SspUserRequest::LeavesSwapRequest(r) => { - r.fee.as_sats().map_err(|e| TrustedError::Other(format!("{e:?}")))? - }, - SspUserRequest::ClaimStaticDeposit(_) => 0, - SspUserRequest::LightningReceiveRequest(_) => 0, - }, - None => 0, - }; +impl From for TxStatus { + fn from(o: breez_sdk_spark::PaymentStatus) -> TxStatus { + match o { + breez_sdk_spark::PaymentStatus::Pending => TxStatus::Pending, + breez_sdk_spark::PaymentStatus::Completed => TxStatus::Completed, + breez_sdk_spark::PaymentStatus::Failed => TxStatus::Failed, + } + } +} - let payment_type = transfer - .user_request - .as_ref() - .map(|t| t.try_into()) - .transpose()? - .unwrap_or(PaymentType::TrustedInternal {}); - - Ok(StoreTransaction { - status: transfer.status.into(), - outbound: matches!(transfer.direction, spark_wallet::TransferDirection::Outgoing), - amount_msats: Some(transfer.total_value_sat * 1_000), - fee_msats: Some(fee_sats * 1000), - payment_type, - time_since_epoch: transfer - .updated_at - .map(|x| x.duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs()) - .unwrap_or(0), - }) +impl From for TrustedError { + fn from(e: SdkError) -> Self { + TrustedError::WalletOperationFailed(format!("{e:?}")) } } -impl TryFrom<&SspUserRequest> for PaymentType { - type Error = SparkWalletError; +const SPARK_PRIMARY_NAMESPACE: &str = "spark"; +const SPARK_CACHE_NAMESPACE: &str = "cache"; +const SPARK_PAYMENTS_NAMESPACE: &str = "payment"; +const SPARK_DEPOSITS_NAMESPACE: &str = "deposit"; + +#[derive(Clone)] +struct SparkStore(Arc); + +#[async_trait::async_trait] +impl breez_sdk_spark::Storage for SparkStore { + async fn delete_cached_item(&self, key: String) -> Result<(), StorageError> { + self.0 + .remove(SPARK_PRIMARY_NAMESPACE, SPARK_CACHE_NAMESPACE, &key, false) + .map_err(|e| StorageError::Implementation(format!("{e:?}")))?; + Ok(()) + } - fn try_from(user_request: &SspUserRequest) -> Result { - let details = match user_request { - SspUserRequest::CoopExitRequest(request) => PaymentType::OutgoingOnChain { - txid: Some(Txid::from_str(&request.coop_exit_txid).map_err(|e| { - SparkWalletError::Generic(format!("Invalid CoopExitRequest txid: {e}")) - })?), - }, - SspUserRequest::LeavesSwapRequest(_) => PaymentType::TrustedInternal {}, - SspUserRequest::LightningReceiveRequest(_) => PaymentType::IncomingLightning {}, - SspUserRequest::LightningSendRequest(request) => { - let preimage: Option<[u8; 32]> = request - .lightning_send_payment_preimage - .as_deref() - .map(|t| { - FromHex::from_hex(t).map_err(|e| { - SparkWalletError::Generic(format!( - "Invalid LightningSendRequest preimage: {e}" - )) - }) - }) - .transpose()?; - let payment_preimage = preimage.map(PaymentPreimage); - PaymentType::OutgoingLightningBolt11 { payment_preimage } - }, - SspUserRequest::ClaimStaticDeposit(request) => PaymentType::IncomingOnChain { - txid: Some(Txid::from_str(&request.transaction_id).map_err(|e| { - SparkWalletError::Generic(format!("Invalid CoopExitRequest txid: {e}")) - })?), + async fn get_cached_item(&self, key: String) -> Result, StorageError> { + match self.0.read(SPARK_PRIMARY_NAMESPACE, SPARK_CACHE_NAMESPACE, &key) { + Ok(bytes) => Ok(Some(String::from_utf8(bytes).map_err(|e| { + StorageError::Serialization(format!("Invalid UTF-8 in cached item: {e:?}")) + })?)), + Err(e) => { + if let io::ErrorKind::NotFound = e.kind() { + Ok(None) + } else { + Err(StorageError::Implementation(format!("{e:?}"))) + } }, - }; - Ok(details) + } } -} -fn get_id_from_wallet_transfer(transfer: &WalletTransfer) -> Result { - match &transfer.user_request { - Some(SspUserRequest::LightningSendRequest(request)) => { - // Spark uses UUIDs for payment IDs, so we need to convert them - // to our format. Spark uses a UUID in the format `SparkLightningSendRequest:` - // We only need the UUID part, so we split by ':' and take the last part. - // If the format is invalid, we return an error. - if let Some(id) = request.id.split(':').next_back() { - let uuid = Uuid::from_str(id).map_err(|_| { - TrustedError::Other(format!("Failed to parse payment id: {id}")) - })?; - Ok(uuid.to_string()) - } else { - // if it's not in the expected format, try to parse the whole thing as a uuid - let uuid = Uuid::from_str(&request.id).map_err(|_| { - TrustedError::Other(format!("Failed to parse payment id: {}", request.id)) - })?; - Ok(uuid.to_string()) - } - }, - None => Ok(transfer.id.to_string()), - _ => Ok(transfer.id.to_string()), // todo do we need to handle other types differently? + async fn set_cached_item(&self, key: String, value: String) -> Result<(), StorageError> { + self.0 + .write(SPARK_PRIMARY_NAMESPACE, SPARK_CACHE_NAMESPACE, &key, value.as_bytes()) + .map_err(|e| StorageError::Implementation(format!("{e:?}")))?; + Ok(()) } -} -impl From for TxStatus { - fn from(o: TransferStatus) -> TxStatus { - match o { - TransferStatus::SenderInitiated - | TransferStatus::SenderInitiatedCoordinator - | TransferStatus::SenderKeyTweakPending - | TransferStatus::SenderKeyTweaked - | TransferStatus::ReceiverKeyTweakLocked - | TransferStatus::ReceiverKeyTweakApplied - | TransferStatus::ReceiverKeyTweaked => TxStatus::Pending, - TransferStatus::Completed => TxStatus::Completed, - TransferStatus::Expired - | TransferStatus::Returned - | TransferStatus::ReceiverRefundSigned => TxStatus::Failed, + async fn list_payments( + &self, offset: Option, limit: Option, + ) -> Result, StorageError> { + let keys = self + .0 + .list(SPARK_PRIMARY_NAMESPACE, SPARK_PAYMENTS_NAMESPACE) + .map_err(|e| StorageError::Implementation(format!("{e:?}")))?; + + let mut payments = Vec::with_capacity(keys.len()); + for key in keys { + let data = self + .0 + .read(SPARK_PRIMARY_NAMESPACE, SPARK_PAYMENTS_NAMESPACE, &key) + .map_err(|e| StorageError::Implementation(format!("{e:?}")))?; + + let payment: breez_sdk_spark::Payment = serde_json::from_slice(&data) + .map_err(|e| StorageError::Serialization(format!("{e:?}")))?; + payments.push(payment); } + // sort + payments.sort_by_key(|p| p.timestamp); + + // apply offset and limit + let start = offset.unwrap_or(0) as usize; + let end = if let Some(l) = limit { + (start + l as usize).min(payments.len()) + } else { + payments.len() + }; + let payments = + if start < payments.len() { payments[start..end].to_vec() } else { Vec::new() }; + + Ok(payments) } -} -impl From for TxStatus { - fn from(o: LightningSendStatus) -> TxStatus { - match o { - LightningSendStatus::LightningPaymentSucceeded - | LightningSendStatus::TransferCompleted => TxStatus::Completed, - LightningSendStatus::TransferFailed - | LightningSendStatus::LightningPaymentFailed - | LightningSendStatus::UserSwapReturnFailed - | LightningSendStatus::PreimageProvidingFailed => TxStatus::Failed, - LightningSendStatus::Unknown - | LightningSendStatus::UserSwapReturned - | LightningSendStatus::PendingUserSwapReturn - | LightningSendStatus::Created - | LightningSendStatus::RequestValidated - | LightningSendStatus::LightningPaymentInitiated - | LightningSendStatus::PreimageProvided => TxStatus::Pending, + async fn insert_payment(&self, payment: breez_sdk_spark::Payment) -> Result<(), StorageError> { + let data = serde_json::to_vec(&payment) + .map_err(|e| StorageError::Serialization(format!("{e:?}")))?; + + self.0 + .write(SPARK_PRIMARY_NAMESPACE, SPARK_PAYMENTS_NAMESPACE, &payment.id, &data) + .map_err(|e| StorageError::Implementation(format!("{e:?}")))?; + Ok(()) + } + + async fn set_payment_metadata( + &self, _: String, _: PaymentMetadata, + ) -> Result<(), StorageError> { + // we don't use this + Ok(()) + } + + async fn get_payment_by_id( + &self, id: String, + ) -> Result { + let data = self + .0 + .read(SPARK_PRIMARY_NAMESPACE, SPARK_PAYMENTS_NAMESPACE, &id) + .map_err(|e| StorageError::Implementation(format!("{e:?}")))?; + + let payment: breez_sdk_spark::Payment = serde_json::from_slice(&data) + .map_err(|e| StorageError::Serialization(format!("{e:?}")))?; + Ok(payment) + } + + async fn add_deposit( + &self, txid: String, vout: u32, amount_sats: u64, + ) -> Result<(), StorageError> { + let id = format!("{txid}:{vout}"); + let info = DepositInfo { + txid, + vout, + amount_sats, + refund_tx: None, + refund_tx_id: None, + claim_error: None, + }; + + let data = + serde_json::to_vec(&info).map_err(|e| StorageError::Serialization(format!("{e:?}")))?; + + self.0 + .write(SPARK_PRIMARY_NAMESPACE, SPARK_DEPOSITS_NAMESPACE, &id, &data) + .map_err(|e| StorageError::Implementation(format!("{e:?}")))?; + + Ok(()) + } + + async fn delete_deposit(&self, txid: String, vout: u32) -> Result<(), StorageError> { + let id = format!("{txid}:{vout}"); + self.0 + .remove(SPARK_PRIMARY_NAMESPACE, SPARK_DEPOSITS_NAMESPACE, &id, false) + .map_err(|e| StorageError::Implementation(format!("{e:?}")))?; + Ok(()) + } + + async fn list_deposits(&self) -> Result, StorageError> { + let keys = self + .0 + .list(SPARK_PRIMARY_NAMESPACE, SPARK_DEPOSITS_NAMESPACE) + .map_err(|e| StorageError::Implementation(format!("{e:?}")))?; + + let mut deposits = Vec::with_capacity(keys.len()); + for key in keys { + let data = self + .0 + .read(SPARK_PRIMARY_NAMESPACE, SPARK_DEPOSITS_NAMESPACE, &key) + .map_err(|e| StorageError::Implementation(format!("{e:?}")))?; + + let deposit: DepositInfo = serde_json::from_slice(&data) + .map_err(|e| StorageError::Serialization(format!("{e:?}")))?; + deposits.push(deposit); } + + Ok(deposits) } -} -impl From for TrustedError { - fn from(e: SparkWalletError) -> Self { - match e { - SparkWalletError::ValidationError(_) => { - TrustedError::WalletOperationFailed(format!("{e:?}")) - }, - SparkWalletError::InsufficientFunds => TrustedError::InsufficientFunds, - SparkWalletError::InvalidNetwork => TrustedError::InvalidNetwork, - SparkWalletError::InvalidAddress(_) => { - TrustedError::WalletOperationFailed(format!("{e:?}")) - }, - SparkWalletError::InvalidOutputIndex => { - TrustedError::WalletOperationFailed(format!("{e:?}")) - }, - SparkWalletError::LeavesNotFound => { - TrustedError::WalletOperationFailed(format!("{e:?}")) - }, - SparkWalletError::NotADepositOutput => { - TrustedError::WalletOperationFailed(format!("{e:?}")) - }, - SparkWalletError::SignerServiceError(_) => { - TrustedError::WalletOperationFailed(format!("{e:?}")) - }, - SparkWalletError::DepositAddressUsed => { - TrustedError::WalletOperationFailed(format!("{e:?}")) - }, - SparkWalletError::OperatorRpcError(_) => { - TrustedError::WalletOperationFailed(format!("{e:?}")) - }, - SparkWalletError::OperatorPoolError(_) => { - TrustedError::WalletOperationFailed(format!("{e:?}")) - }, - SparkWalletError::AddressError(_) => { - TrustedError::WalletOperationFailed(format!("{e:?}")) - }, - SparkWalletError::TreeServiceError(_) => { - TrustedError::WalletOperationFailed(format!("{e:?}")) + async fn update_deposit( + &self, txid: String, vout: u32, payload: UpdateDepositPayload, + ) -> Result<(), StorageError> { + let id = format!("{txid}:{vout}"); + + // todo should we fail if not found + let data = self + .0 + .read(SPARK_PRIMARY_NAMESPACE, SPARK_DEPOSITS_NAMESPACE, &id) + .map_err(|e| StorageError::Implementation(format!("{e:?}")))?; + + let mut deposit: DepositInfo = serde_json::from_slice(&data) + .map_err(|e| StorageError::Serialization(format!("{e:?}")))?; + + match payload { + UpdateDepositPayload::ClaimError { error } => { + deposit.claim_error = Some(error); }, - SparkWalletError::ServiceError(_) => { - TrustedError::WalletOperationFailed(format!("{e:?}")) + UpdateDepositPayload::Refund { refund_txid, refund_tx } => { + deposit.refund_tx_id = Some(refund_txid); + deposit.refund_tx = Some(refund_tx); }, - SparkWalletError::SspError(_) => TrustedError::WalletOperationFailed(format!("{e:?}")), - SparkWalletError::Generic(str) => TrustedError::Other(str), } - } -} -// spark uses uuid which are only 16 bytes, just pad 0 bytes to the back for ease -fn convert_from_transfer_id(uuid: [u8; 16]) -> [u8; 32] { - let mut bytes = [0; 32]; - bytes[..16].copy_from_slice(&uuid); - bytes + let data = serde_json::to_vec(&deposit) + .map_err(|e| StorageError::Serialization(format!("{e:?}")))?; + + self.0 + .write(SPARK_PRIMARY_NAMESPACE, SPARK_DEPOSITS_NAMESPACE, &id, &data) + .map_err(|e| StorageError::Implementation(format!("{e:?}")))?; + + Ok(()) + } } From 1be844782585eb66e815b8654d9dca712520d89e Mon Sep 17 00:00:00 2001 From: benthecarman Date: Fri, 19 Sep 2025 16:42:38 -0500 Subject: [PATCH 2/8] Bunch of small fixes --- examples/cli/src/main.rs | 22 ++------ orange-sdk/Cargo.toml | 2 +- orange-sdk/src/ffi/spark.rs | 37 +++---------- orange-sdk/src/lib.rs | 6 +-- orange-sdk/src/trusted_wallet/mod.rs | 2 +- orange-sdk/src/trusted_wallet/spark.rs | 74 +++++++++++++++++--------- 6 files changed, 65 insertions(+), 78 deletions(-) diff --git a/examples/cli/src/main.rs b/examples/cli/src/main.rs index efa3978..74eb0ab 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, Mnemonic, PaymentInfo, Seed, SparkNetwork, SparkWalletConfig, - StorageConfig, Tunables, Wallet, WalletConfig, bitcoin::Network, + ChainSource, Event, ExtraConfig, Mnemonic, PaymentInfo, Seed, SparkWalletConfig, StorageConfig, + Tunables, Wallet, WalletConfig, bitcoin::Network, }; use rand::RngCore; use std::fs; @@ -89,14 +89,7 @@ fn get_config(network: Network) -> Result { network, seed, tunables: Tunables::default(), - extra_config: ExtraConfig::Spark(SparkWalletConfig { - api_key: None, - network: SparkNetwork::Regtest, - sync_interval_secs: 60, - max_deposit_claim_fee: None, - lnurl_domain: None, - prefer_spark_over_lightning: false, - }), + extra_config: ExtraConfig::Spark(SparkWalletConfig::default()), }) }, Network::Bitcoin => { @@ -123,14 +116,7 @@ fn get_config(network: Network) -> Result { network, seed, tunables: Tunables::default(), - extra_config: ExtraConfig::Spark(SparkWalletConfig { - api_key: None, - network: SparkNetwork::Mainnet, - sync_interval_secs: 60, - max_deposit_claim_fee: None, - lnurl_domain: None, - prefer_spark_over_lightning: false, - }), + extra_config: ExtraConfig::Spark(SparkWalletConfig::default()), }) }, _ => Err(anyhow::anyhow!("Unsupported network: {network:?}")), diff --git a/orange-sdk/Cargo.toml b/orange-sdk/Cargo.toml index 9e03116..ab42f48 100644 --- a/orange-sdk/Cargo.toml +++ b/orange-sdk/Cargo.toml @@ -23,7 +23,7 @@ bitcoin-payment-instructions = { workspace = true } chrono = { version = "0.4", default-features = false } rand = { version = "0.8.5", optional = true } reqwest = { version = "0.12.23", default-features = false, features = ["rustls-tls"] } -breez-sdk-spark = { git = "https://github.com/breez/spark-sdk.git", rev = "6994785e98c13191e6dc26146be6ce87c5c8d987", default-features = false, features = ["rustls-tls"], optional = true } +breez-sdk-spark = { git = "https://github.com/breez/spark-sdk.git", rev = "e0ecc7ff44ecc9d3ae82e40f5cb576e80711a45d", default-features = false, features = ["rustls-tls"], optional = true } tokio = { version = "1.0", default-features = false, features = ["rt-multi-thread", "sync"] } uuid = { version = "1.0", default-features = false, optional = true } cdk = { git = "https://github.com/benthecarman/cdk.git", rev = "7d25e9ae5ed7f47f9ae7e87d8a9ee16797fee8cd", default-features = false, features = ["wallet"], optional = true } diff --git a/orange-sdk/src/ffi/spark.rs b/orange-sdk/src/ffi/spark.rs index 42b76cb..d221ea0 100644 --- a/orange-sdk/src/ffi/spark.rs +++ b/orange-sdk/src/ffi/spark.rs @@ -1,43 +1,18 @@ -use spark_wallet::Network as SparkNetwork; -use spark_wallet::SparkWalletConfig as SparkSparkWalletConfig; - -use crate::ffi::Network; +use crate::SparkWalletConfig as OrangeSparkWalletConfig; use crate::{impl_from_core_type, impl_into_core_type}; -impl From for Network { - fn from(network: SparkNetwork) -> Self { - match network { - SparkNetwork::Mainnet => Network::Mainnet, - SparkNetwork::Regtest => Network::Regtest, - SparkNetwork::Testnet => Network::Testnet, - SparkNetwork::Signet => Network::Signet, - } - } -} - -impl From for SparkNetwork { - fn from(network: Network) -> Self { - match network { - Network::Mainnet => SparkNetwork::Mainnet, - Network::Regtest => SparkNetwork::Regtest, - Network::Testnet => SparkNetwork::Testnet, - Network::Signet => SparkNetwork::Signet, - } - } -} - #[derive(Clone, Debug, uniffi::Object)] -pub struct SparkWalletConfig(pub SparkSparkWalletConfig); +pub struct SparkWalletConfig(pub OrangeSparkWalletConfig); // TODO: For now just support the default configuration. // In the future we will want to expose all of the Spark configuration objects #[uniffi::export] impl SparkWalletConfig { #[uniffi::constructor] - pub fn default_config(network: Network) -> Self { - SparkWalletConfig(SparkSparkWalletConfig::default_config(network.into())) + pub fn default_config() -> Self { + SparkWalletConfig(OrangeSparkWalletConfig::default()) } } -impl_from_core_type!(SparkSparkWalletConfig, SparkWalletConfig); -impl_into_core_type!(SparkWalletConfig, SparkSparkWalletConfig); +impl_from_core_type!(OrangeSparkWalletConfig, SparkWalletConfig); +impl_into_core_type!(SparkWalletConfig, OrangeSparkWalletConfig); diff --git a/orange-sdk/src/lib.rs b/orange-sdk/src/lib.rs index a363dff..ccaf2d1 100644 --- a/orange-sdk/src/lib.rs +++ b/orange-sdk/src/lib.rs @@ -65,9 +65,9 @@ use trusted_wallet::TrustedError; #[cfg(feature = "cashu")] pub use crate::trusted_wallet::cashu::CashuConfig; -pub use bitcoin_payment_instructions; #[cfg(feature = "spark")] -pub use breez_sdk_spark::{Config as SparkWalletConfig, Fee, Network as SparkNetwork}; +pub use crate::trusted_wallet::spark::SparkWalletConfig; +pub use bitcoin_payment_instructions; #[cfg(feature = "cashu")] pub use cdk::nuts::nut00::CurrencyUnit; pub use event::{Event, EventQueue}; @@ -522,7 +522,7 @@ impl Wallet { ExtraConfig::Spark(sp) => Arc::new(Box::new( Spark::init( &config, - sp.clone(), + *sp, Arc::clone(&store), Arc::clone(&event_queue), tx_metadata.clone(), diff --git a/orange-sdk/src/trusted_wallet/mod.rs b/orange-sdk/src/trusted_wallet/mod.rs index 1323a9c..edfccb0 100644 --- a/orange-sdk/src/trusted_wallet/mod.rs +++ b/orange-sdk/src/trusted_wallet/mod.rs @@ -125,7 +125,7 @@ impl graduated_rebalancer::TrustedWallet for pub enum ExtraConfig { /// Configuration for Spark wallet. #[cfg(feature = "spark")] - Spark(crate::SparkWalletConfig), // todo make my own reduced version + Spark(crate::SparkWalletConfig), /// Configuration for Cashu wallet. #[cfg(feature = "cashu")] Cashu(cashu::CashuConfig), diff --git a/orange-sdk/src/trusted_wallet/spark.rs b/orange-sdk/src/trusted_wallet/spark.rs index 713b528..3dd1a39 100644 --- a/orange-sdk/src/trusted_wallet/spark.rs +++ b/orange-sdk/src/trusted_wallet/spark.rs @@ -1,6 +1,6 @@ //! An implementation of `TrustedWalletInterface` using the Spark SDK. use crate::bitcoin::hex::FromHex; -use crate::bitcoin::io; +use crate::bitcoin::{Network, io}; use crate::logging::Logger; use crate::store::{PaymentId, TxMetadataStore, TxStatus}; use crate::trusted_wallet::{Payment, TrustedError, TrustedWalletInterface}; @@ -32,17 +32,53 @@ use std::time::Duration; use tokio::runtime::Runtime; use uuid::Uuid; +/// Configuration options for the Spark wallet. +#[derive(Debug, Copy, Clone)] +pub struct SparkWalletConfig { + /// How often to sync the wallet with the blockchain, in seconds. + /// Default is 60 seconds. + pub sync_interval_secs: u32, + /// When this is set to `true` we will prefer to use spark payments over + /// 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, +} + +impl Default for SparkWalletConfig { + fn default() -> Self { + SparkWalletConfig { sync_interval_secs: 60, prefer_spark_over_lightning: false } + } +} + +/// Breez API key for using the Spark SDK. We aren't using any of their services +/// but the SDK requires a valid API key to function. +const BREEZ_API_KEY: &str = "MIIBajCCARygAwIBAgIHPnfOjAhBgzAFBgMrZXAwEDEOMAwGA1UEAxMFQnJlZXowHhcNMjUwOTE5MjEzNTU1WhcNMzUwOTE3MjEzNTU1WjAqMRMwEQYDVQQKEwpvcmFuZ2Utc2RrMRMwEQYDVQQDEwpvcmFuZ2Utc2RrMCowBQYDK2VwAyEA0IP1y98gPByiIMoph1P0G6cctLb864rNXw1LRLOpXXejezB5MA4GA1UdDwEB/wQEAwIFoDAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBTaOaPuXmtLDTJVv++VYBiQr9gHCTAfBgNVHSMEGDAWgBTeqtaSVvON53SSFvxMtiCyayiYazAZBgNVHREEEjAQgQ5iZW5Ac3BpcmFsLnh5ejAFBgMrZXADQQCry+1LkA3nrYa1sovS5iFI1Tkpmr/R0nM/4gJtsO93vFOkm3vBEGwjKAV7lrGzFcFbbuyM1wEJPi4Po1XCEG0D"; + +impl SparkWalletConfig { + fn to_breez_config(self, network: Network) -> Result { + let network = match network { + Network::Bitcoin => breez_sdk_spark::Network::Mainnet, + Network::Regtest => breez_sdk_spark::Network::Regtest, + _ => return Err(TrustedError::InvalidNetwork), + }; + + Ok(breez_sdk_spark::Config { + network, + sync_interval_secs: self.sync_interval_secs, + prefer_spark_over_lightning: self.prefer_spark_over_lightning, + api_key: Some(BREEZ_API_KEY.to_string()), + max_deposit_claim_fee: None, + lnurl_domain: None, + }) + } +} + /// A wallet implementation using the Breez Spark SDK. #[derive(Clone)] pub(crate) struct Spark { spark_wallet: Arc, - store: Arc, - event_queue: Arc, - tx_metadata: TxMetadataStore, shutdown_sender: watch::Sender<()>, - shutdown_receiver: watch::Receiver<()>, logger: Arc, - runtime: Arc, } impl TrustedWalletInterface for Spark { @@ -199,15 +235,11 @@ impl TrustedWalletInterface for Spark { impl Spark { /// Initialize a new Spark wallet instance with the given configuration. pub(crate) async fn init( - config: &WalletConfig, spark_config: breez_sdk_spark::Config, + config: &WalletConfig, spark_config: SparkWalletConfig, store: Arc, event_queue: Arc, tx_metadata: TxMetadataStore, logger: Arc, runtime: Arc, ) -> Result { - match (config.network, spark_config.network) { - (crate::bitcoin::Network::Bitcoin, breez_sdk_spark::Network::Mainnet) => {}, - (crate::bitcoin::Network::Regtest, breez_sdk_spark::Network::Regtest) => {}, - _ => Err(TrustedError::InvalidNetwork)?, - } + let spark_config: breez_sdk_spark::Config = spark_config.to_breez_config(config.network)?; let (mnemonic, passphrase) = match &config.seed { Seed::Seed64(bytes) => { @@ -217,7 +249,7 @@ impl Spark { Seed::Mnemonic { mnemonic, passphrase } => (mnemonic.to_string(), passphrase.clone()), }; - let spark_store = SparkStore(Arc::clone(&store)); + let spark_store = SparkStore(store); let builder = SdkBuilder::new(spark_config, mnemonic, passphrase, Arc::new(spark_store)); let spark_wallet = Arc::new(builder.build().await.map_err(|e| { @@ -230,6 +262,7 @@ impl Spark { let (shutdown_sender, shutdown_receiver) = watch::channel::<()>(()); let listener = SparkEventHandler { event_queue: Arc::clone(&event_queue), + tx_metadata, logger: Arc::clone(&logger), }; @@ -244,21 +277,14 @@ impl Spark { log_info!(logger, "Spark wallet initialized"); - Ok(Spark { - spark_wallet, - store, - event_queue, - tx_metadata, - shutdown_sender, - shutdown_receiver, - logger, - runtime, - }) + Ok(Spark { spark_wallet, shutdown_sender, logger }) } } struct SparkEventHandler { event_queue: Arc, + #[allow(unused)] // will be used in future events + tx_metadata: TxMetadataStore, logger: Arc, } @@ -370,7 +396,7 @@ fn parse_payment_id(id: &str) -> Result<[u8; 32], TrustedError> { .map_err(|_| TrustedError::Other(format!("Failed to parse payment id: {id}")))? } else { // if it's not in the expected format, try to parse the whole thing as a uuid - Uuid::from_str(&id) + Uuid::from_str(id) .map_err(|_| TrustedError::Other(format!("Failed to parse payment id: {id}")))? }; Ok(convert_from_uuid_id(uuid.into_bytes())) From 742ff9b67d59c67ccac69ca0d121ba692fde059a Mon Sep 17 00:00:00 2001 From: benthecarman Date: Fri, 19 Sep 2025 16:51:25 -0500 Subject: [PATCH 3/8] Support amountless invoices for spark --- orange-sdk/src/trusted_wallet/spark.rs | 34 ++++++++++++-------------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/orange-sdk/src/trusted_wallet/spark.rs b/orange-sdk/src/trusted_wallet/spark.rs index 3dd1a39..d82beb5 100644 --- a/orange-sdk/src/trusted_wallet/spark.rs +++ b/orange-sdk/src/trusted_wallet/spark.rs @@ -103,33 +103,29 @@ impl TrustedWalletInterface for Spark { &self, amount: Option, ) -> Pin> + Send + '_>> { Box::pin(async move { - match amount { - None => Err(TrustedError::UnsupportedOperation( - "Spark does not support amount-less invoices".to_owned(), - )), - Some(a) if a == Amount::ZERO => Err(TrustedError::UnsupportedOperation( - "Spark does not support amount-less invoices".to_owned(), - )), + // check amount is not msat value + let amount_sats = match amount { Some(a) => { let sats = a.sats().map_err(|_| { TrustedError::UnsupportedOperation( "msat amounts not supported by spark".to_owned(), ) })?; + Some(sats) + }, + None => None, + }; - let params = ReceivePaymentRequest { - payment_method: ReceivePaymentMethod::Bolt11Invoice { - description: "".to_string(), - // TODO: can we do amountless now? - amount_sats: Some(sats), - }, - }; - let res = self.spark_wallet.receive_payment(params).await?; - - Bolt11Invoice::from_str(&res.payment_request) - .map_err(|e| TrustedError::Other(format!("Failed to parse invoice: {e}"))) + let params = ReceivePaymentRequest { + payment_method: ReceivePaymentMethod::Bolt11Invoice { + description: "".to_string(), // empty description for smaller QRs and better privacy + amount_sats, }, - } + }; + let res = self.spark_wallet.receive_payment(params).await?; + + Bolt11Invoice::from_str(&res.payment_request) + .map_err(|e| TrustedError::Other(format!("Failed to parse invoice: {e}"))) }) } From 824e668a7148b14b1ed0f3e2cf28733590342682 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Fri, 19 Sep 2025 17:19:11 -0500 Subject: [PATCH 4/8] Fix seed64 for spark wallets --- orange-sdk/src/trusted_wallet/spark.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/orange-sdk/src/trusted_wallet/spark.rs b/orange-sdk/src/trusted_wallet/spark.rs index d82beb5..6184567 100644 --- a/orange-sdk/src/trusted_wallet/spark.rs +++ b/orange-sdk/src/trusted_wallet/spark.rs @@ -1,4 +1,5 @@ //! An implementation of `TrustedWalletInterface` using the Spark SDK. +use crate::bitcoin::hashes::{Hash, sha256}; use crate::bitcoin::hex::FromHex; use crate::bitcoin::{Network, io}; use crate::logging::Logger; @@ -239,7 +240,9 @@ impl Spark { let (mnemonic, passphrase) = match &config.seed { Seed::Seed64(bytes) => { - let mnemonic = Mnemonic::from_entropy(bytes.as_slice()).expect("valid length"); + // max entropy for bip39 is 32 bytes, so we hash the 64 bytes down to 32 + let hash = sha256::Hash::hash(bytes); + let mnemonic = Mnemonic::from_entropy(hash.as_byte_array()).expect("valid length"); (mnemonic.to_string(), None) }, Seed::Mnemonic { mnemonic, passphrase } => (mnemonic.to_string(), passphrase.clone()), From 2dc7e91260d95e4fd1bb63c3aef5525c7d5d75db Mon Sep 17 00:00:00 2001 From: benthecarman Date: Fri, 19 Sep 2025 17:30:10 -0500 Subject: [PATCH 5/8] Properly convert breez payment to ours --- orange-sdk/Cargo.toml | 2 +- orange-sdk/src/trusted_wallet/spark.rs | 85 ++++++++++++++++++-------- 2 files changed, 62 insertions(+), 25 deletions(-) diff --git a/orange-sdk/Cargo.toml b/orange-sdk/Cargo.toml index ab42f48..6463521 100644 --- a/orange-sdk/Cargo.toml +++ b/orange-sdk/Cargo.toml @@ -23,7 +23,7 @@ bitcoin-payment-instructions = { workspace = true } chrono = { version = "0.4", default-features = false } rand = { version = "0.8.5", optional = true } reqwest = { version = "0.12.23", default-features = false, features = ["rustls-tls"] } -breez-sdk-spark = { git = "https://github.com/breez/spark-sdk.git", rev = "e0ecc7ff44ecc9d3ae82e40f5cb576e80711a45d", default-features = false, features = ["rustls-tls"], optional = true } +breez-sdk-spark = { git = "https://github.com/breez/spark-sdk.git", rev = "41212dfcfe36e22a55ac224c791b326f259f90d6", default-features = false, features = ["rustls-tls"], optional = true } tokio = { version = "1.0", default-features = false, features = ["rt-multi-thread", "sync"] } uuid = { version = "1.0", default-features = false, optional = true } cdk = { git = "https://github.com/benthecarman/cdk.git", rev = "7d25e9ae5ed7f47f9ae7e87d8a9ee16797fee8cd", default-features = false, features = ["wallet"], optional = true } diff --git a/orange-sdk/src/trusted_wallet/spark.rs b/orange-sdk/src/trusted_wallet/spark.rs index 6184567..0446ac0 100644 --- a/orange-sdk/src/trusted_wallet/spark.rs +++ b/orange-sdk/src/trusted_wallet/spark.rs @@ -1,11 +1,10 @@ //! An implementation of `TrustedWalletInterface` using the Spark SDK. -use crate::bitcoin::hashes::{Hash, sha256}; use crate::bitcoin::hex::FromHex; use crate::bitcoin::{Network, io}; use crate::logging::Logger; use crate::store::{PaymentId, TxMetadataStore, TxStatus}; use crate::trusted_wallet::{Payment, TrustedError, TrustedWalletInterface}; -use crate::{Event, EventQueue, InitFailure, Mnemonic, Seed, WalletConfig}; +use crate::{Event, EventQueue, InitFailure, Seed, WalletConfig}; use ldk_node::lightning::util::logger::Logger as _; use ldk_node::lightning::util::persist::KVStore; @@ -139,20 +138,8 @@ impl TrustedWalletInterface for Spark { .list_payments(ListPaymentsRequest { limit: None, offset: None }) .await?; - let payments = resp - .payments - .into_iter() - .map(|p| { - parse_payment_id(&p.id).map(|id| Payment { - id, - amount: Amount::from_sats(p.amount).unwrap(), - fee: Amount::from_sats(p.fees).unwrap(), - status: p.status.into(), - outbound: p.payment_type == PaymentType::Send, - time_since_epoch: Duration::from_secs(p.timestamp), - }) - }) - .collect::>()?; + let payments = + resp.payments.into_iter().map(|p| p.try_into()).collect::>()?; Ok(payments) }) @@ -238,18 +225,16 @@ impl Spark { ) -> Result { let spark_config: breez_sdk_spark::Config = spark_config.to_breez_config(config.network)?; - let (mnemonic, passphrase) = match &config.seed { - Seed::Seed64(bytes) => { - // max entropy for bip39 is 32 bytes, so we hash the 64 bytes down to 32 - let hash = sha256::Hash::hash(bytes); - let mnemonic = Mnemonic::from_entropy(hash.as_byte_array()).expect("valid length"); - (mnemonic.to_string(), None) + let seed = match &config.seed { + Seed::Seed64(bytes) => breez_sdk_spark::Seed::Entropy(bytes.to_vec()), + Seed::Mnemonic { mnemonic, passphrase } => breez_sdk_spark::Seed::Mnemonic { + mnemonic: mnemonic.to_string(), + passphrase: passphrase.clone(), }, - Seed::Mnemonic { mnemonic, passphrase } => (mnemonic.to_string(), passphrase.clone()), }; let spark_store = SparkStore(store); - let builder = SdkBuilder::new(spark_config, mnemonic, passphrase, Arc::new(spark_store)); + let builder = SdkBuilder::new(spark_config, seed, Arc::new(spark_store)); let spark_wallet = Arc::new(builder.build().await.map_err(|e| { log_error!(logger, "Failed to initialize Spark wallet: {e:?}"); @@ -307,6 +292,11 @@ impl EventListener for SparkEventHandler { log_error!(self.logger, "Failed to handle payment succeeded: {e:?}"); } }, + SdkEvent::PaymentFailed { payment } => { + if let Err(e) = self.handle_payment_failed(payment) { + log_error!(self.logger, "Failed to handle payment succeeded: {e:?}"); + } + }, } } } @@ -383,6 +373,36 @@ impl SparkEventHandler { Ok(()) } + + fn handle_payment_failed(&self, payment: breez_sdk_spark::Payment) -> Result<(), TrustedError> { + log_info!(self.logger, "Spark payment failed: {payment:?}"); + + let id = parse_payment_id(&payment.id)?; + + match payment.payment_type { + PaymentType::Send => match payment.details { + Some(PaymentDetails::Lightning { payment_hash, .. }) => { + let payment_hash: [u8; 32] = FromHex::from_hex(&payment_hash).map_err(|e| { + TrustedError::Other(format!("Invalid payment_hash hex: {e:?}")) + })?; + + self.event_queue.add_event(Event::PaymentFailed { + payment_id: PaymentId::Trusted(id), + payment_hash: Some(PaymentHash(payment_hash)), + reason: None, + })?; + }, + _ => { + log_debug!(self.logger, "Unsupported payment details for Send: {payment:?}") + }, + }, + PaymentType::Receive => { + log_debug!(self.logger, "Receive payments cannot fail: {payment:?}"); + }, + } + + Ok(()) + } } fn parse_payment_id(id: &str) -> Result<[u8; 32], TrustedError> { @@ -614,3 +634,20 @@ impl breez_sdk_spark::Storage for SparkStore { Ok(()) } } + +impl TryFrom for Payment { + type Error = TrustedError; + + fn try_from(value: breez_sdk_spark::Payment) -> Result { + let id = parse_payment_id(&value.id)?; + + Ok(Payment { + id, + amount: Amount::from_sats(value.amount).map_err(|_| TrustedError::AmountError)?, + fee: Amount::from_sats(value.fees).map_err(|_| TrustedError::AmountError)?, + status: value.status.into(), + outbound: value.payment_type == PaymentType::Send, + time_since_epoch: Duration::from_secs(value.timestamp), + }) + } +} From 1785a3fc5d0166a8c2a336ab9597f5f186f6e034 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Mon, 22 Sep 2025 14:19:47 -0500 Subject: [PATCH 6/8] Don't fail on update_deposit --- orange-sdk/src/trusted_wallet/spark.rs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/orange-sdk/src/trusted_wallet/spark.rs b/orange-sdk/src/trusted_wallet/spark.rs index 0446ac0..2823315 100644 --- a/orange-sdk/src/trusted_wallet/spark.rs +++ b/orange-sdk/src/trusted_wallet/spark.rs @@ -605,11 +605,17 @@ impl breez_sdk_spark::Storage for SparkStore { ) -> Result<(), StorageError> { let id = format!("{txid}:{vout}"); - // todo should we fail if not found - let data = self - .0 - .read(SPARK_PRIMARY_NAMESPACE, SPARK_DEPOSITS_NAMESPACE, &id) - .map_err(|e| StorageError::Implementation(format!("{e:?}")))?; + let data = match self.0.read(SPARK_PRIMARY_NAMESPACE, SPARK_DEPOSITS_NAMESPACE, &id) { + Ok(data) => data, + Err(e) => { + if let io::ErrorKind::NotFound = e.kind() { + // deposit does not exist, nothing to update + return Ok(()); + } else { + Err(StorageError::Implementation(format!("{e:?}")))? + } + }, + }; let mut deposit: DepositInfo = serde_json::from_slice(&data) .map_err(|e| StorageError::Serialization(format!("{e:?}")))?; From 88bbbf3cd2e4367d911749410a44c934fdf1cda5 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Thu, 25 Sep 2025 13:46:22 -0500 Subject: [PATCH 7/8] Don't send events on rebalances --- orange-sdk/src/trusted_wallet/spark.rs | 33 +++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/orange-sdk/src/trusted_wallet/spark.rs b/orange-sdk/src/trusted_wallet/spark.rs index 2823315..23f984f 100644 --- a/orange-sdk/src/trusted_wallet/spark.rs +++ b/orange-sdk/src/trusted_wallet/spark.rs @@ -267,7 +267,6 @@ impl Spark { struct SparkEventHandler { event_queue: Arc, - #[allow(unused)] // will be used in future events tx_metadata: TxMetadataStore, logger: Arc, } @@ -313,6 +312,20 @@ impl SparkEventHandler { PaymentType::Send => { match payment.details { Some(PaymentDetails::Lightning { preimage, payment_hash, .. }) => { + let payment_id = PaymentId::Trusted(id); + let is_rebalance = { + let map = self.tx_metadata.read(); + map.get(&payment_id).is_some_and(|m| m.ty.is_rebalance()) + }; + + if is_rebalance { + log_info!( + self.logger, + "Ignoring successful payment event for rebalance payment: {payment_id:?}" + ); + return Ok(()); + } + let preimage = preimage.ok_or_else(|| { TrustedError::Other( "Payment succeeded but preimage is missing".to_string(), @@ -328,7 +341,7 @@ impl SparkEventHandler { })?; self.event_queue.add_event(Event::PaymentSuccessful { - payment_id: PaymentId::Trusted(id), + payment_id, payment_hash: PaymentHash(payment_hash), payment_preimage: PaymentPreimage(preimage), fee_paid_msat: Some(payment.fees * 1_000), // convert to msats @@ -382,12 +395,26 @@ impl SparkEventHandler { match payment.payment_type { PaymentType::Send => match payment.details { Some(PaymentDetails::Lightning { payment_hash, .. }) => { + let payment_id = PaymentId::Trusted(id); + let is_rebalance = { + let map = self.tx_metadata.read(); + map.get(&payment_id).is_some_and(|m| m.ty.is_rebalance()) + }; + + if is_rebalance { + log_info!( + self.logger, + "Ignoring failed payment event for rebalance payment: {payment_id:?}" + ); + return Ok(()); + } + let payment_hash: [u8; 32] = FromHex::from_hex(&payment_hash).map_err(|e| { TrustedError::Other(format!("Invalid payment_hash hex: {e:?}")) })?; self.event_queue.add_event(Event::PaymentFailed { - payment_id: PaymentId::Trusted(id), + payment_id, payment_hash: Some(PaymentHash(payment_hash)), reason: None, })?; From 97ae7859fe611f5a619efa6934283738606475e8 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Thu, 25 Sep 2025 13:58:30 -0500 Subject: [PATCH 8/8] Move spark to its own module --- .../trusted_wallet/{spark.rs => spark/mod.rs} | 211 +----------------- .../src/trusted_wallet/spark/spark_store.rs | 204 +++++++++++++++++ 2 files changed, 212 insertions(+), 203 deletions(-) rename orange-sdk/src/trusted_wallet/{spark.rs => spark/mod.rs} (70%) create mode 100644 orange-sdk/src/trusted_wallet/spark/spark_store.rs diff --git a/orange-sdk/src/trusted_wallet/spark.rs b/orange-sdk/src/trusted_wallet/spark/mod.rs similarity index 70% rename from orange-sdk/src/trusted_wallet/spark.rs rename to orange-sdk/src/trusted_wallet/spark/mod.rs index 23f984f..9436ffb 100644 --- a/orange-sdk/src/trusted_wallet/spark.rs +++ b/orange-sdk/src/trusted_wallet/spark/mod.rs @@ -1,6 +1,9 @@ //! An implementation of `TrustedWalletInterface` using the Spark SDK. + +pub(crate) mod spark_store; + +use crate::bitcoin::Network; use crate::bitcoin::hex::FromHex; -use crate::bitcoin::{Network, io}; use crate::logging::Logger; use crate::store::{PaymentId, TxMetadataStore, TxStatus}; use crate::trusted_wallet::{Payment, TrustedError, TrustedWalletInterface}; @@ -16,10 +19,9 @@ use bitcoin_payment_instructions::PaymentMethod; use bitcoin_payment_instructions::amount::Amount; use breez_sdk_spark::{ - BreezSdk, DepositInfo, EventListener, GetInfoRequest, ListPaymentsRequest, PaymentDetails, - PaymentMetadata, PaymentType, PrepareSendPaymentRequest, ReceivePaymentMethod, - ReceivePaymentRequest, SdkBuilder, SdkError, SdkEvent, SendPaymentMethod, SendPaymentRequest, - StorageError, UpdateDepositPayload, + BreezSdk, EventListener, GetInfoRequest, ListPaymentsRequest, PaymentDetails, PaymentType, + PrepareSendPaymentRequest, ReceivePaymentMethod, ReceivePaymentRequest, SdkBuilder, SdkError, + SdkEvent, SendPaymentMethod, SendPaymentRequest, }; use tokio::sync::watch; @@ -233,7 +235,7 @@ impl Spark { }, }; - let spark_store = SparkStore(store); + let spark_store = spark_store::SparkStore(store); let builder = SdkBuilder::new(spark_config, seed, Arc::new(spark_store)); let spark_wallet = Arc::new(builder.build().await.map_err(|e| { @@ -471,203 +473,6 @@ impl From for TrustedError { } } -const SPARK_PRIMARY_NAMESPACE: &str = "spark"; -const SPARK_CACHE_NAMESPACE: &str = "cache"; -const SPARK_PAYMENTS_NAMESPACE: &str = "payment"; -const SPARK_DEPOSITS_NAMESPACE: &str = "deposit"; - -#[derive(Clone)] -struct SparkStore(Arc); - -#[async_trait::async_trait] -impl breez_sdk_spark::Storage for SparkStore { - async fn delete_cached_item(&self, key: String) -> Result<(), StorageError> { - self.0 - .remove(SPARK_PRIMARY_NAMESPACE, SPARK_CACHE_NAMESPACE, &key, false) - .map_err(|e| StorageError::Implementation(format!("{e:?}")))?; - Ok(()) - } - - async fn get_cached_item(&self, key: String) -> Result, StorageError> { - match self.0.read(SPARK_PRIMARY_NAMESPACE, SPARK_CACHE_NAMESPACE, &key) { - Ok(bytes) => Ok(Some(String::from_utf8(bytes).map_err(|e| { - StorageError::Serialization(format!("Invalid UTF-8 in cached item: {e:?}")) - })?)), - Err(e) => { - if let io::ErrorKind::NotFound = e.kind() { - Ok(None) - } else { - Err(StorageError::Implementation(format!("{e:?}"))) - } - }, - } - } - - async fn set_cached_item(&self, key: String, value: String) -> Result<(), StorageError> { - self.0 - .write(SPARK_PRIMARY_NAMESPACE, SPARK_CACHE_NAMESPACE, &key, value.as_bytes()) - .map_err(|e| StorageError::Implementation(format!("{e:?}")))?; - Ok(()) - } - - async fn list_payments( - &self, offset: Option, limit: Option, - ) -> Result, StorageError> { - let keys = self - .0 - .list(SPARK_PRIMARY_NAMESPACE, SPARK_PAYMENTS_NAMESPACE) - .map_err(|e| StorageError::Implementation(format!("{e:?}")))?; - - let mut payments = Vec::with_capacity(keys.len()); - for key in keys { - let data = self - .0 - .read(SPARK_PRIMARY_NAMESPACE, SPARK_PAYMENTS_NAMESPACE, &key) - .map_err(|e| StorageError::Implementation(format!("{e:?}")))?; - - let payment: breez_sdk_spark::Payment = serde_json::from_slice(&data) - .map_err(|e| StorageError::Serialization(format!("{e:?}")))?; - payments.push(payment); - } - // sort - payments.sort_by_key(|p| p.timestamp); - - // apply offset and limit - let start = offset.unwrap_or(0) as usize; - let end = if let Some(l) = limit { - (start + l as usize).min(payments.len()) - } else { - payments.len() - }; - let payments = - if start < payments.len() { payments[start..end].to_vec() } else { Vec::new() }; - - Ok(payments) - } - - async fn insert_payment(&self, payment: breez_sdk_spark::Payment) -> Result<(), StorageError> { - let data = serde_json::to_vec(&payment) - .map_err(|e| StorageError::Serialization(format!("{e:?}")))?; - - self.0 - .write(SPARK_PRIMARY_NAMESPACE, SPARK_PAYMENTS_NAMESPACE, &payment.id, &data) - .map_err(|e| StorageError::Implementation(format!("{e:?}")))?; - Ok(()) - } - - async fn set_payment_metadata( - &self, _: String, _: PaymentMetadata, - ) -> Result<(), StorageError> { - // we don't use this - Ok(()) - } - - async fn get_payment_by_id( - &self, id: String, - ) -> Result { - let data = self - .0 - .read(SPARK_PRIMARY_NAMESPACE, SPARK_PAYMENTS_NAMESPACE, &id) - .map_err(|e| StorageError::Implementation(format!("{e:?}")))?; - - let payment: breez_sdk_spark::Payment = serde_json::from_slice(&data) - .map_err(|e| StorageError::Serialization(format!("{e:?}")))?; - Ok(payment) - } - - async fn add_deposit( - &self, txid: String, vout: u32, amount_sats: u64, - ) -> Result<(), StorageError> { - let id = format!("{txid}:{vout}"); - let info = DepositInfo { - txid, - vout, - amount_sats, - refund_tx: None, - refund_tx_id: None, - claim_error: None, - }; - - let data = - serde_json::to_vec(&info).map_err(|e| StorageError::Serialization(format!("{e:?}")))?; - - self.0 - .write(SPARK_PRIMARY_NAMESPACE, SPARK_DEPOSITS_NAMESPACE, &id, &data) - .map_err(|e| StorageError::Implementation(format!("{e:?}")))?; - - Ok(()) - } - - async fn delete_deposit(&self, txid: String, vout: u32) -> Result<(), StorageError> { - let id = format!("{txid}:{vout}"); - self.0 - .remove(SPARK_PRIMARY_NAMESPACE, SPARK_DEPOSITS_NAMESPACE, &id, false) - .map_err(|e| StorageError::Implementation(format!("{e:?}")))?; - Ok(()) - } - - async fn list_deposits(&self) -> Result, StorageError> { - let keys = self - .0 - .list(SPARK_PRIMARY_NAMESPACE, SPARK_DEPOSITS_NAMESPACE) - .map_err(|e| StorageError::Implementation(format!("{e:?}")))?; - - let mut deposits = Vec::with_capacity(keys.len()); - for key in keys { - let data = self - .0 - .read(SPARK_PRIMARY_NAMESPACE, SPARK_DEPOSITS_NAMESPACE, &key) - .map_err(|e| StorageError::Implementation(format!("{e:?}")))?; - - let deposit: DepositInfo = serde_json::from_slice(&data) - .map_err(|e| StorageError::Serialization(format!("{e:?}")))?; - deposits.push(deposit); - } - - Ok(deposits) - } - - async fn update_deposit( - &self, txid: String, vout: u32, payload: UpdateDepositPayload, - ) -> Result<(), StorageError> { - let id = format!("{txid}:{vout}"); - - let data = match self.0.read(SPARK_PRIMARY_NAMESPACE, SPARK_DEPOSITS_NAMESPACE, &id) { - Ok(data) => data, - Err(e) => { - if let io::ErrorKind::NotFound = e.kind() { - // deposit does not exist, nothing to update - return Ok(()); - } else { - Err(StorageError::Implementation(format!("{e:?}")))? - } - }, - }; - - let mut deposit: DepositInfo = serde_json::from_slice(&data) - .map_err(|e| StorageError::Serialization(format!("{e:?}")))?; - - match payload { - UpdateDepositPayload::ClaimError { error } => { - deposit.claim_error = Some(error); - }, - UpdateDepositPayload::Refund { refund_txid, refund_tx } => { - deposit.refund_tx_id = Some(refund_txid); - deposit.refund_tx = Some(refund_tx); - }, - } - - let data = serde_json::to_vec(&deposit) - .map_err(|e| StorageError::Serialization(format!("{e:?}")))?; - - self.0 - .write(SPARK_PRIMARY_NAMESPACE, SPARK_DEPOSITS_NAMESPACE, &id, &data) - .map_err(|e| StorageError::Implementation(format!("{e:?}")))?; - - Ok(()) - } -} - impl TryFrom for Payment { type Error = TrustedError; diff --git a/orange-sdk/src/trusted_wallet/spark/spark_store.rs b/orange-sdk/src/trusted_wallet/spark/spark_store.rs new file mode 100644 index 0000000..6c986d5 --- /dev/null +++ b/orange-sdk/src/trusted_wallet/spark/spark_store.rs @@ -0,0 +1,204 @@ +//! Spark KV store implementation + +use std::sync::Arc; + +use crate::{KVStore, io}; + +use breez_sdk_spark::{DepositInfo, PaymentMetadata, StorageError, UpdateDepositPayload}; + +const SPARK_PRIMARY_NAMESPACE: &str = "spark"; +const SPARK_CACHE_NAMESPACE: &str = "cache"; +const SPARK_PAYMENTS_NAMESPACE: &str = "payment"; +const SPARK_DEPOSITS_NAMESPACE: &str = "deposit"; + +#[derive(Clone)] +pub(crate) struct SparkStore(pub(crate) Arc); + +#[async_trait::async_trait] +impl breez_sdk_spark::Storage for SparkStore { + async fn delete_cached_item(&self, key: String) -> Result<(), StorageError> { + self.0 + .remove(SPARK_PRIMARY_NAMESPACE, SPARK_CACHE_NAMESPACE, &key, false) + .map_err(|e| StorageError::Implementation(format!("{e:?}")))?; + Ok(()) + } + + async fn get_cached_item(&self, key: String) -> Result, StorageError> { + match self.0.read(SPARK_PRIMARY_NAMESPACE, SPARK_CACHE_NAMESPACE, &key) { + Ok(bytes) => Ok(Some(String::from_utf8(bytes).map_err(|e| { + StorageError::Serialization(format!("Invalid UTF-8 in cached item: {e:?}")) + })?)), + Err(e) => { + if let io::ErrorKind::NotFound = e.kind() { + Ok(None) + } else { + Err(StorageError::Implementation(format!("{e:?}"))) + } + }, + } + } + + async fn set_cached_item(&self, key: String, value: String) -> Result<(), StorageError> { + self.0 + .write(SPARK_PRIMARY_NAMESPACE, SPARK_CACHE_NAMESPACE, &key, value.as_bytes()) + .map_err(|e| StorageError::Implementation(format!("{e:?}")))?; + Ok(()) + } + + async fn list_payments( + &self, offset: Option, limit: Option, + ) -> Result, StorageError> { + let keys = self + .0 + .list(SPARK_PRIMARY_NAMESPACE, SPARK_PAYMENTS_NAMESPACE) + .map_err(|e| StorageError::Implementation(format!("{e:?}")))?; + + let mut payments = Vec::with_capacity(keys.len()); + for key in keys { + let data = self + .0 + .read(SPARK_PRIMARY_NAMESPACE, SPARK_PAYMENTS_NAMESPACE, &key) + .map_err(|e| StorageError::Implementation(format!("{e:?}")))?; + + let payment: breez_sdk_spark::Payment = serde_json::from_slice(&data) + .map_err(|e| StorageError::Serialization(format!("{e:?}")))?; + payments.push(payment); + } + // sort + payments.sort_by_key(|p| p.timestamp); + + // apply offset and limit + let start = offset.unwrap_or(0) as usize; + let end = if let Some(l) = limit { + (start + l as usize).min(payments.len()) + } else { + payments.len() + }; + let payments = + if start < payments.len() { payments[start..end].to_vec() } else { Vec::new() }; + + Ok(payments) + } + + async fn insert_payment(&self, payment: breez_sdk_spark::Payment) -> Result<(), StorageError> { + let data = serde_json::to_vec(&payment) + .map_err(|e| StorageError::Serialization(format!("{e:?}")))?; + + self.0 + .write(SPARK_PRIMARY_NAMESPACE, SPARK_PAYMENTS_NAMESPACE, &payment.id, &data) + .map_err(|e| StorageError::Implementation(format!("{e:?}")))?; + Ok(()) + } + + async fn set_payment_metadata( + &self, _: String, _: PaymentMetadata, + ) -> Result<(), StorageError> { + // we don't use this + Ok(()) + } + + async fn get_payment_by_id( + &self, id: String, + ) -> Result { + let data = self + .0 + .read(SPARK_PRIMARY_NAMESPACE, SPARK_PAYMENTS_NAMESPACE, &id) + .map_err(|e| StorageError::Implementation(format!("{e:?}")))?; + + let payment: breez_sdk_spark::Payment = serde_json::from_slice(&data) + .map_err(|e| StorageError::Serialization(format!("{e:?}")))?; + Ok(payment) + } + + async fn add_deposit( + &self, txid: String, vout: u32, amount_sats: u64, + ) -> Result<(), StorageError> { + let id = format!("{txid}:{vout}"); + let info = DepositInfo { + txid, + vout, + amount_sats, + refund_tx: None, + refund_tx_id: None, + claim_error: None, + }; + + let data = + serde_json::to_vec(&info).map_err(|e| StorageError::Serialization(format!("{e:?}")))?; + + self.0 + .write(SPARK_PRIMARY_NAMESPACE, SPARK_DEPOSITS_NAMESPACE, &id, &data) + .map_err(|e| StorageError::Implementation(format!("{e:?}")))?; + + Ok(()) + } + + async fn delete_deposit(&self, txid: String, vout: u32) -> Result<(), StorageError> { + let id = format!("{txid}:{vout}"); + self.0 + .remove(SPARK_PRIMARY_NAMESPACE, SPARK_DEPOSITS_NAMESPACE, &id, false) + .map_err(|e| StorageError::Implementation(format!("{e:?}")))?; + Ok(()) + } + + async fn list_deposits(&self) -> Result, StorageError> { + let keys = self + .0 + .list(SPARK_PRIMARY_NAMESPACE, SPARK_DEPOSITS_NAMESPACE) + .map_err(|e| StorageError::Implementation(format!("{e:?}")))?; + + let mut deposits = Vec::with_capacity(keys.len()); + for key in keys { + let data = self + .0 + .read(SPARK_PRIMARY_NAMESPACE, SPARK_DEPOSITS_NAMESPACE, &key) + .map_err(|e| StorageError::Implementation(format!("{e:?}")))?; + + let deposit: DepositInfo = serde_json::from_slice(&data) + .map_err(|e| StorageError::Serialization(format!("{e:?}")))?; + deposits.push(deposit); + } + + Ok(deposits) + } + + async fn update_deposit( + &self, txid: String, vout: u32, payload: UpdateDepositPayload, + ) -> Result<(), StorageError> { + let id = format!("{txid}:{vout}"); + + let data = match self.0.read(SPARK_PRIMARY_NAMESPACE, SPARK_DEPOSITS_NAMESPACE, &id) { + Ok(data) => data, + Err(e) => { + if let io::ErrorKind::NotFound = e.kind() { + // deposit does not exist, nothing to update + return Ok(()); + } else { + Err(StorageError::Implementation(format!("{e:?}")))? + } + }, + }; + + let mut deposit: DepositInfo = serde_json::from_slice(&data) + .map_err(|e| StorageError::Serialization(format!("{e:?}")))?; + + match payload { + UpdateDepositPayload::ClaimError { error } => { + deposit.claim_error = Some(error); + }, + UpdateDepositPayload::Refund { refund_txid, refund_tx } => { + deposit.refund_tx_id = Some(refund_txid); + deposit.refund_tx = Some(refund_tx); + }, + } + + let data = serde_json::to_vec(&deposit) + .map_err(|e| StorageError::Serialization(format!("{e:?}")))?; + + self.0 + .write(SPARK_PRIMARY_NAMESPACE, SPARK_DEPOSITS_NAMESPACE, &id, &data) + .map_err(|e| StorageError::Implementation(format!("{e:?}")))?; + + Ok(()) + } +}