diff --git a/examples/cli/src/main.rs b/examples/cli/src/main.rs index 117243d..74eb0ab 100644 --- a/examples/cli/src/main.rs +++ b/examples/cli/src/main.rs @@ -89,9 +89,7 @@ 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::default()), }) }, Network::Bitcoin => { @@ -118,9 +116,7 @@ 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::default()), }) }, _ => Err(anyhow::anyhow!("Unsupported network: {network:?}")), diff --git a/orange-sdk/Cargo.toml b/orange-sdk/Cargo.toml index 7498fa0..6463521 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 = "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/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 881583f..ccaf2d1 100644 --- a/orange-sdk/src/lib.rs +++ b/orange-sdk/src/lib.rs @@ -65,6 +65,8 @@ use trusted_wallet::TrustedError; #[cfg(feature = "cashu")] pub use crate::trusted_wallet::cashu::CashuConfig; +#[cfg(feature = "spark")] +pub use crate::trusted_wallet::spark::SparkWalletConfig; pub use bitcoin_payment_instructions; #[cfg(feature = "cashu")] pub use cdk::nuts::nut00::CurrencyUnit; @@ -72,8 +74,6 @@ 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; @@ -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/spark.rs b/orange-sdk/src/trusted_wallet/spark.rs deleted file mode 100644 index 495d397..0000000 --- a/orange-sdk/src/trusted_wallet/spark.rs +++ /dev/null @@ -1,788 +0,0 @@ -//! An implementation of `TrustedWalletInterface` using the Spark SDK. -use crate::bitcoin::hex::FromHex; -use crate::bitcoin::{Txid, io}; -use crate::logging::Logger; -use crate::store::{PaymentId, StoreTransaction, TxMetadataStore, TxStatus}; -use crate::trusted_wallet::{Payment, TrustedError, TrustedWalletInterface}; -use crate::{Event, EventQueue, InitFailure, PaymentType, 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_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 tokio::sync::watch; - -use std::future::Future; -use std::pin::Pin; -use std::str::FromStr; -use std::sync::Arc; -use std::time::{Duration, SystemTime}; -use tokio::runtime::Runtime; -use uuid::Uuid; - -/// 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 { - fn get_balance( - &self, - ) -> Pin> + Send + '_>> { - Box::pin(async move { - let sats = self.spark_wallet.get_balance().await?; - Amount::from_sats(sats).map_err(|_| TrustedError::AmountError) - }) - } - - fn get_reusable_receive_uri( - &self, - ) -> Pin> + Send + '_>> { - Box::pin(async move { - Err(TrustedError::UnsupportedOperation("Spark does not support BOLT 12".to_owned())) - }) - } - - fn get_bolt11_invoice( - &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(), - )), - Some(a) if a == Amount::ZERO => Err(TrustedError::UnsupportedOperation( - "Spark does not support amount-less invoices".to_owned(), - )), - Some(a) => { - let sats = a.sats().map_err(|_| { - TrustedError::UnsupportedOperation( - "msat amounts not supported by spark".to_owned(), - ) - })?; - - let res = self.spark_wallet.create_lightning_invoice(sats, None, None).await?; - - Bolt11Invoice::from_str(&res.invoice) - .map_err(|e| TrustedError::Other(format!("Failed to parse invoice: {e}"))) - }, - } - }) - } - - fn list_payments( - &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; - }, - }; - - // 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, - }; - - 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) - }) - } - - fn estimate_fee( - &self, method: PaymentMethod, amount: Amount, - ) -> Pin> + Send + '_>> { - Box::pin(async move { - if let PaymentMethod::LightningBolt11(invoice) = method { - let sats = amount.sats().map_err(|_| { - TrustedError::UnsupportedOperation( - "msat amounts not supported by spark".to_owned(), - ) - })?; - - 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) - } else { - log_error!(self.logger, "Only BOLT 11 is currently supported for fee estimation"); - Err(TrustedError::UnsupportedOperation( - "Only BOLT 11 is currently supported".to_owned(), - )) - } - }) - } - - fn pay( - &self, method: PaymentMethod, amount: Amount, - ) -> Pin> + Send + '_>> { - Box::pin(async move { - if let PaymentMethod::LightningBolt11(invoice) = method { - let sats = amount.sats().map_err(|_| { - TrustedError::UnsupportedOperation( - "msat amounts not supported by spark".to_owned(), - ) - })?; - - let res = self - .spark_wallet - .pay_lightning_invoice( - &invoice.to_string(), - Some(sats), - None, - false, // do not prefer spark for better privacy - ) - .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) - }, - } - } else { - Err(TrustedError::UnsupportedOperation( - "Only BOLT 11 is currently supported".to_owned(), - )) - } - }) - } - - fn stop(&self) -> Pin + Send + '_>> { - Box::pin(async move { - log_info!(self.logger, "Stopping Spark wallet"); - let _ = self.shutdown_sender.send(()); - }) - } -} - -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, - store: Arc, event_queue: Arc, - tx_metadata: TxMetadataStore, logger: Arc, runtime: Arc, - ) -> Result { - if config.network != spark_config.network.into() { - Err(TrustedError::InvalidNetwork)? - } - - let signer = 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 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_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 (shutdown_sender, shutdown_receiver) = watch::channel::<()>(()); - let mut events = spark_wallet.subscribe_events(); - let l = Arc::clone(&logger); - 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:?}"); - }, - } - } - } - } - }); - - log_info!(logger, "Spark wallet initialized"); - - Ok(Spark { - spark_wallet, - store, - event_queue, - tx_metadata, - shutdown_sender, - shutdown_receiver, - logger, - 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)); - } - }, - }; - - // 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?; - - 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; - } - log_info!(logger, "Inserted payment: {store_tx:?}"); - } - - // 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; - } - - 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"); - }); - } -} - -impl TryFrom<&WalletTransfer> for StoreTransaction { - type Error = TrustedError; - - 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, - }; - - 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 TryFrom<&SspUserRequest> for PaymentType { - type Error = SparkWalletError; - - 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}")) - })?), - }, - }; - 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? - } -} - -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, - } - } -} - -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, - } - } -} - -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:?}")) - }, - SparkWalletError::ServiceError(_) => { - TrustedError::WalletOperationFailed(format!("{e:?}")) - }, - 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 -} diff --git a/orange-sdk/src/trusted_wallet/spark/mod.rs b/orange-sdk/src/trusted_wallet/spark/mod.rs new file mode 100644 index 0000000..9436ffb --- /dev/null +++ b/orange-sdk/src/trusted_wallet/spark/mod.rs @@ -0,0 +1,491 @@ +//! An implementation of `TrustedWalletInterface` using the Spark SDK. + +pub(crate) mod spark_store; + +use crate::bitcoin::Network; +use crate::bitcoin::hex::FromHex; +use crate::logging::Logger; +use crate::store::{PaymentId, TxMetadataStore, TxStatus}; +use crate::trusted_wallet::{Payment, TrustedError, TrustedWalletInterface}; +use crate::{Event, EventQueue, InitFailure, Seed, WalletConfig}; + +use ldk_node::lightning::util::logger::Logger as _; +use ldk_node::lightning::util::persist::KVStore; +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 bitcoin_payment_instructions::PaymentMethod; +use bitcoin_payment_instructions::amount::Amount; + +use breez_sdk_spark::{ + BreezSdk, EventListener, GetInfoRequest, ListPaymentsRequest, PaymentDetails, PaymentType, + PrepareSendPaymentRequest, ReceivePaymentMethod, ReceivePaymentRequest, SdkBuilder, SdkError, + SdkEvent, SendPaymentMethod, SendPaymentRequest, +}; + +use tokio::sync::watch; + +use std::future::Future; +use std::pin::Pin; +use std::str::FromStr; +use std::sync::Arc; +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, + shutdown_sender: watch::Sender<()>, + logger: Arc, +} + +impl TrustedWalletInterface for Spark { + fn get_balance( + &self, + ) -> Pin> + Send + '_>> { + Box::pin(async move { + let info = self.spark_wallet.get_info(GetInfoRequest {}).await?; + Amount::from_sats(info.balance_sats).map_err(|_| TrustedError::AmountError) + }) + } + + fn get_reusable_receive_uri( + &self, + ) -> Pin> + Send + '_>> { + Box::pin(async move { + Err(TrustedError::UnsupportedOperation("Spark does not support BOLT 12".to_owned())) + }) + } + + fn get_bolt11_invoice( + &self, amount: Option, + ) -> Pin> + Send + '_>> { + Box::pin(async move { + // 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(), // 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}"))) + }) + } + + fn list_payments( + &self, + ) -> Pin, TrustedError>> + Send + '_>> { + Box::pin(async move { + let resp = self + .spark_wallet + .list_payments(ListPaymentsRequest { limit: None, offset: None }) + .await?; + + let payments = + resp.payments.into_iter().map(|p| p.try_into()).collect::>()?; + + Ok(payments) + }) + } + + fn estimate_fee( + &self, method: PaymentMethod, amount: Amount, + ) -> Pin> + Send + '_>> { + Box::pin(async move { + if let PaymentMethod::LightningBolt11(invoice) = method { + let sats = amount.sats().map_err(|_| { + TrustedError::UnsupportedOperation( + "msat amounts not supported by spark".to_owned(), + ) + })?; + + 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( + "Only BOLT 11 is currently supported".to_owned(), + )) + } + }) + } + + fn pay( + &self, method: PaymentMethod, amount: Amount, + ) -> Pin> + Send + '_>> { + Box::pin(async move { + if let PaymentMethod::LightningBolt11(invoice) = method { + let sats = amount.sats().map_err(|_| { + TrustedError::UnsupportedOperation( + "msat amounts not supported by spark".to_owned(), + ) + })?; + + 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 + .send_payment(SendPaymentRequest { prepare_response: prepare, options: None }) + .await?; + + let id = parse_payment_id(&res.payment.id)?; + Ok(id) + } else { + Err(TrustedError::UnsupportedOperation( + "Only BOLT 11 is currently supported".to_owned(), + )) + } + }) + } + + fn stop(&self) -> Pin + Send + '_>> { + Box::pin(async move { + log_info!(self.logger, "Stopping Spark wallet"); + let _ = self.shutdown_sender.send(()); + }) + } +} + +impl Spark { + /// Initialize a new Spark wallet instance with the given configuration. + pub(crate) async fn init( + config: &WalletConfig, spark_config: SparkWalletConfig, + store: Arc, 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 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(), + }, + }; + + 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| { + 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 listener = SparkEventHandler { + event_queue: Arc::clone(&event_queue), + tx_metadata, + 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 mut shutdown_recv = shutdown_receiver.clone(); + runtime.spawn(async move { + let _ = shutdown_recv.changed().await; + w.remove_event_listener(&listener_id); + }); + + log_info!(logger, "Spark wallet initialized"); + + Ok(Spark { spark_wallet, shutdown_sender, logger }) + } +} + +struct SparkEventHandler { + event_queue: Arc, + tx_metadata: TxMetadataStore, + 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:?}"); + } + }, + SdkEvent::PaymentFailed { payment } => { + if let Err(e) = self.handle_payment_failed(payment) { + log_error!(self.logger, "Failed to handle payment succeeded: {e:?}"); + } + }, + } + } +} + +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 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(), + ) + })?; + + 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:?}")) + })?; + + self.event_queue.add_event(Event::PaymentSuccessful { + payment_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:?}") + }, + } + }, + 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:?}")) + })?; + + 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(()) + } + + 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_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, + 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> { + // 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())) +} + +// 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 +} + +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, + } + } +} + +impl From for TrustedError { + fn from(e: SdkError) -> Self { + TrustedError::WalletOperationFailed(format!("{e:?}")) + } +} + +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), + }) + } +} 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(()) + } +}