From f4dcfe58976c8caa05996bb2c3104e2b079941c1 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Tue, 21 Oct 2025 12:59:08 -0500 Subject: [PATCH 01/20] WIP: Upgrade to ldk-node 0.7 --- Cargo.toml | 6 +- orange-sdk/Cargo.toml | 11 +- orange-sdk/src/event.rs | 38 +-- orange-sdk/src/lib.rs | 21 +- orange-sdk/src/lightning_wallet.rs | 36 ++- orange-sdk/src/rebalancer.rs | 5 +- orange-sdk/src/store.rs | 81 +++--- .../src/trusted_wallet/cashu/cashu_store.rs | 275 +++++++++++------- orange-sdk/src/trusted_wallet/cashu/mod.rs | 10 +- orange-sdk/src/trusted_wallet/dummy.rs | 4 +- orange-sdk/src/trusted_wallet/spark/mod.rs | 8 +- .../src/trusted_wallet/spark/spark_store.rs | 123 +++++--- orange-sdk/tests/integration_tests.rs | 22 +- orange-sdk/tests/test_utils.rs | 12 +- 14 files changed, 382 insertions(+), 270 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 71d85f0..5222f00 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,9 +16,9 @@ codegen-units = 1 # Reduce number of codegen units to increase optimizations. panic = 'abort' # Abort on panic [workspace.dependencies] -bitcoin-payment-instructions = { git = "https://github.com/benthecarman/bitcoin-payment-instructions.git", branch = "orange-fork", features = ["http"] } -lightning = { git = "https://github.com/tnull/rust-lightning", branch = "2025-08-bump-electrum-client-0.1" } -lightning-invoice = { git = "https://github.com/tnull/rust-lightning", branch = "2025-08-bump-electrum-client-0.1" } +bitcoin-payment-instructions = { git = "https://github.com/benthecarman/bitcoin-payment-instructions.git", branch = "orange-fork2", features = ["http"] } +lightning = { version = "0.2.0-beta1" } +lightning-invoice = { version = "0.34.0-beta1" } [profile.release] panic = "abort" diff --git a/orange-sdk/Cargo.toml b/orange-sdk/Cargo.toml index 6133bb5..e4e094b 100644 --- a/orange-sdk/Cargo.toml +++ b/orange-sdk/Cargo.toml @@ -23,7 +23,8 @@ _cashu-tests = ["_test-utils", "cdk-ldk-node", "cdk/mint", "cdk-sqlite", "cdk-ax [dependencies] graduated-rebalancer = { path = "../graduated-rebalancer", version = "0.1.0" } -ldk-node = { git = "https://github.com/benthecarman/ldk-node.git", branch = "esplora-auth" } +ldk-node = { git = "https://github.com/lightningdevkit/ldk-node.git", rev = "dd519080318ba099280b4353fb6335c587a67cbd" } +lightning-macros = "0.2.0-beta1" bitcoin-payment-instructions = { workspace = true } chrono = { version = "0.4", default-features = false } rand = { version = "0.8.5", optional = true } @@ -31,15 +32,15 @@ reqwest = { version = "0.12.23", default-features = false, features = ["rustls-t breez-sdk-spark = { git = "https://github.com/breez/spark-sdk.git", rev = "1f2e9995230cd582d6b4aa7d06d76b99defb635e", 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 = "39c1206a4a1dda2adc1f3e23628136ef645f6c6b", default-features = false, features = ["wallet"], optional = true } +cdk = { git = "https://github.com/benthecarman/cdk.git", rev = "0d7c1b2b9b73964e497763c4a315133db83d1c53", default-features = false, features = ["wallet"], optional = true } serde_json = { version = "1.0", optional = true } async-trait = "0.1" log = "0.4.28" corepc-node = { version = "0.8.0", features = ["29_0", "download"], optional = true } -cdk-ldk-node = { git = "https://github.com/benthecarman/cdk.git", rev = "39c1206a4a1dda2adc1f3e23628136ef645f6c6b", optional = true } -cdk-sqlite = { git = "https://github.com/benthecarman/cdk.git", rev = "39c1206a4a1dda2adc1f3e23628136ef645f6c6b", optional = true } -cdk-axum = { git = "https://github.com/benthecarman/cdk.git", rev = "39c1206a4a1dda2adc1f3e23628136ef645f6c6b", optional = true } +cdk-ldk-node = { git = "https://github.com/benthecarman/cdk.git", rev = "0d7c1b2b9b73964e497763c4a315133db83d1c53", optional = true } +cdk-sqlite = { git = "https://github.com/benthecarman/cdk.git", rev = "0d7c1b2b9b73964e497763c4a315133db83d1c53", optional = true } +cdk-axum = { git = "https://github.com/benthecarman/cdk.git", rev = "0d7c1b2b9b73964e497763c4a315133db83d1c53", optional = true } axum = { version = "0.8.1", optional = true } uniffi = { version = "0.29", features = ["cli", "tokio"], optional = true } diff --git a/orange-sdk/src/event.rs b/orange-sdk/src/event.rs index 7332123..decdf8e 100644 --- a/orange-sdk/src/event.rs +++ b/orange-sdk/src/event.rs @@ -6,12 +6,12 @@ use ldk_node::bitcoin::{OutPoint, Txid}; use ldk_node::lightning::events::{ClosureReason, PaymentFailureReason}; use ldk_node::lightning::ln::types::ChannelId; use ldk_node::lightning::util::logger::Logger as _; -use ldk_node::lightning::util::persist::KVStore; +use ldk_node::lightning::util::persist::KVStoreSync; use ldk_node::lightning::util::ser::{Writeable, Writer}; use ldk_node::lightning::{impl_writeable_tlv_based_enum, log_debug, log_error, log_warn}; use ldk_node::lightning_types::payment::{PaymentHash, PaymentPreimage}; use ldk_node::payment::{ConfirmationStatus, PaymentKind}; -use ldk_node::{CustomTlvRecord, UserChannelId}; +use ldk_node::{CustomTlvRecord, DynStore, UserChannelId}; use std::collections::VecDeque; use std::sync::{Arc, Condvar, Mutex}; @@ -191,12 +191,12 @@ pub struct EventQueue { queue: Arc>>, waker: Arc>>, notifier: Condvar, - kv_store: Arc, + kv_store: Arc, logger: Arc, } impl EventQueue { - pub(crate) fn new(kv_store: Arc, logger: Arc) -> Self { + pub(crate) fn new(kv_store: Arc, logger: Arc) -> Self { let queue = Arc::new(Mutex::new(VecDeque::new())); let waker = Arc::new(Mutex::new(None)); let notifier = Condvar::new(); @@ -251,24 +251,24 @@ impl EventQueue { &self, locked_queue: &VecDeque, ) -> Result<(), ldk_node::lightning::io::Error> { let data = EventQueueSerWrapper(locked_queue).encode(); - self.kv_store - .write( + KVStoreSync::write( + self.kv_store.as_ref(), + EVENT_QUEUE_PERSISTENCE_PRIMARY_NAMESPACE, + EVENT_QUEUE_PERSISTENCE_SECONDARY_NAMESPACE, + EVENT_QUEUE_PERSISTENCE_KEY, + data, + ) + .map_err(|e| { + log_error!( + self.logger.as_ref(), + "Write for key {}/{}/{} failed due to: {}", EVENT_QUEUE_PERSISTENCE_PRIMARY_NAMESPACE, EVENT_QUEUE_PERSISTENCE_SECONDARY_NAMESPACE, EVENT_QUEUE_PERSISTENCE_KEY, - &data, - ) - .map_err(|e| { - log_error!( - self.logger.as_ref(), - "Write for key {}/{}/{} failed due to: {}", - EVENT_QUEUE_PERSISTENCE_PRIMARY_NAMESPACE, - EVENT_QUEUE_PERSISTENCE_SECONDARY_NAMESPACE, - EVENT_QUEUE_PERSISTENCE_KEY, - e - ); e - })?; + ); + e + })?; Ok(()) } } @@ -316,7 +316,7 @@ pub(crate) struct LdkEventHandler { } impl LdkEventHandler { - pub(crate) fn handle_ldk_node_event(&self, event: ldk_node::Event) { + pub(crate) async fn handle_ldk_node_event(&self, event: ldk_node::Event) { match event { ldk_node::Event::PaymentSuccessful { payment_id, diff --git a/orange-sdk/src/lib.rs b/orange-sdk/src/lib.rs index 5c00033..23adc9c 100644 --- a/orange-sdk/src/lib.rs +++ b/orange-sdk/src/lib.rs @@ -24,11 +24,10 @@ use ldk_node::bitcoin::secp256k1::PublicKey; use ldk_node::io::sqlite_store::SqliteStore; use ldk_node::lightning::ln::msgs::SocketAddress; 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_trace, log_warn}; use ldk_node::lightning_invoice::Bolt11Invoice; use ldk_node::payment::PaymentKind; -use ldk_node::{BuildError, ChannelDetails, NodeError}; +use ldk_node::{BuildError, ChannelDetails, DynStore, NodeError}; use tokio::runtime::Runtime; @@ -113,7 +112,7 @@ struct WalletImpl { /// Metadata store for tracking transactions. tx_metadata: TxMetadataStore, /// Key-value store for persistent storage. - store: Arc, + store: Arc, /// Logger for logging wallet operations. logger: Arc, /// The Tokio runtime for asynchronous operations. @@ -529,7 +528,7 @@ impl Wallet { log_info!(logger, "Initializing orange on network: {network}"); - let store: Arc = match &config.storage_config { + let store: Arc = match &config.storage_config { StorageConfig::LocalSQLite(path) => { Arc::new(SqliteStore::new(path.into(), Some("orange.sqlite".to_owned()), None)?) }, @@ -537,7 +536,7 @@ impl Wallet { let event_queue = Arc::new(EventQueue::new(Arc::clone(&store), Arc::clone(&logger))); - let tx_metadata = TxMetadataStore::new(Arc::clone(&store)); + let tx_metadata = TxMetadataStore::new(Arc::clone(&store)).await; let trusted: Arc> = match &config.extra_config { #[cfg(feature = "spark")] @@ -647,7 +646,7 @@ impl Wallet { /// Sets whether the wallet should automatically rebalance from trusted/onchain to lightning. pub fn set_rebalance_enabled(&self, value: bool) { - store::set_rebalance_enabled(self.inner.store.as_ref(), value); + store::set_rebalance_enabled(self.inner.store.as_ref(), value) } /// Whether the wallet should automatically rebalance from trusted/onchain to lightning. @@ -1023,7 +1022,8 @@ impl Wallet { pub async fn parse_payment_instructions( &self, instructions: &str, ) -> Result { - PaymentInstructions::parse(instructions, self.inner.network, &HTTPHrnResolver, true).await + PaymentInstructions::parse(instructions, self.inner.network, &HTTPHrnResolver::new(), true) + .await } // /// Verifies instructions which allow us to claim funds given as: @@ -1152,7 +1152,8 @@ impl Wallet { let methods = match &instructions.instructions { PaymentInstructions::ConfigurableAmount(conf) => { - let res = conf.clone().set_amount(instructions.amount, &HTTPHrnResolver).await; + let res = + conf.clone().set_amount(instructions.amount, &HTTPHrnResolver::new()).await; let fixed_instr = res.map_err(|e| { log_error!( self.inner.logger, @@ -1266,8 +1267,8 @@ impl Wallet { /// Authenticates the user via [LNURL-auth] for the given LNURL string. /// /// [LNURL-auth]: https://github.com/lnurl/luds/blob/luds/04.md - pub fn lnurl_auth(&self, lnurl: &str) -> Result<(), WalletError> { - self.inner.ln_wallet.inner.ldk_node.lnurl_auth(lnurl)?; + pub fn lnurl_auth(&self, _lnurl: &str) -> Result<(), WalletError> { + // todo wait for merge, self.inner.ln_wallet.inner.ldk_node.lnurl_auth(lnurl)?; Ok(()) } diff --git a/orange-sdk/src/lightning_wallet.rs b/orange-sdk/src/lightning_wallet.rs index 472d6af..2c945c5 100644 --- a/orange-sdk/src/lightning_wallet.rs +++ b/orange-sdk/src/lightning_wallet.rs @@ -10,7 +10,7 @@ use bitcoin_payment_instructions::amount::Amount; use ldk_node::bitcoin::base64::Engine; use ldk_node::bitcoin::base64::prelude::BASE64_STANDARD; use ldk_node::bitcoin::secp256k1::PublicKey; -use ldk_node::bitcoin::{Address, Network, Script}; +use ldk_node::bitcoin::{Address, Network}; use ldk_node::lightning::ln::channelmanager::PaymentId; use ldk_node::lightning::ln::msgs::SocketAddress; use ldk_node::lightning::util::logger::Logger as _; @@ -18,7 +18,7 @@ use ldk_node::lightning::util::persist::KVStore; use ldk_node::lightning::{log_debug, log_error, log_info}; use ldk_node::lightning_invoice::{Bolt11Invoice, Bolt11InvoiceDescription, Description}; use ldk_node::payment::{PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus}; -use ldk_node::{NodeError, UserChannelId, lightning}; +use ldk_node::{DynStore, NodeError, UserChannelId, lightning}; use graduated_rebalancer::{LightningBalance, ReceivedLightningPayment}; @@ -52,7 +52,7 @@ const DEFAULT_INVOICE_EXPIRY_SECS: u32 = 86_400; // 24 hours impl LightningWallet { pub(super) async fn init( - runtime: Arc, config: WalletConfig, store: Arc, + runtime: Arc, config: WalletConfig, store: Arc, event_queue: Arc, tx_metadata: TxMetadataStore, logger: Arc, ) -> Result { log_info!(logger, "Creating LDK node..."); @@ -148,12 +148,14 @@ impl LightningWallet { InitFailure::LdkNodeStartFailure(NodeError::InvalidUri) })?; - store.write( + KVStore::write( + store.as_ref(), lightning::util::persist::SCORER_PERSISTENCE_PRIMARY_NAMESPACE, lightning::util::persist::SCORER_PERSISTENCE_SECONDARY_NAMESPACE, lightning::util::persist::SCORER_PERSISTENCE_KEY, - bytes.as_ref(), - )?; + bytes.to_vec(), + ) + .await?; } let ldk_node = Arc::new(builder.build_with_store(Arc::clone(&store))?); @@ -175,13 +177,13 @@ impl LightningWallet { lsp_socket_addr, }); - inner.ldk_node.start_with_runtime(Arc::clone(&runtime))?; + inner.ldk_node.start()?; runtime.spawn(async move { loop { let event = ev_handler.ldk_node.next_event_async().await; log_debug!(ev_handler.logger, "Got ldk-node event {:?}", event); - ev_handler.handle_ldk_node_event(event); + ev_handler.handle_ldk_node_event(event).await; } }); @@ -295,18 +297,20 @@ impl LightningWallet { let bal = self.inner.ldk_node.list_balances().spendable_onchain_balance_sats; // need a dummy p2wsh address to estimate the fee, p2wsh is used for LN channels - let fake_addr = Address::p2wsh(Script::new(), self.inner.ldk_node.config().network); - - let fee = self - .inner - .ldk_node - .onchain_payment() - .estimate_send_all_to_address(&fake_addr, true, None)?; + // let fake_addr = Address::p2wsh(Script::new(), self.inner.ldk_node.config().network); + // + // let fee = self + // .inner + // .ldk_node + // .onchain_payment() + // .estimate_send_all_to_address(&fake_addr, true, None)?; + // todo get real fee + let fee = 1000; let id = self.inner.ldk_node.open_channel( self.inner.lsp_node_id, self.inner.lsp_socket_addr.clone(), - bal - fee.to_sat(), + bal - fee, None, None, )?; diff --git a/orange-sdk/src/rebalancer.rs b/orange-sdk/src/rebalancer.rs index 2d7881a..6c1fb0b 100644 --- a/orange-sdk/src/rebalancer.rs +++ b/orange-sdk/src/rebalancer.rs @@ -8,6 +8,7 @@ use crate::trusted_wallet::DynTrustedWalletInterface; use crate::{Event, EventQueue, PaymentType, Tunables, store}; use bitcoin_payment_instructions::amount::Amount; use graduated_rebalancer::{RebalanceTrigger, RebalancerEvent, TriggerParams}; +use ldk_node::DynStore; use ldk_node::lightning::util::logger::Logger as _; use ldk_node::lightning::util::persist::KVStore; use ldk_node::lightning::{log_error, log_info, log_trace, log_warn}; @@ -29,7 +30,7 @@ pub(crate) struct OrangeTrigger { /// The event handler for processing wallet events. event_queue: Arc, /// Key-value store for persistent storage. - store: Arc, + store: Arc, /// Time of the last on-chain sync, used to determine when to trigger rebalances. onchain_sync_time: AtomicU64, /// Logger for logging events and errors. @@ -41,7 +42,7 @@ impl OrangeTrigger { pub(crate) fn new( ln_wallet: Arc, trusted: Arc>, tunables: Tunables, tx_metadata: TxMetadataStore, event_queue: Arc, - store: Arc, logger: Arc, + store: Arc, logger: Arc, ) -> Self { let start = ln_wallet.inner.ldk_node.status().latest_onchain_wallet_sync_timestamp.unwrap_or(0); diff --git a/orange-sdk/src/store.rs b/orange-sdk/src/store.rs index a305cab..39a9590 100644 --- a/orange-sdk/src/store.rs +++ b/orange-sdk/src/store.rs @@ -18,10 +18,11 @@ use ldk_node::bitcoin::hex::{DisplayHex, FromHex}; use ldk_node::lightning::io; use ldk_node::lightning::ln::msgs::DecodeError; use ldk_node::lightning::types::payment::PaymentPreimage; -use ldk_node::lightning::util::persist::KVStore; +use ldk_node::lightning::util::persist::{KVStore, KVStoreSync}; use ldk_node::lightning::util::ser::{Readable, Writeable, Writer}; use ldk_node::lightning::{impl_writeable_tlv_based, impl_writeable_tlv_based_enum}; +use ldk_node::DynStore; use std::collections::HashMap; use std::fmt; use std::str::FromStr; @@ -292,19 +293,20 @@ impl_writeable_tlv_based!(TxMetadata, { (0, ty, required), (2, time, required) } #[derive(Clone)] pub(crate) struct TxMetadataStore { tx_metadata: Arc>>, - store: Arc, + store: Arc, } impl TxMetadataStore { - pub fn new(store: Arc) -> TxMetadataStore { - let keys = store - .list(STORE_PRIMARY_KEY, STORE_SECONDARY_KEY) + pub async fn new(store: Arc) -> TxMetadataStore { + let keys = KVStore::list(store.as_ref(), STORE_PRIMARY_KEY, STORE_SECONDARY_KEY) + .await .expect("We do not allow reads to fail"); let mut tx_metadata = HashMap::with_capacity(keys.len()); for key in keys { - let data_bytes = store - .read(STORE_PRIMARY_KEY, STORE_SECONDARY_KEY, &key) - .expect("We do not allow reads to fail"); + let data_bytes = + KVStore::read(store.as_ref(), STORE_PRIMARY_KEY, STORE_SECONDARY_KEY, &key) + .await + .expect("We do not allow reads to fail"); let key = PaymentId::from_str(&key).expect("Invalid key in transaction metadata storage"); let data = Readable::read(&mut &data_bytes[..]) @@ -323,9 +325,14 @@ impl TxMetadataStore { let key_str = key.to_string(); let ser = value.encode(); let old = tx_metadata.insert(key, value); - self.store - .write(STORE_PRIMARY_KEY, STORE_SECONDARY_KEY, &key_str, &ser) - .expect("We do not allow writes to fail"); + KVStoreSync::write( + self.store.as_ref(), + STORE_PRIMARY_KEY, + STORE_SECONDARY_KEY, + &key_str, + ser, + ) + .expect("We do not allow writes to fail"); old.is_some() } @@ -345,9 +352,14 @@ impl TxMetadataStore { metadata.ty = TxType::PaymentTriggeringTransferLightning { ty: *ty }; let key_str = payment_id.to_string(); let ser = metadata.encode(); - self.store - .write(STORE_PRIMARY_KEY, STORE_SECONDARY_KEY, &key_str, &ser) - .expect("We do not allow writes to fail"); + KVStoreSync::write( + self.store.as_ref(), + STORE_PRIMARY_KEY, + STORE_SECONDARY_KEY, + &key_str, + ser, + ) + .expect("We do not allow writes to fail"); Ok(()) } else { eprintln!("payment_id {payment_id} is not a payment, cannot set rebalance"); @@ -377,14 +389,14 @@ impl TxMetadataStore { }, }; - self.store - .write( - STORE_PRIMARY_KEY, - STORE_SECONDARY_KEY, - &payment_id.to_string(), - &metadata.encode(), - ) - .expect("We do not allow writes to fail"); + KVStoreSync::write( + self.store.as_ref(), + STORE_PRIMARY_KEY, + STORE_SECONDARY_KEY, + &payment_id.to_string(), + metadata.encode(), + ) + .expect("We do not allow writes to fail"); Ok(()) } }, @@ -398,14 +410,14 @@ impl TxMetadataStore { }, }; - self.store - .write( - STORE_PRIMARY_KEY, - STORE_SECONDARY_KEY, - &payment_id.to_string(), - &metadata.encode(), - ) - .expect("We do not allow writes to fail"); + KVStoreSync::write( + self.store.as_ref(), + STORE_PRIMARY_KEY, + STORE_SECONDARY_KEY, + &payment_id.to_string(), + metadata.encode(), + ) + .expect("We do not allow writes to fail"); Ok(()) } }, @@ -431,8 +443,8 @@ impl TxMetadataStore { const REBALANCE_ENABLED_KEY: &str = "rebalance_enabled"; -pub(crate) fn get_rebalance_enabled(store: &dyn KVStore) -> bool { - match store.read(STORE_PRIMARY_KEY, "", REBALANCE_ENABLED_KEY) { +pub(crate) fn get_rebalance_enabled(store: &DynStore) -> bool { + match KVStoreSync::read(store, STORE_PRIMARY_KEY, "", REBALANCE_ENABLED_KEY) { Ok(bytes) => Readable::read(&mut &bytes[..]).expect("Invalid data in rebalance_enabled"), Err(e) if e.kind() == io::ErrorKind::NotFound => { // if rebalance_enabled is not found, default to true @@ -447,10 +459,9 @@ pub(crate) fn get_rebalance_enabled(store: &dyn KVStore) -> bool { } } -pub(crate) fn set_rebalance_enabled(store: &dyn KVStore, enabled: bool) { +pub(crate) fn set_rebalance_enabled(store: &DynStore, enabled: bool) { let bytes = enabled.encode(); - store - .write(STORE_PRIMARY_KEY, "", REBALANCE_ENABLED_KEY, &bytes) + KVStoreSync::write(store, STORE_PRIMARY_KEY, "", REBALANCE_ENABLED_KEY, bytes) .expect("Failed to write rebalance_enabled"); } diff --git a/orange-sdk/src/trusted_wallet/cashu/cashu_store.rs b/orange-sdk/src/trusted_wallet/cashu/cashu_store.rs index 170b284..e127d45 100644 --- a/orange-sdk/src/trusted_wallet/cashu/cashu_store.rs +++ b/orange-sdk/src/trusted_wallet/cashu/cashu_store.rs @@ -5,9 +5,11 @@ use std::sync::{Arc, RwLock}; use async_trait::async_trait; use cdk::cdk_database::WalletDatabase; +use ldk_node::DynStore; use ldk_node::lightning::io; use ldk_node::lightning::util::persist::KVStore; +use crate::trusted_wallet::TrustedError; use cdk::mint_url::MintUrl; use cdk::nuts::{ CurrencyUnit, Id, KeySet, KeySetInfo, Keys, MintInfo, PublicKey, SpendingConditions, State, @@ -21,8 +23,6 @@ use cdk::wallet::{ use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; -use crate::trusted_wallet::TrustedError; - // Constants for organizing data in the KV store const CASHU_PRIMARY_KEY: &str = "cashu_wallet"; @@ -110,7 +110,7 @@ impl From for cdk::cdk_database::Error { /// A KV store-based implementation of the Cashu WalletDatabase trait pub struct CashuKvDatabase { - store: Arc, + store: Arc, // In-memory caches for frequently accessed data mints_cache: Arc>>>, proofs_cache: Arc>>, @@ -140,7 +140,7 @@ impl CashuKvDatabase { /// /// Returns a `Result` containing the initialized database or a `DatabaseError` if /// initialization fails. - pub fn new(store: Arc) -> Result { + pub async fn new(store: Arc) -> Result { let database = Self { store, mints_cache: Arc::new(RwLock::new(HashMap::new())), @@ -148,20 +148,20 @@ impl CashuKvDatabase { }; // Initialize caches from persistent storage - database.load_caches()?; + database.load_caches().await?; Ok(database) } - fn load_caches(&self) -> Result<(), DatabaseError> { + async fn load_caches(&self) -> Result<(), DatabaseError> { // Load mints cache - if let Ok(mints) = self.load_mints_from_store() { + if let Ok(mints) = self.load_mints_from_store().await { let mut cache = self.mints_cache.write().unwrap(); *cache = mints; } // Load proofs cache - if let Ok(proofs) = self.load_proofs_from_store() { + if let Ok(proofs) = self.load_proofs_from_store().await { let mut cache = self.proofs_cache.write().unwrap(); *cache = proofs; } @@ -169,20 +169,25 @@ impl CashuKvDatabase { Ok(()) } - fn load_mints_from_store(&self) -> Result>, DatabaseError> { - let keys = self.store.list(CASHU_PRIMARY_KEY, MINTS_KEY).map_err(DatabaseError::Io)?; + async fn load_mints_from_store( + &self, + ) -> Result>, DatabaseError> { + let keys = KVStore::list(self.store.as_ref(), CASHU_PRIMARY_KEY, MINTS_KEY) + .await + .map_err(DatabaseError::Io)?; - let mut mints = HashMap::new(); + let mut mints = HashMap::with_capacity(keys.len()); for key in keys { - let data = - self.store.read(CASHU_PRIMARY_KEY, MINTS_KEY, &key).map_err(DatabaseError::Io)?; + let data = KVStore::read(self.store.as_ref(), CASHU_PRIMARY_KEY, MINTS_KEY, &key) + .await + .map_err(DatabaseError::Io)?; if !data.is_empty() { let mint_url: MintUrl = serde_json::from_slice(&data) .map_err(|e| DatabaseError::Serialization(e.to_string()))?; // Try to load mint info - let mint_info = self.load_mint_info(&mint_url).ok().flatten(); + let mint_info = self.load_mint_info(&mint_url).await.ok().flatten(); mints.insert(mint_url, mint_info); } } @@ -190,13 +195,16 @@ impl CashuKvDatabase { Ok(mints) } - fn load_proofs_from_store(&self) -> Result, DatabaseError> { - let keys = self.store.list(CASHU_PRIMARY_KEY, PROOFS_KEY).map_err(DatabaseError::Io)?; + async fn load_proofs_from_store(&self) -> Result, DatabaseError> { + let keys = KVStore::list(self.store.as_ref(), CASHU_PRIMARY_KEY, PROOFS_KEY) + .await + .map_err(DatabaseError::Io)?; - let mut proofs = Vec::new(); + let mut proofs = Vec::with_capacity(keys.len()); for key in keys { - let data = - self.store.read(CASHU_PRIMARY_KEY, PROOFS_KEY, &key).map_err(DatabaseError::Io)?; + let data = KVStore::read(self.store.as_ref(), CASHU_PRIMARY_KEY, PROOFS_KEY, &key) + .await + .map_err(DatabaseError::Io)?; if !data.is_empty() { let proof: ProofInfo = serde_json::from_slice(&data) @@ -209,9 +217,9 @@ impl CashuKvDatabase { Ok(proofs) } - fn load_mint_info(&self, mint_url: &MintUrl) -> Result, DatabaseError> { + async fn load_mint_info(&self, mint_url: &MintUrl) -> Result, DatabaseError> { let key = Self::generate_mint_info_key(mint_url); - match self.store.read(CASHU_PRIMARY_KEY, MINTS_KEY, &key) { + match KVStore::read(self.store.as_ref(), CASHU_PRIMARY_KEY, MINTS_KEY, &key).await { Ok(data) => { if data.is_empty() { return Ok(None); @@ -225,14 +233,16 @@ impl CashuKvDatabase { } } - fn save_mint_info( + async fn save_mint_info( &self, mint_url: &MintUrl, mint_info: &MintInfo, ) -> Result<(), DatabaseError> { let key = Self::generate_mint_info_key(mint_url); let data = serde_json::to_vec(mint_info) .map_err(|e| DatabaseError::Serialization(e.to_string()))?; - self.store.write(CASHU_PRIMARY_KEY, MINTS_KEY, &key, &data).map_err(DatabaseError::Io) + KVStore::write(self.store.as_ref(), CASHU_PRIMARY_KEY, MINTS_KEY, &key, data) + .await + .map_err(DatabaseError::Io) } fn generate_proof_key(proof: &ProofInfo) -> String { @@ -277,13 +287,13 @@ impl WalletDatabase for CashuKvDatabase { let mint_data = serde_json::to_vec(&mint_url) .map_err(|e| DatabaseError::Serialization(e.to_string()))?; - self.store - .write(CASHU_PRIMARY_KEY, MINTS_KEY, &mint_key, &mint_data) + KVStore::write(self.store.as_ref(), CASHU_PRIMARY_KEY, MINTS_KEY, &mint_key, mint_data) + .await .map_err(DatabaseError::Io)?; // Save mint info if provided if let Some(info) = &mint_info { - self.save_mint_info(&mint_url, info)?; + self.save_mint_info(&mint_url, info).await?; } // Update cache @@ -299,20 +309,20 @@ impl WalletDatabase for CashuKvDatabase { let mint_key = Self::generate_mint_key(&mint_url); // Remove mint URL by writing empty data - self.store - .write(CASHU_PRIMARY_KEY, MINTS_KEY, &mint_key, &[]) + KVStore::remove(self.store.as_ref(), CASHU_PRIMARY_KEY, MINTS_KEY, &mint_key) + .await .map_err(DatabaseError::Io)?; // Remove mint info let info_key = Self::generate_mint_info_key(&mint_url); - self.store - .write(CASHU_PRIMARY_KEY, MINTS_KEY, &info_key, &[]) + KVStore::remove(self.store.as_ref(), CASHU_PRIMARY_KEY, MINTS_KEY, &info_key) + .await .map_err(DatabaseError::Io)?; // Remove mint keysets let keysets_key = Self::generate_mint_keysets_key(&mint_url); - self.store - .write(CASHU_PRIMARY_KEY, MINT_KEYSETS_KEY, &keysets_key, &[]) + KVStore::remove(self.store.as_ref(), CASHU_PRIMARY_KEY, MINT_KEYSETS_KEY, &keysets_key) + .await .map_err(DatabaseError::Io)?; // Update cache @@ -334,7 +344,7 @@ impl WalletDatabase for CashuKvDatabase { } // Load from storage - self.load_mint_info(&mint_url).map_err(Into::into) + self.load_mint_info(&mint_url).await.map_err(Into::into) } async fn get_mints(&self) -> Result>, Self::Err> { @@ -374,19 +384,32 @@ impl WalletDatabase for CashuKvDatabase { for keyset in keysets { // Check if keyset already exists in individual keysets table let keyset_key = format!("keyset_{}", keyset.id); - let existing_keyset = - match self.store.read(CASHU_PRIMARY_KEY, KEYSETS_TABLE_KEY, &keyset_key) { - Ok(data) if !data.is_empty() => { - let existing: KeySetInfo = serde_json::from_slice(&data) - .map_err(|e| DatabaseError::Serialization(e.to_string()))?; - Some(existing) - }, - _ => None, - }; + let existing_keyset = match KVStore::read( + self.store.as_ref(), + CASHU_PRIMARY_KEY, + KEYSETS_TABLE_KEY, + &keyset_key, + ) + .await + { + Ok(data) if !data.is_empty() => { + let existing: KeySetInfo = serde_json::from_slice(&data) + .map_err(|e| DatabaseError::Serialization(e.to_string()))?; + Some(existing) + }, + _ => None, + }; // Check u32 mapping for conflicts let u32_key = format!("u32_{}", u32::from(keyset.id)); - match self.store.read(CASHU_PRIMARY_KEY, KEYSET_U32_MAPPING_KEY, &u32_key) { + match KVStore::read( + self.store.as_ref(), + CASHU_PRIMARY_KEY, + KEYSET_U32_MAPPING_KEY, + &u32_key, + ) + .await + { Ok(data) if !data.is_empty() => { let existing_id_str = String::from_utf8(data.to_vec()) .map_err(|e| DatabaseError::Serialization(e.to_string()))?; @@ -401,9 +424,15 @@ impl WalletDatabase for CashuKvDatabase { _ => { // No existing mapping, create one let id_data = keyset.id.to_string().as_bytes().to_vec(); - self.store - .write(CASHU_PRIMARY_KEY, KEYSET_U32_MAPPING_KEY, &u32_key, &id_data) - .map_err(DatabaseError::Io)?; + KVStore::write( + self.store.as_ref(), + CASHU_PRIMARY_KEY, + KEYSET_U32_MAPPING_KEY, + &u32_key, + id_data, + ) + .await + .map_err(DatabaseError::Io)?; }, } @@ -420,9 +449,15 @@ impl WalletDatabase for CashuKvDatabase { // Store individual keyset let keyset_data = serde_json::to_vec(&final_keyset) .map_err(|e| DatabaseError::Serialization(e.to_string()))?; - self.store - .write(CASHU_PRIMARY_KEY, KEYSETS_TABLE_KEY, &keyset_key, &keyset_data) - .map_err(DatabaseError::Io)?; + KVStore::write( + self.store.as_ref(), + CASHU_PRIMARY_KEY, + KEYSETS_TABLE_KEY, + &keyset_key, + keyset_data, + ) + .await + .map_err(DatabaseError::Io)?; updated_keysets.push(final_keyset); } @@ -453,8 +488,8 @@ impl WalletDatabase for CashuKvDatabase { let data = serde_json::to_vec(&all_mint_keysets) .map_err(|e| DatabaseError::Serialization(e.to_string()))?; - self.store - .write(CASHU_PRIMARY_KEY, MINT_KEYSETS_KEY, &key, &data) + KVStore::write(self.store.as_ref(), CASHU_PRIMARY_KEY, MINT_KEYSETS_KEY, &key, data) + .await .map_err(DatabaseError::Io)?; Ok(()) @@ -465,7 +500,7 @@ impl WalletDatabase for CashuKvDatabase { ) -> Result>, Self::Err> { let key = Self::generate_mint_keysets_key(&mint_url); - match self.store.read(CASHU_PRIMARY_KEY, MINT_KEYSETS_KEY, &key) { + match KVStore::read(self.store.as_ref(), CASHU_PRIMARY_KEY, MINT_KEYSETS_KEY, &key).await { Ok(data) => { if data.is_empty() { return Ok(None); @@ -482,7 +517,7 @@ impl WalletDatabase for CashuKvDatabase { async fn get_keyset_by_id(&self, keyset_id: &Id) -> Result, Self::Err> { // Read directly from the dedicated KEYSETS_TABLE keyed by the keyset ID for efficiency let key = format!("keyset_{}", keyset_id); - match self.store.read(CASHU_PRIMARY_KEY, KEYSETS_TABLE_KEY, &key) { + match KVStore::read(self.store.as_ref(), CASHU_PRIMARY_KEY, KEYSETS_TABLE_KEY, &key).await { Ok(data) if !data.is_empty() => { let keyset: KeySetInfo = serde_json::from_slice(&data) .map_err(|e| DatabaseError::Serialization(e.to_string()))?; @@ -499,15 +534,16 @@ impl WalletDatabase for CashuKvDatabase { let data = serde_json::to_vec("e).map_err(|e| DatabaseError::Serialization(e.to_string()))?; - self.store - .write(CASHU_PRIMARY_KEY, MINT_QUOTES_KEY, &key, &data) + KVStore::write(self.store.as_ref(), CASHU_PRIMARY_KEY, MINT_QUOTES_KEY, &key, data) + .await .map_err(DatabaseError::Io)?; Ok(()) } async fn get_mint_quote(&self, quote_id: &str) -> Result, Self::Err> { - match self.store.read(CASHU_PRIMARY_KEY, MINT_QUOTES_KEY, quote_id) { + match KVStore::read(self.store.as_ref(), CASHU_PRIMARY_KEY, MINT_QUOTES_KEY, quote_id).await + { Ok(data) => { if data.is_empty() { return Ok(None); @@ -522,14 +558,14 @@ impl WalletDatabase for CashuKvDatabase { } async fn get_mint_quotes(&self) -> Result, Self::Err> { - let keys = - self.store.list(CASHU_PRIMARY_KEY, MINT_QUOTES_KEY).map_err(DatabaseError::Io)?; + let keys = KVStore::list(self.store.as_ref(), CASHU_PRIMARY_KEY, MINT_QUOTES_KEY) + .await + .map_err(DatabaseError::Io)?; - let mut quotes = Vec::new(); + let mut quotes = Vec::with_capacity(keys.len()); for key in keys { - let data = self - .store - .read(CASHU_PRIMARY_KEY, MINT_QUOTES_KEY, &key) + let data = KVStore::read(self.store.as_ref(), CASHU_PRIMARY_KEY, MINT_QUOTES_KEY, &key) + .await .map_err(DatabaseError::Io)?; if !data.is_empty() { @@ -544,8 +580,8 @@ impl WalletDatabase for CashuKvDatabase { async fn remove_mint_quote(&self, quote_id: &str) -> Result<(), Self::Err> { // Mark as removed by writing empty data - self.store - .write(CASHU_PRIMARY_KEY, MINT_QUOTES_KEY, quote_id, &[]) + KVStore::remove(self.store.as_ref(), CASHU_PRIMARY_KEY, MINT_QUOTES_KEY, quote_id) + .await .map_err(DatabaseError::Io)?; Ok(()) @@ -556,15 +592,16 @@ impl WalletDatabase for CashuKvDatabase { let data = serde_json::to_vec("e).map_err(|e| DatabaseError::Serialization(e.to_string()))?; - self.store - .write(CASHU_PRIMARY_KEY, MELT_QUOTES_KEY, &key, &data) + KVStore::write(self.store.as_ref(), CASHU_PRIMARY_KEY, MELT_QUOTES_KEY, &key, data) + .await .map_err(DatabaseError::Io)?; Ok(()) } async fn get_melt_quote(&self, quote_id: &str) -> Result, Self::Err> { - match self.store.read(CASHU_PRIMARY_KEY, MELT_QUOTES_KEY, quote_id) { + match KVStore::read(self.store.as_ref(), CASHU_PRIMARY_KEY, MELT_QUOTES_KEY, quote_id).await + { Ok(data) => { if data.is_empty() { return Ok(None); @@ -579,14 +616,14 @@ impl WalletDatabase for CashuKvDatabase { } async fn get_melt_quotes(&self) -> Result, Self::Err> { - let keys = - self.store.list(CASHU_PRIMARY_KEY, MELT_QUOTES_KEY).map_err(DatabaseError::Io)?; + let keys = KVStore::list(self.store.as_ref(), CASHU_PRIMARY_KEY, MELT_QUOTES_KEY) + .await + .map_err(DatabaseError::Io)?; - let mut quotes = Vec::new(); + let mut quotes = Vec::with_capacity(keys.len()); for key in keys { - let data = self - .store - .read(CASHU_PRIMARY_KEY, MELT_QUOTES_KEY, &key) + let data = KVStore::read(self.store.as_ref(), CASHU_PRIMARY_KEY, MELT_QUOTES_KEY, &key) + .await .map_err(DatabaseError::Io)?; if !data.is_empty() { @@ -600,8 +637,8 @@ impl WalletDatabase for CashuKvDatabase { } async fn remove_melt_quote(&self, quote_id: &str) -> Result<(), Self::Err> { - self.store - .write(CASHU_PRIMARY_KEY, MELT_QUOTES_KEY, quote_id, &[]) + KVStore::remove(self.store.as_ref(), CASHU_PRIMARY_KEY, MELT_QUOTES_KEY, quote_id) + .await .map_err(DatabaseError::Io)?; Ok(()) @@ -618,7 +655,9 @@ impl WalletDatabase for CashuKvDatabase { let data = serde_json::to_vec(&keyset).map_err(|e| DatabaseError::Serialization(e.to_string()))?; - self.store.write(CASHU_PRIMARY_KEY, KEYS_KEY, &key, &data).map_err(DatabaseError::Io)?; + KVStore::write(self.store.as_ref(), CASHU_PRIMARY_KEY, KEYS_KEY, &key, data) + .await + .map_err(DatabaseError::Io)?; Ok(()) } @@ -626,7 +665,7 @@ impl WalletDatabase for CashuKvDatabase { async fn get_keys(&self, id: &Id) -> Result, Self::Err> { let key = id.to_string(); - match self.store.read(CASHU_PRIMARY_KEY, KEYS_KEY, &key) { + match KVStore::read(self.store.as_ref(), CASHU_PRIMARY_KEY, KEYS_KEY, &key).await { Ok(data) => { if data.is_empty() { return Ok(None); @@ -643,7 +682,9 @@ impl WalletDatabase for CashuKvDatabase { async fn remove_keys(&self, id: &Id) -> Result<(), Self::Err> { let key = id.to_string(); - self.store.write(CASHU_PRIMARY_KEY, KEYS_KEY, &key, &[]).map_err(DatabaseError::Io)?; + KVStore::remove(self.store.as_ref(), CASHU_PRIMARY_KEY, KEYS_KEY, &key) + .await + .map_err(DatabaseError::Io)?; Ok(()) } @@ -657,8 +698,8 @@ impl WalletDatabase for CashuKvDatabase { let data = serde_json::to_vec(proof) .map_err(|e| DatabaseError::Serialization(e.to_string()))?; - self.store - .write(CASHU_PRIMARY_KEY, PROOFS_KEY, &key, &data) + KVStore::write(self.store.as_ref(), CASHU_PRIMARY_KEY, PROOFS_KEY, &key, data) + .await .map_err(DatabaseError::Io)?; } @@ -666,8 +707,8 @@ impl WalletDatabase for CashuKvDatabase { for y in &removed_ys { let key = format!("proof_{}", hex::encode(y.serialize())); - self.store - .write(CASHU_PRIMARY_KEY, PROOFS_KEY, &key, &[]) + KVStore::remove(self.store.as_ref(), CASHU_PRIMARY_KEY, PROOFS_KEY, &key) + .await .map_err(DatabaseError::Io)?; } @@ -724,7 +765,7 @@ impl WalletDatabase for CashuKvDatabase { let key = format!("proof_{}", hex::encode(y.serialize())); // Read existing proof - match self.store.read(CASHU_PRIMARY_KEY, PROOFS_KEY, &key) { + match KVStore::read(self.store.as_ref(), CASHU_PRIMARY_KEY, PROOFS_KEY, &key).await { Ok(data) if !data.is_empty() => { let mut proof: ProofInfo = serde_json::from_slice(&data) .map_err(|e| DatabaseError::Serialization(e.to_string()))?; @@ -736,9 +777,15 @@ impl WalletDatabase for CashuKvDatabase { let updated_data = serde_json::to_vec(&proof) .map_err(|e| DatabaseError::Serialization(e.to_string()))?; - self.store - .write(CASHU_PRIMARY_KEY, PROOFS_KEY, &key, &updated_data) - .map_err(DatabaseError::Io)?; + KVStore::write( + self.store.as_ref(), + CASHU_PRIMARY_KEY, + PROOFS_KEY, + &key, + updated_data, + ) + .await + .map_err(DatabaseError::Io)?; }, _ => continue, // Proof not found, skip } @@ -761,11 +808,14 @@ impl WalletDatabase for CashuKvDatabase { let key = keyset_id.to_string(); // Read current counter - let current_count = match self.store.read(CASHU_PRIMARY_KEY, KEYSET_COUNTERS_KEY, &key) { - Ok(data) if !data.is_empty() => serde_json::from_slice::(&data) - .map_err(|e| DatabaseError::Serialization(e.to_string()))?, - _ => 0, // Default to 0 if not found - }; + let current_count = + match KVStore::read(self.store.as_ref(), CASHU_PRIMARY_KEY, KEYSET_COUNTERS_KEY, &key) + .await + { + Ok(data) if !data.is_empty() => serde_json::from_slice::(&data) + .map_err(|e| DatabaseError::Serialization(e.to_string()))?, + _ => 0, // Default to 0 if not found + }; let new_count = current_count + count; @@ -773,8 +823,8 @@ impl WalletDatabase for CashuKvDatabase { let data = serde_json::to_vec(&new_count) .map_err(|e| DatabaseError::Serialization(e.to_string()))?; - self.store - .write(CASHU_PRIMARY_KEY, KEYSET_COUNTERS_KEY, &key, &data) + KVStore::write(self.store.as_ref(), CASHU_PRIMARY_KEY, KEYSET_COUNTERS_KEY, &key, data) + .await .map_err(DatabaseError::Io)?; Ok(new_count) @@ -785,8 +835,8 @@ impl WalletDatabase for CashuKvDatabase { let data = serde_json::to_vec(&transaction) .map_err(|e| DatabaseError::Serialization(e.to_string()))?; - self.store - .write(CASHU_PRIMARY_KEY, TRANSACTIONS_KEY, &key, &data) + KVStore::write(self.store.as_ref(), CASHU_PRIMARY_KEY, TRANSACTIONS_KEY, &key, data) + .await .map_err(DatabaseError::Io)?; Ok(()) @@ -797,7 +847,7 @@ impl WalletDatabase for CashuKvDatabase { ) -> Result, Self::Err> { let key = transaction_id.to_string(); - match self.store.read(CASHU_PRIMARY_KEY, TRANSACTIONS_KEY, &key) { + match KVStore::read(self.store.as_ref(), CASHU_PRIMARY_KEY, TRANSACTIONS_KEY, &key).await { Ok(data) => { if data.is_empty() { return Ok(None); @@ -815,15 +865,16 @@ impl WalletDatabase for CashuKvDatabase { &self, mint_url: Option, direction: Option, unit: Option, ) -> Result, Self::Err> { - let keys = - self.store.list(CASHU_PRIMARY_KEY, TRANSACTIONS_KEY).map_err(DatabaseError::Io)?; + let keys = KVStore::list(self.store.as_ref(), CASHU_PRIMARY_KEY, TRANSACTIONS_KEY) + .await + .map_err(DatabaseError::Io)?; - let mut transactions = Vec::new(); + let mut transactions = Vec::with_capacity(keys.len()); for key in keys { - let data = self - .store - .read(CASHU_PRIMARY_KEY, TRANSACTIONS_KEY, &key) - .map_err(DatabaseError::Io)?; + let data = + KVStore::read(self.store.as_ref(), CASHU_PRIMARY_KEY, TRANSACTIONS_KEY, &key) + .await + .map_err(DatabaseError::Io)?; if !data.is_empty() { let transaction: Transaction = serde_json::from_slice(&data) @@ -862,18 +913,16 @@ impl WalletDatabase for CashuKvDatabase { async fn remove_transaction(&self, transaction_id: TransactionId) -> Result<(), Self::Err> { let key = transaction_id.to_string(); - self.store - .write(CASHU_PRIMARY_KEY, TRANSACTIONS_KEY, &key, &[]) + KVStore::remove(self.store.as_ref(), CASHU_PRIMARY_KEY, TRANSACTIONS_KEY, &key) + .await .map_err(DatabaseError::Io)?; Ok(()) } } -pub(super) fn read_has_recovered( - store: &Arc, -) -> Result { - match store.read(CASHU_PRIMARY_KEY, "", HAS_RECOVERED_KEY) { +pub(super) async fn read_has_recovered(store: &Arc) -> Result { + match KVStore::read(store.as_ref(), CASHU_PRIMARY_KEY, "", HAS_RECOVERED_KEY).await { Ok(data) => { if data.is_empty() { return Ok(false); @@ -885,10 +934,12 @@ pub(super) fn read_has_recovered( } } -pub(super) fn write_has_recovered( - store: &Arc, has_recovered: bool, +pub(super) async fn write_has_recovered( + store: &Arc, has_recovered: bool, ) -> Result<(), TrustedError> { let data = vec![if has_recovered { 1 } else { 0 }]; - store.write(CASHU_PRIMARY_KEY, "", HAS_RECOVERED_KEY, &data).map_err(TrustedError::IOError) + KVStore::write(store.as_ref(), CASHU_PRIMARY_KEY, "", HAS_RECOVERED_KEY, data) + .await + .map_err(TrustedError::IOError) } diff --git a/orange-sdk/src/trusted_wallet/cashu/mod.rs b/orange-sdk/src/trusted_wallet/cashu/mod.rs index d507a15..48d4a47 100644 --- a/orange-sdk/src/trusted_wallet/cashu/mod.rs +++ b/orange-sdk/src/trusted_wallet/cashu/mod.rs @@ -6,11 +6,11 @@ use crate::store::{PaymentId, TxMetadataStore, TxStatus}; use crate::trusted_wallet::{Payment, TrustedError, TrustedWalletInterface}; use crate::{Event, EventQueue, InitFailure, Seed, WalletConfig}; +use ldk_node::DynStore; use ldk_node::bitcoin::hashes::Hash; use ldk_node::bitcoin::hashes::sha256::Hash as Sha256; use ldk_node::bitcoin::hex::FromHex; use ldk_node::lightning::util::logger::Logger as _; -use ldk_node::lightning::util::persist::KVStore; use ldk_node::lightning::{log_error, log_info}; use ldk_node::lightning_invoice::Bolt11Invoice; use ldk_node::lightning_types::payment::{PaymentHash, PaymentPreimage}; @@ -475,7 +475,7 @@ const PAYMENT_HASH_METADATA_KEY: &str = "payment_hash"; impl Cashu { pub(crate) async fn init( - config: &WalletConfig, cashu_config: CashuConfig, store: Arc, + config: &WalletConfig, cashu_config: CashuConfig, store: Arc, event_queue: Arc, tx_metadata: TxMetadataStore, logger: Arc, runtime: Arc, ) -> Result { @@ -505,7 +505,7 @@ impl Cashu { }, }; - let db = Arc::new(CashuKvDatabase::new(Arc::clone(&store)).map_err(|e| { + let db = Arc::new(CashuKvDatabase::new(Arc::clone(&store)).await.map_err(|e| { InitFailure::TrustedFailure(TrustedError::Other(format!( "Failed to create Cashu database: {e}" ))) @@ -579,7 +579,7 @@ impl Cashu { } // spawn background task to recover funds if first time initializing - let has_recovered = read_has_recovered(&store)?; + let has_recovered = read_has_recovered(&store).await?; if !has_recovered { let w = Arc::clone(&cashu_wallet); let l = Arc::clone(&logger); @@ -590,7 +590,7 @@ impl Cashu { if amt > cdk::Amount::ZERO { log_info!(l, "Restored cashu mint: {}, amt: {amt}", w.mint_url); } - if let Err(e) = write_has_recovered(&store, true) { + if let Err(e) = write_has_recovered(&store, true).await { log_error!(l, "Failed to write has_recovered flag: {e:?}"); } }, diff --git a/orange-sdk/src/trusted_wallet/dummy.rs b/orange-sdk/src/trusted_wallet/dummy.rs index 9d080be..733b08a 100644 --- a/orange-sdk/src/trusted_wallet/dummy.rs +++ b/orange-sdk/src/trusted_wallet/dummy.rs @@ -78,7 +78,7 @@ impl DummyTrustedWallet { let ldk_node = Arc::new(builder.build().unwrap()); - ldk_node.start_with_runtime(Arc::clone(&rt)).unwrap(); + ldk_node.start().unwrap(); let current_bal_msats = Arc::new(AtomicU64::new(0)); let payments: Arc>> = Arc::new(RwLock::new(vec![])); @@ -216,7 +216,7 @@ impl DummyTrustedWallet { // wait for ldk to be ready let iterations = if std::env::var("CI").is_ok() { 120 } else { 10 }; for _ in 0..iterations { - if ldk_node.status().is_listening { + if ldk_node.status().is_running { break; } tokio::time::sleep(Duration::from_secs(1)).await; diff --git a/orange-sdk/src/trusted_wallet/spark/mod.rs b/orange-sdk/src/trusted_wallet/spark/mod.rs index eb9b55f..f95c7d7 100644 --- a/orange-sdk/src/trusted_wallet/spark/mod.rs +++ b/orange-sdk/src/trusted_wallet/spark/mod.rs @@ -9,8 +9,8 @@ use crate::store::{PaymentId, TxMetadataStore, TxStatus}; use crate::trusted_wallet::{Payment, TrustedError, TrustedWalletInterface}; use crate::{Event, EventQueue, InitFailure, Seed, WalletConfig}; +use ldk_node::DynStore; 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}; @@ -256,9 +256,9 @@ 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: SparkWalletConfig, - store: Arc, event_queue: Arc, - tx_metadata: TxMetadataStore, logger: Arc, runtime: Arc, + 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)?; diff --git a/orange-sdk/src/trusted_wallet/spark/spark_store.rs b/orange-sdk/src/trusted_wallet/spark/spark_store.rs index fe4197b..44ff001 100644 --- a/orange-sdk/src/trusted_wallet/spark/spark_store.rs +++ b/orange-sdk/src/trusted_wallet/spark/spark_store.rs @@ -2,7 +2,9 @@ use std::sync::Arc; -use crate::{KVStore, io}; +use crate::io; +use ldk_node::DynStore; +use ldk_node::lightning::util::persist::KVStore; use breez_sdk_spark::{ DepositInfo, ListPaymentsRequest, Payment, PaymentDetails, PaymentMetadata, StorageError, @@ -16,7 +18,7 @@ const SPARK_PAYMENTS_NAMESPACE: &str = "payment"; const SPARK_DEPOSITS_NAMESPACE: &str = "deposit"; #[derive(Clone)] -pub(crate) struct SparkStore(pub(crate) Arc); +pub(crate) struct SparkStore(pub(crate) Arc); /// The Spark sdk can produce keys that are too long, we just truncate them here fn sanitize_key(key: String) -> String { @@ -31,15 +33,17 @@ fn sanitize_key(key: String) -> String { impl breez_sdk_spark::Storage for SparkStore { async fn delete_cached_item(&self, key: String) -> Result<(), StorageError> { let key = sanitize_key(key); - self.0 - .remove(SPARK_PRIMARY_NAMESPACE, SPARK_CACHE_NAMESPACE, &key, false) + KVStore::remove(self.0.as_ref(), SPARK_PRIMARY_NAMESPACE, SPARK_CACHE_NAMESPACE, &key, false) + .await .map_err(|e| StorageError::Implementation(format!("{e:?}")))?; Ok(()) } async fn get_cached_item(&self, key: String) -> Result, StorageError> { let key = sanitize_key(key); - match self.0.read(SPARK_PRIMARY_NAMESPACE, SPARK_CACHE_NAMESPACE, &key) { + match KVStore::read(self.0.as_ref(), SPARK_PRIMARY_NAMESPACE, SPARK_CACHE_NAMESPACE, &key) + .await + { Ok(bytes) => Ok(Some(String::from_utf8(bytes).map_err(|e| { StorageError::Serialization(format!("Invalid UTF-8 in cached item: {e:?}")) })?)), @@ -55,26 +59,36 @@ impl breez_sdk_spark::Storage for SparkStore { async fn set_cached_item(&self, key: String, value: String) -> Result<(), StorageError> { let key = sanitize_key(key); - self.0 - .write(SPARK_PRIMARY_NAMESPACE, SPARK_CACHE_NAMESPACE, &key, value.as_bytes()) - .map_err(|e| StorageError::Implementation(format!("{e:?}")))?; + KVStore::write( + self.0.as_ref(), + SPARK_PRIMARY_NAMESPACE, + SPARK_CACHE_NAMESPACE, + &key, + value.into_bytes(), + ) + .await + .map_err(|e| StorageError::Implementation(format!("{e:?}")))?; Ok(()) } async fn list_payments( &self, request: ListPaymentsRequest, ) -> Result, StorageError> { - let keys = self - .0 - .list(SPARK_PRIMARY_NAMESPACE, SPARK_PAYMENTS_NAMESPACE) - .map_err(|e| StorageError::Implementation(format!("{e:?}")))?; + let keys = + KVStore::list(self.0.as_ref(), SPARK_PRIMARY_NAMESPACE, SPARK_PAYMENTS_NAMESPACE) + .await + .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 data = KVStore::read( + self.0.as_ref(), + SPARK_PRIMARY_NAMESPACE, + SPARK_PAYMENTS_NAMESPACE, + &key, + ) + .await + .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:?}")))?; @@ -105,9 +119,15 @@ impl breez_sdk_spark::Storage for SparkStore { 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:?}")))?; + KVStore::write( + self.0.as_ref(), + SPARK_PRIMARY_NAMESPACE, + SPARK_PAYMENTS_NAMESPACE, + &payment.id, + data, + ) + .await + .map_err(|e| StorageError::Implementation(format!("{e:?}")))?; Ok(()) } @@ -121,10 +141,10 @@ impl breez_sdk_spark::Storage for SparkStore { 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 data = + KVStore::read(self.0.as_ref(), SPARK_PRIMARY_NAMESPACE, SPARK_PAYMENTS_NAMESPACE, &id) + .await + .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:?}")))?; @@ -176,33 +196,43 @@ impl breez_sdk_spark::Storage for SparkStore { 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:?}")))?; + KVStore::write( + self.0.as_ref(), + SPARK_PRIMARY_NAMESPACE, + SPARK_DEPOSITS_NAMESPACE, + &id, + data, + ) + .await + .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) + KVStore::remove(self.0.as_ref(), SPARK_PRIMARY_NAMESPACE, SPARK_DEPOSITS_NAMESPACE, &id) + .await .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 keys = + KVStore::list(self.0.as_ref(), SPARK_PRIMARY_NAMESPACE, SPARK_DEPOSITS_NAMESPACE) + .await + .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 data = KVStore::read( + self.0.as_ref(), + SPARK_PRIMARY_NAMESPACE, + SPARK_DEPOSITS_NAMESPACE, + &key, + ) + .await + .map_err(|e| StorageError::Implementation(format!("{e:?}")))?; let deposit: DepositInfo = serde_json::from_slice(&data) .map_err(|e| StorageError::Serialization(format!("{e:?}")))?; @@ -217,7 +247,14 @@ impl breez_sdk_spark::Storage for SparkStore { ) -> Result<(), StorageError> { let id = format!("{txid}:{vout}"); - let data = match self.0.read(SPARK_PRIMARY_NAMESPACE, SPARK_DEPOSITS_NAMESPACE, &id) { + let data = match KVStore::read( + self.0.as_ref(), + SPARK_PRIMARY_NAMESPACE, + SPARK_DEPOSITS_NAMESPACE, + &id, + ) + .await + { Ok(data) => data, Err(e) => { if let io::ErrorKind::NotFound = e.kind() { @@ -245,9 +282,15 @@ impl breez_sdk_spark::Storage for SparkStore { 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:?}")))?; + KVStore::write( + self.0.as_ref(), + SPARK_PRIMARY_NAMESPACE, + SPARK_DEPOSITS_NAMESPACE, + &id, + data, + ) + .await + .map_err(|e| StorageError::Implementation(format!("{e:?}")))?; Ok(()) } diff --git a/orange-sdk/tests/integration_tests.rs b/orange-sdk/tests/integration_tests.rs index 0d02b5e..04dedaa 100644 --- a/orange-sdk/tests/integration_tests.rs +++ b/orange-sdk/tests/integration_tests.rs @@ -943,7 +943,7 @@ fn test_threshold_boundary_rebalance_min() { .await; test_utils::wait_for_condition("wait for transaction", || async { - wallet.list_transactions().await.unwrap().len() >= 1 + !wallet.list_transactions().await.unwrap().is_empty() }) .await; @@ -1263,10 +1263,14 @@ fn test_payment_network_mismatch() { ); // now force a correct parsing to ensure we fail when trying to pay - let instr = - PaymentInstructions::parse(wrong_network, Network::Bitcoin, &HTTPHrnResolver, true) - .await - .unwrap(); + let instr = PaymentInstructions::parse( + wrong_network, + Network::Bitcoin, + &HTTPHrnResolver::new(), + true, + ) + .await + .unwrap(); // If it parsed, trying to pay should fail due to network mismatch let amount = Amount::from_sats(1000).unwrap(); @@ -1498,9 +1502,7 @@ fn test_concurrent_receive_operations() { // Wait for first payment to complete test_utils::wait_for_condition("first payment to succeed", || async { - third_party - .payment(&payment_id_1) - .map_or(false, |p| p.status == PaymentStatus::Succeeded) + third_party.payment(&payment_id_1).is_some_and(|p| p.status == PaymentStatus::Succeeded) }) .await; @@ -1509,9 +1511,7 @@ fn test_concurrent_receive_operations() { // Wait for second payment to complete test_utils::wait_for_condition("second payment to succeed", || async { - third_party - .payment(&payment_id_2) - .map_or(false, |p| p.status == PaymentStatus::Succeeded) + third_party.payment(&payment_id_2).is_some_and(|p| p.status == PaymentStatus::Succeeded) }) .await; diff --git a/orange-sdk/tests/test_utils.rs b/orange-sdk/tests/test_utils.rs index 7293bf3..cab41e8 100644 --- a/orange-sdk/tests/test_utils.rs +++ b/orange-sdk/tests/test_utils.rs @@ -68,7 +68,7 @@ fn create_bitcoind(uuid: Uuid) -> Bitcoind { conf.args.push("-rpcworkqueue=100"); conf.staticdir = Some(temp_dir().join(format!("orange-test-{uuid}/bitcoind"))); let bitcoind = Bitcoind::with_conf(corepc_node::downloaded_exe_path().unwrap(), &conf) - .expect(&format!("Failed to start bitcoind for test {uuid}")); + .unwrap_or_else(|_| panic!("Failed to start bitcoind for test {uuid}")); // Wait for bitcoind to be ready before returning wait_for_bitcoind_ready(&bitcoind); @@ -109,7 +109,7 @@ pub fn generate_blocks(bitcoind: &Bitcoind, num: usize) { let _block_hashes = bitcoind .client .generate_to_address(num, &address) - .expect(&format!("failed to generate {num} blocks")); + .unwrap_or_else(|_| panic!("failed to generate {num} blocks")); } fn create_lsp(rt: Arc, uuid: Uuid, bitcoind: &Bitcoind) -> Arc { @@ -153,7 +153,7 @@ fn create_lsp(rt: Arc, uuid: Uuid, bitcoind: &Bitcoind) -> Arc { let ldk_node = Arc::new(builder.build().unwrap()); - ldk_node.start_with_runtime(Arc::clone(&rt)).unwrap(); + ldk_node.start().unwrap(); let events_ref = Arc::clone(&ldk_node); rt.spawn(async move { @@ -197,7 +197,7 @@ fn create_third_party(rt: Arc, uuid: Uuid, bitcoind: &Bitcoind) -> Arc< let ldk_node = Arc::new(builder.build().unwrap()); - ldk_node.start_with_runtime(Arc::clone(&rt)).unwrap(); + ldk_node.start().unwrap(); let events_ref = Arc::clone(&ldk_node); rt.spawn(async move { @@ -478,10 +478,10 @@ pub async fn open_channel_from_lsp(wallet: &orange_sdk::Wallet, payer: Arc }) .await; - let event = wait_next_event(&wallet).await; + let event = wait_next_event(wallet).await; assert!(matches!(event, orange_sdk::Event::ChannelOpened { .. })); - let event = wait_next_event(&wallet).await; + let event = wait_next_event(wallet).await; match event { orange_sdk::Event::PaymentReceived { payment_hash, amount_msat, lsp_fee_msats, .. } => { assert!(lsp_fee_msats.is_some()); // we expect a fee to be paid for opening a channel From 82b1f16099f0cc7ba5569214e9f287e75b00765e Mon Sep 17 00:00:00 2001 From: benthecarman Date: Tue, 21 Oct 2025 14:54:18 -0500 Subject: [PATCH 02/20] try to move to new runtime --- examples/cli/src/main.rs | 14 +- justfile | 2 +- orange-sdk/Cargo.toml | 3 + orange-sdk/src/ffi/orange/wallet.rs | 4 +- orange-sdk/src/lib.rs | 34 +- orange-sdk/src/lightning_wallet.rs | 8 +- orange-sdk/src/runtime.rs | 146 + orange-sdk/src/trusted_wallet/cashu/mod.rs | 10 +- orange-sdk/src/trusted_wallet/dummy.rs | 6 +- orange-sdk/src/trusted_wallet/spark/mod.rs | 4 +- orange-sdk/tests/integration_tests.rs | 3360 ++++++++++---------- orange-sdk/tests/test_utils.rs | 64 +- 12 files changed, 1842 insertions(+), 1813 deletions(-) create mode 100644 orange-sdk/src/runtime.rs diff --git a/examples/cli/src/main.rs b/examples/cli/src/main.rs index 5b00aad..f86c179 100644 --- a/examples/cli/src/main.rs +++ b/examples/cli/src/main.rs @@ -58,7 +58,6 @@ enum Commands { struct WalletState { wallet: Wallet, - _runtime: Arc, // Keep runtime alive shutdown: Arc, } @@ -128,20 +127,20 @@ fn get_config(network: Network) -> Result { } impl WalletState { - async fn new(runtime: Arc) -> Result { + async fn new() -> Result { let shutdown = Arc::new(AtomicBool::new(false)); let config = get_config(NETWORK) .with_context(|| format!("Failed to get wallet config for network: {NETWORK:?}"))?; println!("{} Initializing wallet...", "⚡".bright_yellow()); - match Wallet::new_with_runtime(runtime.clone(), config).await { + match Wallet::new(config).await { Ok(wallet) => { println!("{} Wallet initialized successfully!", "✅".bright_green()); println!("Network: {}", NETWORK.to_string().bright_cyan()); let w = wallet.clone(); - runtime.spawn(async move { + tokio::spawn(async move { let event = w.next_event_async().await; match event { Event::PaymentSuccessful { payment_id, .. } => { @@ -198,7 +197,7 @@ impl WalletState { w.event_handled().unwrap(); }); - Ok(WalletState { wallet, _runtime: runtime, shutdown }) + Ok(WalletState { wallet, shutdown }) }, Err(e) => Err(anyhow::anyhow!("Failed to initialize wallet: {:?}", e)), } @@ -213,7 +212,8 @@ impl WalletState { } } -fn main() -> Result<()> { +#[tokio::main(flavor = "multi_thread")] +async fn main() -> Result<()> { let cli = Cli::parse(); println!("{}", "🟠 Orange CLI Wallet".bright_yellow().bold()); @@ -224,7 +224,7 @@ fn main() -> Result<()> { let runtime = Arc::new(Runtime::new().context("Failed to create tokio runtime")?); // Initialize wallet once at startup - let mut state = runtime.block_on(WalletState::new(runtime.clone()))?; + let mut state = runtime.block_on(WalletState::new())?; // Set up signal handling for graceful shutdown let shutdown_state = state.shutdown.clone(); diff --git a/justfile b/justfile index 027a201..e85fcab 100644 --- a/justfile +++ b/justfile @@ -2,7 +2,7 @@ default: @just --list test *args: - cargo test {{ args }} --features _test-utils -p orange-sdk + cargo test {{ args }} --features _test-utils -p orange-sdk -- --nocapture test-cashu *args: cargo test {{ args }} --features _cashu-tests -p orange-sdk diff --git a/orange-sdk/Cargo.toml b/orange-sdk/Cargo.toml index e4e094b..a026b30 100644 --- a/orange-sdk/Cargo.toml +++ b/orange-sdk/Cargo.toml @@ -44,3 +44,6 @@ cdk-axum = { git = "https://github.com/benthecarman/cdk.git", rev = "0d7c1b2b9b7 axum = { version = "0.8.1", optional = true } uniffi = { version = "0.29", features = ["cli", "tokio"], optional = true } + +[dev-dependencies] +test-log = "0.2.18" \ No newline at end of file diff --git a/orange-sdk/src/ffi/orange/wallet.rs b/orange-sdk/src/ffi/orange/wallet.rs index f52e4f3..3025b4f 100644 --- a/orange-sdk/src/ffi/orange/wallet.rs +++ b/orange-sdk/src/ffi/orange/wallet.rs @@ -98,9 +98,7 @@ impl Wallet { let config: OrangeWalletConfig = config.try_into()?; - let rt_clone = rt.clone(); - let inner = - rt.block_on(async move { OrangeWallet::new_with_runtime(rt_clone, config).await })?; + let inner = rt.block_on(async move { OrangeWallet::new(config).await })?; Ok(Wallet { inner: Arc::new(inner) }) } diff --git a/orange-sdk/src/lib.rs b/orange-sdk/src/lib.rs index 23adc9c..1c5a072 100644 --- a/orange-sdk/src/lib.rs +++ b/orange-sdk/src/lib.rs @@ -29,8 +29,6 @@ use ldk_node::lightning_invoice::Bolt11Invoice; use ldk_node::payment::PaymentKind; use ldk_node::{BuildError, ChannelDetails, DynStore, NodeError}; -use tokio::runtime::Runtime; - use std::collections::HashMap; use std::fmt::{self, Debug, Write}; use std::sync::Arc; @@ -42,6 +40,7 @@ mod ffi; mod lightning_wallet; pub(crate) mod logging; mod rebalancer; +mod runtime; mod store; pub mod trusted_wallet; @@ -50,6 +49,7 @@ use logging::Logger; use trusted_wallet::TrustedError; pub use crate::logging::LoggerType; +use crate::runtime::Runtime; #[cfg(feature = "cashu")] pub use crate::trusted_wallet::cashu::CashuConfig; #[cfg(feature = "spark")] @@ -507,27 +507,17 @@ impl Wallet { /// Recovery ensures trusted wallet funds can be restored when reconstructed from the same seed /// across different devices or installations. pub async fn new(config: WalletConfig) -> Result { - let rt = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - .map_err(|e| InitFailure::IoError(e.into()))?; - - Self::new_with_runtime(Arc::new(rt), config).await - } - /// Constructs a new Wallet with a runtime. - /// - /// `runtime` must be a reference to the running `tokio` runtime which we are currently - /// operating in. - // TODO: WOW that is a terrible API lol - pub async fn new_with_runtime( - runtime: Arc, config: WalletConfig, - ) -> Result { let tunables = config.tunables; let network = config.network; let logger = Arc::new(Logger::new(&config.logger_type).expect("Failed to open log file")); log_info!(logger, "Initializing orange on network: {network}"); + let runtime = Arc::new(Runtime::new(Arc::clone(&logger)).map_err(|e| { + log_error!(logger, "Failed to set up tokio runtime: {e}"); + BuildError::RuntimeSetupFailed + })?); + let store: Arc = match &config.storage_config { StorageConfig::LocalSQLite(path) => { Arc::new(SqliteStore::new(path.into(), Some("orange.sqlite".to_owned()), None)?) @@ -620,7 +610,7 @@ impl Wallet { // Spawn a background thread that every second, we see if we should initiate a rebalance // This will withdraw from the trusted balance to our LN balance, possibly opening a channel. let rb = Arc::clone(&rebalancer); - runtime.spawn(async move { + runtime.spawn_cancellable_background_task(async move { loop { rb.do_rebalance_if_needed().await; @@ -1134,7 +1124,7 @@ impl Wallet { }, ); let inner_ref = Arc::clone(&self.inner); - self.inner.runtime.spawn(async move { + self.inner.runtime.spawn_cancellable_background_task(async move { inner_ref.rebalancer.do_rebalance_if_needed().await; }); return Ok(()); @@ -1338,5 +1328,11 @@ impl Wallet { log_debug!(self.inner.logger, "Stopping ln wallet..."); self.inner.ln_wallet.stop(); + + // Cancel cancellable background tasks + self.inner.runtime.abort_cancellable_background_tasks(); + + // Wait until non-cancellable background tasks (mod LDK's background processor) are done. + self.inner.runtime.wait_on_background_tasks(); } } diff --git a/orange-sdk/src/lightning_wallet.rs b/orange-sdk/src/lightning_wallet.rs index 2c945c5..56b03d3 100644 --- a/orange-sdk/src/lightning_wallet.rs +++ b/orange-sdk/src/lightning_wallet.rs @@ -1,6 +1,7 @@ use crate::bitcoin::OutPoint; use crate::event::{EventQueue, LdkEventHandler}; use crate::logging::Logger; +use crate::runtime::Runtime; use crate::store::{TxMetadataStore, TxStatus}; use crate::{ChainSource, InitFailure, PaymentType, Seed, WalletConfig}; @@ -27,7 +28,6 @@ use std::fmt::Debug; use std::pin::Pin; use std::sync::Arc; -use tokio::runtime::Runtime; use tokio::sync::watch; #[derive(Debug, Clone, Copy)] @@ -129,6 +129,8 @@ impl LightningWallet { builder.set_custom_logger(Arc::clone(&logger) as Arc); + builder.set_runtime(runtime.get_handle()); + // download scorer and write to storage // todo switch to https://github.com/lightningdevkit/ldk-node/pull/449 once available if let Some(url) = config.scorer_url { @@ -179,10 +181,10 @@ impl LightningWallet { inner.ldk_node.start()?; - runtime.spawn(async move { + runtime.spawn_cancellable_background_task(async move { loop { let event = ev_handler.ldk_node.next_event_async().await; - log_debug!(ev_handler.logger, "Got ldk-node event {:?}", event); + log_debug!(ev_handler.logger, "Got ldk-node event {event:?}"); ev_handler.handle_ldk_node_event(event).await; } }); diff --git a/orange-sdk/src/runtime.rs b/orange-sdk/src/runtime.rs new file mode 100644 index 0000000..ac1ed67 --- /dev/null +++ b/orange-sdk/src/runtime.rs @@ -0,0 +1,146 @@ +// This file is Copyright its original authors, visible in version control history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license , at your option. You may not use this file except in +// accordance with one or both of these licenses. + +use ldk_node::lightning::util::logger::Logger as _; +use ldk_node::lightning::{log_debug, log_error, log_trace}; +use std::future::Future; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use tokio::task::{JoinHandle, JoinSet}; + +use crate::logging::Logger; + +// The timeout after which we give up waiting on a background task to exit on shutdown. +pub(crate) const BACKGROUND_TASK_SHUTDOWN_TIMEOUT_SECS: u64 = 5; + +pub(crate) struct Runtime { + mode: RuntimeMode, + background_tasks: Mutex>, + cancellable_background_tasks: Mutex>, + logger: Arc, +} + +impl Runtime { + pub fn new(logger: Arc) -> Result { + let mode = match tokio::runtime::Handle::try_current() { + Ok(handle) => RuntimeMode::Handle(handle), + Err(_) => { + let rt = tokio::runtime::Builder::new_multi_thread().enable_all().build()?; + RuntimeMode::Owned(rt) + }, + }; + let background_tasks = Mutex::new(JoinSet::new()); + let cancellable_background_tasks = Mutex::new(JoinSet::new()); + + Ok(Self { mode, background_tasks, cancellable_background_tasks, logger }) + } + + pub fn get_handle(&self) -> tokio::runtime::Handle { + match &self.mode { + RuntimeMode::Owned(rt) => rt.handle().clone(), + RuntimeMode::Handle(h) => h.clone(), + } + } + + #[allow(unused)] + pub fn with_handle(handle: tokio::runtime::Handle, logger: Arc) -> Self { + let mode = RuntimeMode::Handle(handle); + let background_tasks = Mutex::new(JoinSet::new()); + let cancellable_background_tasks = Mutex::new(JoinSet::new()); + + Self { mode, background_tasks, cancellable_background_tasks, logger } + } + + pub fn spawn_background_task(&self, future: F) + where + F: Future + Send + 'static, + { + let mut background_tasks = self.background_tasks.lock().unwrap(); + let runtime_handle = self.handle(); + background_tasks.spawn_on(future, runtime_handle); + } + + pub fn spawn_cancellable_background_task(&self, future: F) + where + F: Future + Send + 'static, + { + let mut cancellable_background_tasks = self.cancellable_background_tasks.lock().unwrap(); + let runtime_handle = self.handle(); + cancellable_background_tasks.spawn_on(future, runtime_handle); + } + + #[allow(unused)] + pub fn spawn_blocking(&self, func: F) -> JoinHandle + where + F: FnOnce() -> R + Send + 'static, + R: Send + 'static, + { + let handle = self.handle(); + handle.spawn_blocking(func) + } + + pub fn block_on(&self, future: F) -> F::Output { + // While we generally decided not to overthink via which call graph users would enter our + // runtime context, we'd still try to reuse whatever current context would be present + // during `block_on`, as this is the context `block_in_place` would operate on. So we try + // to detect the outer context here, and otherwise use whatever was set during + // initialization. + let handle = tokio::runtime::Handle::try_current().unwrap_or(self.handle().clone()); + tokio::task::block_in_place(move || handle.block_on(future)) + } + + pub fn abort_cancellable_background_tasks(&self) { + let mut tasks = core::mem::take(&mut *self.cancellable_background_tasks.lock().unwrap()); + debug_assert!(!tasks.is_empty(), "Expected some cancellable background_tasks"); + tasks.abort_all(); + self.block_on(async { while tasks.join_next().await.is_some() {} }) + } + + pub fn wait_on_background_tasks(&self) { + let mut tasks = core::mem::take(&mut *self.background_tasks.lock().unwrap()); + debug_assert!(!tasks.is_empty(), "Expected some background_tasks"); + self.block_on(async { + loop { + let timeout_fut = tokio::time::timeout( + Duration::from_secs(BACKGROUND_TASK_SHUTDOWN_TIMEOUT_SECS), + tasks.join_next_with_id(), + ); + match timeout_fut.await { + Ok(Some(Ok((id, _)))) => { + log_trace!(self.logger, "Stopped background task with id {id}"); + }, + Ok(Some(Err(e))) => { + tasks.abort_all(); + log_trace!(self.logger, "Stopping background task failed: {e}"); + break; + }, + Ok(None) => { + log_debug!(self.logger, "Stopped all background tasks"); + break; + }, + Err(e) => { + tasks.abort_all(); + log_error!(self.logger, "Stopping background task timed out: {e}"); + break; + }, + } + } + }) + } + + fn handle(&self) -> &tokio::runtime::Handle { + match &self.mode { + RuntimeMode::Owned(rt) => rt.handle(), + RuntimeMode::Handle(handle) => handle, + } + } +} + +enum RuntimeMode { + Owned(tokio::runtime::Runtime), + Handle(tokio::runtime::Handle), +} diff --git a/orange-sdk/src/trusted_wallet/cashu/mod.rs b/orange-sdk/src/trusted_wallet/cashu/mod.rs index 48d4a47..d301a48 100644 --- a/orange-sdk/src/trusted_wallet/cashu/mod.rs +++ b/orange-sdk/src/trusted_wallet/cashu/mod.rs @@ -2,6 +2,7 @@ use crate::bitcoin::hex::DisplayHex; use crate::logging::Logger; +use crate::runtime::Runtime; use crate::store::{PaymentId, TxMetadataStore, TxStatus}; use crate::trusted_wallet::{Payment, TrustedError, TrustedWalletInterface}; use crate::{Event, EventQueue, InitFailure, Seed, WalletConfig}; @@ -37,7 +38,6 @@ use std::pin::Pin; use std::str::FromStr; use std::sync::Arc; use std::time::Duration; -use tokio::runtime::Runtime; /// Cashu KV store implementation pub mod cashu_store; @@ -298,7 +298,7 @@ impl TrustedWalletInterface for Cashu { let tx_metadata = self.tx_metadata.clone(); let quote_id = quote.id.clone(); let payment_success_sender = self.payment_success_sender.clone(); - self.runtime.spawn(async move { + self.runtime.spawn_background_task(async move { let mut metadata = HashMap::new(); if let Some(hash) = &payment_hash { metadata.insert(PAYMENT_HASH_METADATA_KEY.to_string(), hash.to_string()); @@ -542,7 +542,7 @@ impl Cashu { let logger_for_monitoring = Arc::clone(&logger); let eq_for_monitoring = Arc::clone(&event_queue); let rt_for_monitoring = Arc::clone(&runtime); - runtime.spawn(async move { + runtime.spawn_cancellable_background_task(async move { loop { tokio::select! { _ = shutdown_receiver.changed() => { @@ -556,7 +556,7 @@ impl Cashu { let wallet = Arc::clone(&wallet_for_monitoring); let event_queue = Arc::clone(&eq_for_monitoring); let logger = Arc::clone(&logger_for_monitoring); - rt_for_monitoring.spawn(async move { + rt_for_monitoring.spawn_cancellable_background_task(async move { if let Err(e) = Self::monitor_mint_quote(wallet, event_queue, &logger, mint_quote).await { log_error!(logger, "Failed to monitor mint quote: {e:?}"); } @@ -583,7 +583,7 @@ impl Cashu { if !has_recovered { let w = Arc::clone(&cashu_wallet); let l = Arc::clone(&logger); - runtime.spawn(async move { + runtime.spawn_background_task(async move { match w.restore().await { Err(e) => log_error!(l, "Failed to restore cashu mint: {e}"), Ok(amt) => { diff --git a/orange-sdk/src/trusted_wallet/dummy.rs b/orange-sdk/src/trusted_wallet/dummy.rs index 733b08a..8b73e7e 100644 --- a/orange-sdk/src/trusted_wallet/dummy.rs +++ b/orange-sdk/src/trusted_wallet/dummy.rs @@ -2,6 +2,7 @@ use crate::EventQueue; use crate::bitcoin::hashes::Hash; +use crate::runtime::Runtime; use crate::store::{PaymentId, TxMetadataStore, TxStatus}; use crate::trusted_wallet::{Payment, TrustedError, TrustedWalletInterface}; use bitcoin_payment_instructions::PaymentMethod; @@ -20,7 +21,6 @@ use std::pin::Pin; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, RwLock}; use std::time::Duration; -use tokio::runtime::Runtime; use tokio::sync::watch; use uuid::Uuid; @@ -44,8 +44,6 @@ pub struct DummyTrustedWalletExtraConfig { pub lsp: Arc, /// The Bitcoind node to connect to pub bitcoind: Arc, - /// The runtime to use for async tasks - pub rt: Arc, } impl DummyTrustedWallet { @@ -88,7 +86,7 @@ impl DummyTrustedWallet { let events_ref = Arc::clone(&ldk_node); let bal = Arc::clone(¤t_bal_msats); let pays = Arc::clone(&payments); - rt.spawn(async move { + rt.spawn_cancellable_background_task(async move { loop { let event = events_ref.next_event_async().await; match event { diff --git a/orange-sdk/src/trusted_wallet/spark/mod.rs b/orange-sdk/src/trusted_wallet/spark/mod.rs index f95c7d7..0047fa7 100644 --- a/orange-sdk/src/trusted_wallet/spark/mod.rs +++ b/orange-sdk/src/trusted_wallet/spark/mod.rs @@ -28,12 +28,12 @@ use graduated_rebalancer::ReceivedLightningPayment; use tokio::sync::watch; +use crate::runtime::Runtime; 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. @@ -294,7 +294,7 @@ impl Spark { 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 { + runtime.spawn_background_task(async move { let _ = shutdown_recv.changed().await; w.remove_event_listener(&listener_id).await; }); diff --git a/orange-sdk/tests/integration_tests.rs b/orange-sdk/tests/integration_tests.rs index 04dedaa..b662368 100644 --- a/orange-sdk/tests/integration_tests.rs +++ b/orange-sdk/tests/integration_tests.rs @@ -10,6 +10,7 @@ use ldk_node::NodeError; use ldk_node::bitcoin::Network; use ldk_node::lightning_invoice::{Bolt11InvoiceDescription, Description}; use ldk_node::payment::{ConfirmationStatus, PaymentDirection, PaymentStatus}; +use log::{debug, info}; use orange_sdk::bitcoin::hashes::Hash; use orange_sdk::{Event, PaymentInfo, PaymentType, TxStatus, WalletError}; use std::sync::Arc; @@ -17,1928 +18,1827 @@ use std::time::Duration; mod test_utils; -#[test] -fn test_node_start() { - let TestParams { wallet, rt, .. } = build_test_nodes(); +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_node_start() { + let TestParams { wallet, .. } = build_test_nodes().await; - rt.block_on(async move { - let bal = wallet.get_balance().await.unwrap(); - assert_eq!(bal.available_balance(), Amount::ZERO); - assert_eq!(bal.pending_balance, Amount::ZERO); - }) + let bal = wallet.get_balance().await.unwrap(); + assert_eq!(bal.available_balance(), Amount::ZERO); + assert_eq!(bal.pending_balance, Amount::ZERO); } -#[test] -fn test_receive_to_trusted() { - let TestParams { wallet, third_party, rt, .. } = build_test_nodes(); - - rt.block_on(async move { - let starting_bal = wallet.get_balance().await.unwrap(); - assert_eq!(starting_bal.available_balance(), Amount::ZERO); - assert_eq!(starting_bal.pending_balance, Amount::ZERO); - - let recv_amt = Amount::from_sats(100).unwrap(); - - let limit = wallet.get_tunables(); - assert!(recv_amt < limit.trusted_balance_limit); +#[tokio::test(flavor = "multi_thread")] +#[test_log::test] +async fn test_receive_to_trusted() { + let TestParams { wallet, third_party, lsp, .. } = build_test_nodes().await; - let uri = wallet.get_single_use_receive_uri(Some(recv_amt)).await.unwrap(); - let payment_id = third_party.bolt11_payment().send(&uri.invoice, None).unwrap(); + let starting_bal = wallet.get_balance().await.unwrap(); + assert_eq!(starting_bal.available_balance(), Amount::ZERO); + assert_eq!(starting_bal.pending_balance, Amount::ZERO); - // wait for payment success from payer side - let p = Arc::clone(&third_party); - test_utils::wait_for_condition("payer payment success", || { - let res = p.payment(&payment_id).is_some_and(|p| p.status == PaymentStatus::Succeeded); - async move { res } - }) - .await; + let recv_amt = Amount::from_sats(100).unwrap(); - // wait for balance update on wallet side - test_utils::wait_for_condition("wallet balance update after receive", || async { - wallet.get_balance().await.unwrap().available_balance() > Amount::ZERO - }) - .await; + let limit = wallet.get_tunables(); + assert!(recv_amt < limit.trusted_balance_limit); - let txs = wallet.list_transactions().await.unwrap(); - assert_eq!(txs.len(), 1); - let tx = txs.into_iter().next().unwrap(); + let uri = wallet.get_single_use_receive_uri(Some(recv_amt)).await.unwrap(); + let payment_id = third_party.bolt11_payment().send(&uri.invoice, None).unwrap(); - // Comprehensive validation for trusted wallet receive - assert_eq!(tx.fee, Some(Amount::ZERO), "Trusted wallet receive should have zero fees"); - assert!(!tx.outbound, "Incoming payment should not be outbound"); - assert_eq!(tx.status, TxStatus::Completed, "Payment should be completed"); - assert_eq!( - tx.payment_type, - PaymentType::IncomingLightning {}, - "Payment type should be IncomingLightning" - ); - assert_ne!(tx.time_since_epoch, Duration::ZERO, "Time should be set"); - assert_eq!( - tx.amount, - Some(recv_amt), - "Amount should equal received amount for trusted wallet (no fees deducted)" - ); + // wait for payment success from payer side + let p = Arc::clone(&third_party); + test_utils::wait_for_condition("payer payment success", || { + let res = p.payment(&payment_id).is_some_and(|p| p.status == PaymentStatus::Succeeded); + async move { res } }) -} - -#[test] -fn test_pay_from_trusted() { - let TestParams { wallet, third_party, lsp, rt, .. } = build_test_nodes(); - - rt.block_on(async move { - let starting_bal = wallet.get_balance().await.unwrap(); - assert_eq!(starting_bal.available_balance(), Amount::ZERO); - assert_eq!(starting_bal.pending_balance, Amount::ZERO); - - let recv_amt = Amount::from_sats(100).unwrap(); - - let limit = wallet.get_tunables(); - assert!(recv_amt < limit.trusted_balance_limit); - - let uri = wallet.get_single_use_receive_uri(Some(recv_amt)).await.unwrap(); - let payment_id = third_party.bolt11_payment().send(&uri.invoice, None).unwrap(); - - // wait for payment success from payer side - let p = Arc::clone(&third_party); - test_utils::wait_for_condition("payer payment success", || { - let res = p.payment(&payment_id).is_some_and(|p| p.status == PaymentStatus::Succeeded); - async move { res } - }) - .await; - - // wait for balance update on wallet side - test_utils::wait_for_condition("wallet balance update after receive", || async { - wallet.get_balance().await.unwrap().trusted > Amount::ZERO - }) - .await; - - let bal = wallet.get_balance().await.unwrap(); - - let event = wait_next_event(&wallet).await; - match event { - Event::PaymentReceived { .. } => {}, - e => panic!("Expected PaymentReceived event, got {e:?}"), - } - - let desc = Bolt11InvoiceDescription::Direct(Description::empty()); - let amount = Amount::from_sats(10).unwrap(); - let invoice = lsp.bolt11_payment().receive(amount.milli_sats(), &desc, 300).unwrap(); - - let instr = wallet.parse_payment_instructions(invoice.to_string().as_str()).await.unwrap(); - let info = PaymentInfo::build(instr, None).unwrap(); - wallet.pay(&info).await.unwrap(); - - let event = wait_next_event(&wallet).await; - match event { - Event::PaymentSuccessful { payment_hash, fee_paid_msat, .. } => { - assert!(fee_paid_msat.is_some()); - assert_eq!(payment_hash.0, invoice.payment_hash().to_byte_array()); - }, - e => panic!("Expected PaymentSuccessful event, got {e:?}"), - } + .await; - // wait for balance update on wallet side - test_utils::wait_for_condition("wallet balance update after send", || async { - wallet.get_balance().await.unwrap().trusted < bal.trusted - }) - .await; - - let txs = wallet.list_transactions().await.unwrap(); - assert_eq!(txs.len(), 2); - let tx = txs.into_iter().find(|t| t.outbound).unwrap(); - - assert!(tx.fee.is_some(), "Trusted wallet send should have fees set"); - assert!(tx.outbound, "Outgoing payment should be outbound"); - assert_eq!(tx.status, TxStatus::Completed, "Payment should be completed"); - match tx.payment_type { - PaymentType::OutgoingLightningBolt11 { payment_preimage } => { - assert!( - payment_preimage.is_some_and(|p| p.0 != [0; 32]), - "Completed payment should have payment_preimage" - ); - }, - pt => panic!("Payment type should be OutgoingLightningBolt11, got {pt:?}"), - } + // wait for balance update on wallet side + test_utils::wait_for_condition("wallet balance update after receive", || async { + wallet.get_balance().await.unwrap().available_balance() > Amount::ZERO }) + .await; + + let txs = wallet.list_transactions().await.unwrap(); + assert_eq!(txs.len(), 1); + let tx = txs.into_iter().next().unwrap(); + + // Comprehensive validation for trusted wallet receive + assert_eq!(tx.fee, Some(Amount::ZERO), "Trusted wallet receive should have zero fees"); + assert!(!tx.outbound, "Incoming payment should not be outbound"); + assert_eq!(tx.status, TxStatus::Completed, "Payment should be completed"); + assert_eq!( + tx.payment_type, + PaymentType::IncomingLightning {}, + "Payment type should be IncomingLightning" + ); + assert_ne!(tx.time_since_epoch, Duration::ZERO, "Time should be set"); + assert_eq!( + tx.amount, + Some(recv_amt), + "Amount should equal received amount for trusted wallet (no fees deducted)" + ); + + info!("test passed"); + + debug!("stopping wallet"); + wallet.stop().await; + debug!("stopping third party"); + third_party.stop().unwrap(); + debug!("stopping lsp"); + lsp.stop().unwrap(); } -#[test] -fn test_sweep_to_ln() { - let TestParams { wallet, lsp, third_party, rt, .. } = build_test_nodes(); - - rt.block_on(async move { - let starting_bal = wallet.get_balance().await.unwrap(); - assert_eq!(starting_bal.available_balance(), Amount::ZERO); - assert_eq!(starting_bal.pending_balance, Amount::ZERO); - - let starting_lsp_channels = lsp.list_channels(); - - // start with receiving half the limit - let limit = wallet.get_tunables(); - let recv_amt = - Amount::from_milli_sats(limit.trusted_balance_limit.milli_sats() / 2).unwrap(); - - let uri = wallet.get_single_use_receive_uri(Some(recv_amt)).await.unwrap(); - third_party.bolt11_payment().send(&uri.invoice, None).unwrap(); - - // wait for balance update on wallet side - test_utils::wait_for_condition("wallet balance update after receive", || async { - wallet.get_balance().await.unwrap().available_balance() > Amount::ZERO - }) - .await; - - let event = wait_next_event(&wallet).await; - match event { - Event::PaymentReceived { .. } => {}, - e => panic!("Expected PaymentReceived event, got {e:?}"), - } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_pay_from_trusted() { + let TestParams { wallet, third_party, lsp, .. } = build_test_nodes().await; - let intermediate_amt = wallet.get_balance().await.unwrap().available_balance(); + let starting_bal = wallet.get_balance().await.unwrap(); + assert_eq!(starting_bal.available_balance(), Amount::ZERO); + assert_eq!(starting_bal.pending_balance, Amount::ZERO); - // next receive the limit to trigger the rebalance - let recv_amt = Amount::from_milli_sats(limit.trusted_balance_limit.milli_sats()).unwrap(); + let recv_amt = Amount::from_sats(100).unwrap(); - let uri = wallet.get_single_use_receive_uri(Some(recv_amt)).await.unwrap(); - third_party.bolt11_payment().send(&uri.invoice, None).unwrap(); + let limit = wallet.get_tunables(); + assert!(recv_amt < limit.trusted_balance_limit); - // wait for balance update on wallet side - test_utils::wait_for_condition("wallet balance update after receive", || async { - wallet.get_balance().await.unwrap().available_balance() > intermediate_amt - }) - .await; + let uri = wallet.get_single_use_receive_uri(Some(recv_amt)).await.unwrap(); + let payment_id = third_party.bolt11_payment().send(&uri.invoice, None).unwrap(); - // receive to trusted wallet - let event = wait_next_event(&wallet).await; - match event { - Event::PaymentReceived { .. } => {}, - e => panic!("Expected PaymentReceived event, got {e:?}"), - } - - let event = wait_next_event(&wallet).await; - assert!( - matches!(event, Event::RebalanceInitiated { .. }), - "Expected RebalanceInitiated event but got {event:?}" - ); - - // wait for rebalance - test_utils::wait_for_condition("wait for new channel to be opened", || async { - starting_lsp_channels.len() < lsp.list_channels().len() - }) - .await; + // wait for payment success from payer side + let p = Arc::clone(&third_party); + test_utils::wait_for_condition("payer payment success", || { + let res = p.payment(&payment_id).is_some_and(|p| p.status == PaymentStatus::Succeeded); + async move { res } + }) + .await; - // wait for payment received - let event = wait_next_event(&wallet).await; - match event { - Event::ChannelOpened { counterparty_node_id, .. } => { - assert_eq!(counterparty_node_id, lsp.node_id()); - }, - _ => panic!("Expected ChannelOpened event"), - } + // wait for balance update on wallet side + test_utils::wait_for_condition("wallet balance update after receive", || async { + wallet.get_balance().await.unwrap().trusted > Amount::ZERO + }) + .await; + + let bal = wallet.get_balance().await.unwrap(); + + let event = wait_next_event(&wallet).await; + match event { + Event::PaymentReceived { .. } => {}, + e => panic!("Expected PaymentReceived event, got {e:?}"), + } + + let desc = Bolt11InvoiceDescription::Direct(Description::empty()); + let amount = Amount::from_sats(10).unwrap(); + let invoice = lsp.bolt11_payment().receive(amount.milli_sats(), &desc, 300).unwrap(); + + let instr = wallet.parse_payment_instructions(invoice.to_string().as_str()).await.unwrap(); + let info = PaymentInfo::build(instr, None).unwrap(); + wallet.pay(&info).await.unwrap(); + + let event = wait_next_event(&wallet).await; + match event { + Event::PaymentSuccessful { payment_hash, fee_paid_msat, .. } => { + assert!(fee_paid_msat.is_some()); + assert_eq!(payment_hash.0, invoice.payment_hash().to_byte_array()); + }, + e => panic!("Expected PaymentSuccessful event, got {e:?}"), + } + + // wait for balance update on wallet side + test_utils::wait_for_condition("wallet balance update after send", || async { + wallet.get_balance().await.unwrap().trusted < bal.trusted + }) + .await; - let expect_amt = intermediate_amt.saturating_add(recv_amt); + let txs = wallet.list_transactions().await.unwrap(); + assert_eq!(txs.len(), 2); + let tx = txs.into_iter().find(|t| t.outbound).unwrap(); - let event = wait_next_event(&wallet).await; - match event { - Event::PaymentReceived { payment_id, amount_msat, lsp_fee_msats, .. } => { - assert!(matches!(payment_id, orange_sdk::PaymentId::SelfCustodial(_))); - assert!(lsp_fee_msats.is_some()); - assert_eq!(amount_msat, expect_amt.milli_sats() - lsp_fee_msats.unwrap()); - }, - e => panic!("Expected RebalanceSuccessful event, got {e:?}"), - } + assert!(tx.fee.is_some(), "Trusted wallet send should have fees set"); + assert!(tx.outbound, "Outgoing payment should be outbound"); + assert_eq!(tx.status, TxStatus::Completed, "Payment should be completed"); + match tx.payment_type { + PaymentType::OutgoingLightningBolt11 { payment_preimage } => { + assert!( + payment_preimage.is_some_and(|p| p.0 != [0; 32]), + "Completed payment should have payment_preimage" + ); + }, + pt => panic!("Payment type should be OutgoingLightningBolt11, got {pt:?}"), + } +} - let event = wait_next_event(&wallet).await; - match event { - Event::RebalanceSuccessful { amount_msat, fee_msat, .. } => { - assert!(fee_msat > 0); - assert_eq!(amount_msat, expect_amt.milli_sats()); - }, - e => panic!("Expected RebalanceSuccessful event, got {e:?}"), - } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_sweep_to_ln() { + let TestParams { wallet, lsp, third_party, .. } = build_test_nodes().await; - let event = wallet.next_event(); - assert!(event.is_none(), "No more events expected, got {event:?}"); + let starting_bal = wallet.get_balance().await.unwrap(); + assert_eq!(starting_bal.available_balance(), Amount::ZERO); + assert_eq!(starting_bal.pending_balance, Amount::ZERO); - // Verify transaction list has correct amounts - let txs = wallet.list_transactions().await.unwrap(); + let starting_lsp_channels = lsp.list_channels(); - // Should have 2 incoming payments (the two receives) - rebalances should not appear as transactions - let incoming_txs: Vec<_> = txs.into_iter().filter(|tx| !tx.outbound).collect(); - assert_eq!(incoming_txs.len(), 2, "Should have exactly 2 incoming transactions"); + // start with receiving half the limit + let limit = wallet.get_tunables(); + let recv_amt = Amount::from_milli_sats(limit.trusted_balance_limit.milli_sats() / 2).unwrap(); - // First transaction should be the smaller amount (intermediate_amt) - let first_tx = &incoming_txs[0]; - assert_eq!( - first_tx.amount, - Some(intermediate_amt), - "First transaction should be the intermediate amount" - ); - assert!(!first_tx.outbound, "First transaction should be incoming"); - assert_eq!(first_tx.status, TxStatus::Completed, "First transaction should be completed"); - assert_eq!( - first_tx.payment_type, - PaymentType::IncomingLightning {}, - "First transaction should be IncomingLightning" - ); - assert_eq!( - first_tx.fee, - Some(Amount::ZERO), - "First transaction should have zero fee (trusted wallet)" - ); - - // Second transaction should be the larger amount (recv_amt) but may have fees if it went through Lightning - let second_tx = &incoming_txs[1]; - assert_eq!( - second_tx.amount, - Some(recv_amt), - "Second transaction should be the received amount" - ); - assert!(!second_tx.outbound, "Second transaction should be incoming"); - assert_eq!(second_tx.status, TxStatus::Completed, "Second transaction should be completed"); - assert_eq!( - second_tx.payment_type, - PaymentType::IncomingLightning {}, - "Second transaction should be IncomingLightning" - ); - // The second payment triggers rebalance and should have a fee - assert!( - second_tx.fee.is_some_and(|a| a > Amount::ZERO), - "Second transaction should have fee greater than 0" - ); + let uri = wallet.get_single_use_receive_uri(Some(recv_amt)).await.unwrap(); + third_party.bolt11_payment().send(&uri.invoice, None).unwrap(); - // Verify total amounts match expected - let total_tx_amount = first_tx.amount.unwrap().saturating_add(second_tx.amount.unwrap()); - let expected_total = intermediate_amt.saturating_add(recv_amt); - assert_eq!( - total_tx_amount, - expected_total, - "Total transaction amounts should match expected total: {} msat", - expected_total.milli_sats() - ); + // wait for balance update on wallet side + test_utils::wait_for_condition("wallet balance update after receive", || async { + wallet.get_balance().await.unwrap().available_balance() > Amount::ZERO }) -} + .await; -#[test] -fn test_receive_to_ln() { - let TestParams { wallet, third_party, rt, .. } = build_test_nodes(); + let event = wait_next_event(&wallet).await; + match event { + Event::PaymentReceived { .. } => {}, + e => panic!("Expected PaymentReceived event, got {e:?}"), + } - rt.block_on(async move { - let recv_amt = open_channel_from_lsp(&wallet, Arc::clone(&third_party)).await; + let intermediate_amt = wallet.get_balance().await.unwrap().available_balance(); - let txs = wallet.list_transactions().await.unwrap(); - assert_eq!(txs.len(), 1); - let tx = txs.into_iter().next().unwrap(); + // next receive the limit to trigger the rebalance + let recv_amt = Amount::from_milli_sats(limit.trusted_balance_limit.milli_sats()).unwrap(); - // Comprehensive validation for lightning receive - assert!( - tx.fee.is_some_and(|f| f > Amount::ZERO), - "Lightning receive should have non-zero fees" - ); - assert!(!tx.outbound, "Incoming payment should not be outbound"); - assert_eq!(tx.status, TxStatus::Completed, "Payment should be completed"); - assert_eq!( - tx.payment_type, - PaymentType::IncomingLightning {}, - "Payment type should be IncomingLightning" - ); - assert_ne!(tx.time_since_epoch, Duration::ZERO, "Time should be set"); - assert_eq!( - tx.amount, - Some(recv_amt.saturating_sub(tx.fee.unwrap())), - "Amount should be received amount minus fees" - ); + let uri = wallet.get_single_use_receive_uri(Some(recv_amt)).await.unwrap(); + third_party.bolt11_payment().send(&uri.invoice, None).unwrap(); - // Validate fee is reasonable (should be less than 10% of received amount) - let fee_ratio = tx.fee.unwrap().milli_sats() as f64 / recv_amt.milli_sats() as f64; - assert!( - fee_ratio < 0.1, - "Fee should be less than 10% of received amount, got {:.2}%", - fee_ratio * 100.0 - ); + // wait for balance update on wallet side + test_utils::wait_for_condition("wallet balance update after receive", || async { + wallet.get_balance().await.unwrap().available_balance() > intermediate_amt }) + .await; + + // receive to trusted wallet + let event = wait_next_event(&wallet).await; + match event { + Event::PaymentReceived { .. } => {}, + e => panic!("Expected PaymentReceived event, got {e:?}"), + } + + let event = wait_next_event(&wallet).await; + assert!( + matches!(event, Event::RebalanceInitiated { .. }), + "Expected RebalanceInitiated event but got {event:?}" + ); + + // wait for rebalance + test_utils::wait_for_condition("wait for new channel to be opened", || async { + starting_lsp_channels.len() < lsp.list_channels().len() + }) + .await; + + // wait for payment received + let event = wait_next_event(&wallet).await; + match event { + Event::ChannelOpened { counterparty_node_id, .. } => { + assert_eq!(counterparty_node_id, lsp.node_id()); + }, + _ => panic!("Expected ChannelOpened event"), + } + + let expect_amt = intermediate_amt.saturating_add(recv_amt); + + let event = wait_next_event(&wallet).await; + match event { + Event::PaymentReceived { payment_id, amount_msat, lsp_fee_msats, .. } => { + assert!(matches!(payment_id, orange_sdk::PaymentId::SelfCustodial(_))); + assert!(lsp_fee_msats.is_some()); + assert_eq!(amount_msat, expect_amt.milli_sats() - lsp_fee_msats.unwrap()); + }, + e => panic!("Expected RebalanceSuccessful event, got {e:?}"), + } + + let event = wait_next_event(&wallet).await; + match event { + Event::RebalanceSuccessful { amount_msat, fee_msat, .. } => { + assert!(fee_msat > 0); + assert_eq!(amount_msat, expect_amt.milli_sats()); + }, + e => panic!("Expected RebalanceSuccessful event, got {e:?}"), + } + + let event = wallet.next_event(); + assert!(event.is_none(), "No more events expected, got {event:?}"); + + // Verify transaction list has correct amounts + let txs = wallet.list_transactions().await.unwrap(); + + // Should have 2 incoming payments (the two receives) - rebalances should not appear as transactions + let incoming_txs: Vec<_> = txs.into_iter().filter(|tx| !tx.outbound).collect(); + assert_eq!(incoming_txs.len(), 2, "Should have exactly 2 incoming transactions"); + + // First transaction should be the smaller amount (intermediate_amt) + let first_tx = &incoming_txs[0]; + assert_eq!( + first_tx.amount, + Some(intermediate_amt), + "First transaction should be the intermediate amount" + ); + assert!(!first_tx.outbound, "First transaction should be incoming"); + assert_eq!(first_tx.status, TxStatus::Completed, "First transaction should be completed"); + assert_eq!( + first_tx.payment_type, + PaymentType::IncomingLightning {}, + "First transaction should be IncomingLightning" + ); + assert_eq!( + first_tx.fee, + Some(Amount::ZERO), + "First transaction should have zero fee (trusted wallet)" + ); + + // Second transaction should be the larger amount (recv_amt) but may have fees if it went through Lightning + let second_tx = &incoming_txs[1]; + assert_eq!( + second_tx.amount, + Some(recv_amt), + "Second transaction should be the received amount" + ); + assert!(!second_tx.outbound, "Second transaction should be incoming"); + assert_eq!(second_tx.status, TxStatus::Completed, "Second transaction should be completed"); + assert_eq!( + second_tx.payment_type, + PaymentType::IncomingLightning {}, + "Second transaction should be IncomingLightning" + ); + // The second payment triggers rebalance and should have a fee + assert!( + second_tx.fee.is_some_and(|a| a > Amount::ZERO), + "Second transaction should have fee greater than 0" + ); + + // Verify total amounts match expected + let total_tx_amount = first_tx.amount.unwrap().saturating_add(second_tx.amount.unwrap()); + let expected_total = intermediate_amt.saturating_add(recv_amt); + assert_eq!( + total_tx_amount, + expected_total, + "Total transaction amounts should match expected total: {} msat", + expected_total.milli_sats() + ); } -#[test] -fn test_receive_to_onchain() { - let TestParams { wallet, lsp, bitcoind, third_party, rt, .. } = build_test_nodes(); - - rt.block_on(async move { - let starting_bal = wallet.get_balance().await.unwrap(); - assert_eq!(starting_bal.available_balance(), Amount::ZERO); - assert_eq!(starting_bal.pending_balance, Amount::ZERO); - - let recv_amt = Amount::from_sats(200_000).unwrap(); - - let uri = wallet.get_single_use_receive_uri(Some(recv_amt)).await.unwrap(); - let sent_txid = third_party - .onchain_payment() - .send_to_address(&uri.address.unwrap(), recv_amt.sats().unwrap(), None) - .unwrap(); - - // confirm transaction - generate_blocks(&bitcoind, 6); - - // check we received on-chain, should be pending - // wait for payment success - test_utils::wait_for_condition("pending balance to update", || async { - // onchain balance is always listed as pending until we splice it into the channel. - wallet.get_balance().await.unwrap().pending_balance == recv_amt - }) - .await; - - let event = wait_next_event(&wallet).await; - - match event { - Event::OnchainPaymentReceived { txid, amount_sat, status, .. } => { - assert_eq!(txid, sent_txid); - assert_eq!(amount_sat, recv_amt.sats().unwrap()); - assert!(matches!(status, ConfirmationStatus::Confirmed { .. })); - }, - _ => panic!("Expected OnchainPaymentReceived event"), - } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_receive_to_ln() { + let TestParams { wallet, third_party, .. } = build_test_nodes().await; + + let recv_amt = open_channel_from_lsp(&wallet, Arc::clone(&third_party)).await; + + let txs = wallet.list_transactions().await.unwrap(); + assert_eq!(txs.len(), 1); + let tx = txs.into_iter().next().unwrap(); + + // Comprehensive validation for lightning receive + assert!( + tx.fee.is_some_and(|f| f > Amount::ZERO), + "Lightning receive should have non-zero fees" + ); + assert!(!tx.outbound, "Incoming payment should not be outbound"); + assert_eq!(tx.status, TxStatus::Completed, "Payment should be completed"); + assert_eq!( + tx.payment_type, + PaymentType::IncomingLightning {}, + "Payment type should be IncomingLightning" + ); + assert_ne!(tx.time_since_epoch, Duration::ZERO, "Time should be set"); + assert_eq!( + tx.amount, + Some(recv_amt.saturating_sub(tx.fee.unwrap())), + "Amount should be received amount minus fees" + ); + + // Validate fee is reasonable (should be less than 10% of received amount) + let fee_ratio = tx.fee.unwrap().milli_sats() as f64 / recv_amt.milli_sats() as f64; + assert!( + fee_ratio < 0.1, + "Fee should be less than 10% of received amount, got {:.2}%", + fee_ratio * 100.0 + ); +} - assert!(wallet.next_event().is_none()); +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_receive_to_onchain() { + let TestParams { wallet, lsp, bitcoind, third_party, .. } = build_test_nodes().await; - let txs = wallet.list_transactions().await.unwrap(); - assert_eq!(txs.len(), 1); - let tx = txs.into_iter().next().unwrap(); + let starting_bal = wallet.get_balance().await.unwrap(); + assert_eq!(starting_bal.available_balance(), Amount::ZERO); + assert_eq!(starting_bal.pending_balance, Amount::ZERO); - // Comprehensive validation for on-chain receive - assert!(!tx.outbound, "Incoming payment should not be outbound"); - assert_eq!(tx.status, TxStatus::Completed, "Payment should be completed"); - assert_eq!( - tx.payment_type, - PaymentType::IncomingOnChain { txid: Some(sent_txid) }, - "Payment type should be IncomingOnChain with correct txid" - ); - assert_ne!(tx.time_since_epoch, Duration::ZERO, "Time should be set"); - assert_eq!(tx.amount, Some(recv_amt), "Amount should equal received amount"); - assert_eq!( - tx.fee, - Some(Amount::ZERO), - "On-chain receive should have zero fees (paid by sender)" - ); - - // a rebalance should be initiated, we need to mine the channel opening transaction - // for it to be confirmed and reflected in the wallet's history - generate_blocks(&bitcoind, 6); - tokio::time::sleep(Duration::from_secs(5)).await; // wait for sync - generate_blocks(&bitcoind, 6); // confirm the channel opening transaction - tokio::time::sleep(Duration::from_secs(5)).await; // wait for sync - - // wait for rebalance to be initiated - let event = wait_next_event(&wallet).await; - match event { - Event::ChannelOpened { counterparty_node_id, .. } => { - assert_eq!(counterparty_node_id, lsp.node_id()); - }, - _ => panic!("Expected ChannelOpened event"), - } + let recv_amt = Amount::from_sats(200_000).unwrap(); - let txs = wallet.list_transactions().await.unwrap(); - assert_eq!(txs.len(), 1); - let tx = txs.into_iter().next().unwrap(); - - // Comprehensive validation for on-chain receive after rebalance - assert!(!tx.outbound, "Incoming payment should not be outbound"); - assert_eq!(tx.status, TxStatus::Completed, "Payment should be completed"); - assert_eq!( - tx.payment_type, - PaymentType::IncomingOnChain { txid: Some(sent_txid) }, - "Payment type should be IncomingOnChain with correct txid" - ); - assert_ne!(tx.time_since_epoch, Duration::ZERO, "Time should be set"); - assert_eq!(tx.amount, Some(recv_amt), "Amount should equal received amount"); - assert!( - tx.fee.unwrap() > Amount::ZERO, - "On-chain receive should have rebalance fees after channel opening" - ); + let uri = wallet.get_single_use_receive_uri(Some(recv_amt)).await.unwrap(); + let sent_txid = third_party + .onchain_payment() + .send_to_address(&uri.address.unwrap(), recv_amt.sats().unwrap(), None) + .unwrap(); - // Validate fee is reasonable (should be less than 5% of received amount for rebalance) - let fee_ratio = tx.fee.unwrap().milli_sats() as f64 / recv_amt.milli_sats() as f64; - assert!( - fee_ratio < 0.05, - "Rebalance fee should be less than 5% of received amount, got {:.2}%", - fee_ratio * 100.0 - ); + // confirm transaction + generate_blocks(&bitcoind, 6); - assert!(wallet.next_event().is_none()); + // check we received on-chain, should be pending + // wait for payment success + test_utils::wait_for_condition("pending balance to update", || async { + // onchain balance is always listed as pending until we splice it into the channel. + wallet.get_balance().await.unwrap().pending_balance == recv_amt }) + .await; + + let event = wait_next_event(&wallet).await; + + match event { + Event::OnchainPaymentReceived { txid, amount_sat, status, .. } => { + assert_eq!(txid, sent_txid); + assert_eq!(amount_sat, recv_amt.sats().unwrap()); + assert!(matches!(status, ConfirmationStatus::Confirmed { .. })); + }, + _ => panic!("Expected OnchainPaymentReceived event"), + } + + assert!(wallet.next_event().is_none()); + + let txs = wallet.list_transactions().await.unwrap(); + assert_eq!(txs.len(), 1); + let tx = txs.into_iter().next().unwrap(); + + // Comprehensive validation for on-chain receive + assert!(!tx.outbound, "Incoming payment should not be outbound"); + assert_eq!(tx.status, TxStatus::Completed, "Payment should be completed"); + assert_eq!( + tx.payment_type, + PaymentType::IncomingOnChain { txid: Some(sent_txid) }, + "Payment type should be IncomingOnChain with correct txid" + ); + assert_ne!(tx.time_since_epoch, Duration::ZERO, "Time should be set"); + assert_eq!(tx.amount, Some(recv_amt), "Amount should equal received amount"); + assert_eq!( + tx.fee, + Some(Amount::ZERO), + "On-chain receive should have zero fees (paid by sender)" + ); + + // a rebalance should be initiated, we need to mine the channel opening transaction + // for it to be confirmed and reflected in the wallet's history + generate_blocks(&bitcoind, 6); + tokio::time::sleep(Duration::from_secs(5)).await; // wait for sync + generate_blocks(&bitcoind, 6); // confirm the channel opening transaction + tokio::time::sleep(Duration::from_secs(5)).await; // wait for sync + + // wait for rebalance to be initiated + let event = wait_next_event(&wallet).await; + match event { + Event::ChannelOpened { counterparty_node_id, .. } => { + assert_eq!(counterparty_node_id, lsp.node_id()); + }, + _ => panic!("Expected ChannelOpened event"), + } + + let txs = wallet.list_transactions().await.unwrap(); + assert_eq!(txs.len(), 1); + let tx = txs.into_iter().next().unwrap(); + + // Comprehensive validation for on-chain receive after rebalance + assert!(!tx.outbound, "Incoming payment should not be outbound"); + assert_eq!(tx.status, TxStatus::Completed, "Payment should be completed"); + assert_eq!( + tx.payment_type, + PaymentType::IncomingOnChain { txid: Some(sent_txid) }, + "Payment type should be IncomingOnChain with correct txid" + ); + assert_ne!(tx.time_since_epoch, Duration::ZERO, "Time should be set"); + assert_eq!(tx.amount, Some(recv_amt), "Amount should equal received amount"); + assert!( + tx.fee.unwrap() > Amount::ZERO, + "On-chain receive should have rebalance fees after channel opening" + ); + + // Validate fee is reasonable (should be less than 5% of received amount for rebalance) + let fee_ratio = tx.fee.unwrap().milli_sats() as f64 / recv_amt.milli_sats() as f64; + assert!( + fee_ratio < 0.05, + "Rebalance fee should be less than 5% of received amount, got {:.2}%", + fee_ratio * 100.0 + ); + + assert!(wallet.next_event().is_none()); } -fn run_test_pay_lightning_from_self_custody(amountless: bool) { - let TestParams { wallet, bitcoind, third_party, rt, .. } = build_test_nodes(); - - rt.block_on(async move { - // get a channel so we can make a payment - open_channel_from_lsp(&wallet, Arc::clone(&third_party)).await; - - // wait for sync - generate_blocks(&bitcoind, 6); - test_utils::wait_for_condition("wallet sync after channel open", || async { - wallet.channels().iter().any(|a| a.confirmations.is_some_and(|c| c > 0) && a.is_usable) - && third_party - .list_channels() - .iter() - .any(|a| a.confirmations.is_some_and(|c| c > 0) && a.is_usable) - }) - .await; - - let starting_bal = wallet.get_balance().await.unwrap(); - - let amount = Amount::from_sats(1_000).unwrap(); - - // get invoice from third party node - let desc = Bolt11InvoiceDescription::Direct(Description::empty()); - let invoice = if amountless { - third_party.bolt11_payment().receive_variable_amount(&desc, 300).unwrap() - } else { - third_party.bolt11_payment().receive(amount.milli_sats(), &desc, 300).unwrap() - }; - - let instr = wallet.parse_payment_instructions(invoice.to_string().as_str()).await.unwrap(); - let info = PaymentInfo::build(instr, Some(amount)).unwrap(); - wallet.pay(&info).await.unwrap(); - - let event = wait_next_event(&wallet).await; - assert!( - matches!(event, Event::PaymentSuccessful { .. }), - "Expected PaymentSuccessful event but got {event:?}" - ); - assert_eq!(wallet.next_event(), None); - - // check the payment is correct - let payments = wallet.list_transactions().await.unwrap(); - let payment = payments.into_iter().find(|p| p.outbound).unwrap(); - - // Comprehensive validation for outgoing lightning bolt11 payment - assert_eq!(payment.amount, Some(amount), "Amount should equal sent amount"); - assert!( - payment.fee.is_some_and(|f| f > Amount::ZERO), - "Lightning payment should have non-zero fees" - ); - assert!(payment.outbound, "Outgoing payment should be outbound"); - assert!( - matches!(payment.payment_type, PaymentType::OutgoingLightningBolt11 { .. }), - "Payment type should be OutgoingLightningBolt11" - ); - assert_eq!(payment.status, TxStatus::Completed, "Payment should be completed"); - assert_ne!(payment.time_since_epoch, Duration::ZERO, "Time should be set"); - match payment.payment_type { - PaymentType::OutgoingLightningBolt11 { payment_preimage } => { - assert!( - payment_preimage.is_some_and(|p| p.0 != [0; 32]), - "Completed payment should have payment_preimage" - ); - }, - pt => panic!("Payment type should be OutgoingLightningBolt11, got {pt:?}"), - } - - // Validate fee is reasonable - let fee_ratio = payment.fee.unwrap().milli_sats() as f64 / amount.milli_sats() as f64; - assert!( - fee_ratio < 0.1, - "Fee should be less than 10% of sent amount, got {:.2}%", - fee_ratio * 100.0 - ); - - // Check that payment_type contains payment_preimage for completed payments - if let PaymentType::OutgoingLightningBolt11 { payment_preimage } = &payment.payment_type { - assert!(payment_preimage.is_some(), "Completed payment should have payment_preimage"); - } +async fn run_test_pay_lightning_from_self_custody(amountless: bool) { + let TestParams { wallet, bitcoind, third_party, .. } = build_test_nodes().await; - // check balance left our wallet - let bal = wallet.get_balance().await.unwrap(); - assert_eq!(bal.pending_balance, Amount::ZERO); - assert!(bal.available_balance() <= starting_bal.available_balance().saturating_sub(amount)); + // get a channel so we can make a payment + open_channel_from_lsp(&wallet, Arc::clone(&third_party)).await; - // make sure 3rd party node got payment - let payments = third_party.list_payments(); - assert!(payments.iter().any(|p| p.status == PaymentStatus::Succeeded - && p.direction == PaymentDirection::Inbound - && p.amount_msat == Some(amount.milli_sats()))); + // wait for sync + generate_blocks(&bitcoind, 6); + test_utils::wait_for_condition("wallet sync after channel open", || async { + wallet.channels().iter().any(|a| a.confirmations.is_some_and(|c| c > 0) && a.is_usable) + && third_party + .list_channels() + .iter() + .any(|a| a.confirmations.is_some_and(|c| c > 0) && a.is_usable) }) + .await; + + let starting_bal = wallet.get_balance().await.unwrap(); + + let amount = Amount::from_sats(1_000).unwrap(); + + // get invoice from third party node + let desc = Bolt11InvoiceDescription::Direct(Description::empty()); + let invoice = if amountless { + third_party.bolt11_payment().receive_variable_amount(&desc, 300).unwrap() + } else { + third_party.bolt11_payment().receive(amount.milli_sats(), &desc, 300).unwrap() + }; + + let instr = wallet.parse_payment_instructions(invoice.to_string().as_str()).await.unwrap(); + let info = PaymentInfo::build(instr, Some(amount)).unwrap(); + wallet.pay(&info).await.unwrap(); + + let event = wait_next_event(&wallet).await; + assert!( + matches!(event, Event::PaymentSuccessful { .. }), + "Expected PaymentSuccessful event but got {event:?}" + ); + assert_eq!(wallet.next_event(), None); + + // check the payment is correct + let payments = wallet.list_transactions().await.unwrap(); + let payment = payments.into_iter().find(|p| p.outbound).unwrap(); + + // Comprehensive validation for outgoing lightning bolt11 payment + assert_eq!(payment.amount, Some(amount), "Amount should equal sent amount"); + assert!( + payment.fee.is_some_and(|f| f > Amount::ZERO), + "Lightning payment should have non-zero fees" + ); + assert!(payment.outbound, "Outgoing payment should be outbound"); + assert!( + matches!(payment.payment_type, PaymentType::OutgoingLightningBolt11 { .. }), + "Payment type should be OutgoingLightningBolt11" + ); + assert_eq!(payment.status, TxStatus::Completed, "Payment should be completed"); + assert_ne!(payment.time_since_epoch, Duration::ZERO, "Time should be set"); + match payment.payment_type { + PaymentType::OutgoingLightningBolt11 { payment_preimage } => { + assert!( + payment_preimage.is_some_and(|p| p.0 != [0; 32]), + "Completed payment should have payment_preimage" + ); + }, + pt => panic!("Payment type should be OutgoingLightningBolt11, got {pt:?}"), + } + + // Validate fee is reasonable + let fee_ratio = payment.fee.unwrap().milli_sats() as f64 / amount.milli_sats() as f64; + assert!( + fee_ratio < 0.1, + "Fee should be less than 10% of sent amount, got {:.2}%", + fee_ratio * 100.0 + ); + + // Check that payment_type contains payment_preimage for completed payments + if let PaymentType::OutgoingLightningBolt11 { payment_preimage } = &payment.payment_type { + assert!(payment_preimage.is_some(), "Completed payment should have payment_preimage"); + } + + // check balance left our wallet + let bal = wallet.get_balance().await.unwrap(); + assert_eq!(bal.pending_balance, Amount::ZERO); + assert!(bal.available_balance() <= starting_bal.available_balance().saturating_sub(amount)); + + // make sure 3rd party node got payment + let payments = third_party.list_payments(); + assert!(payments.iter().any(|p| p.status == PaymentStatus::Succeeded + && p.direction == PaymentDirection::Inbound + && p.amount_msat == Some(amount.milli_sats()))); } -#[test] -fn test_pay_lightning_from_self_custody() { - run_test_pay_lightning_from_self_custody(false); - run_test_pay_lightning_from_self_custody(true); +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_pay_lightning_from_self_custody() { + run_test_pay_lightning_from_self_custody(false).await; + run_test_pay_lightning_from_self_custody(true).await; } -fn run_test_pay_bolt12_from_self_custody(amountless: bool) { - let TestParams { wallet, bitcoind, third_party, rt, .. } = build_test_nodes(); - - rt.block_on(async move { - // get a channel so we can make a payment - open_channel_from_lsp(&wallet, Arc::clone(&third_party)).await; - - // wait for sync - generate_blocks(&bitcoind, 6); - test_utils::wait_for_condition("wallet sync after channel open", || async { - wallet.channels().iter().any(|a| a.confirmations.is_some_and(|c| c > 0) && a.is_usable) - && third_party - .list_channels() - .iter() - .any(|a| a.confirmations.is_some_and(|c| c > 0) && a.is_usable) - }) - .await; - - let starting_bal = wallet.get_balance().await.unwrap(); - - let amount = Amount::from_sats(1_000).unwrap(); +async fn run_test_pay_bolt12_from_self_custody(amountless: bool) { + let TestParams { wallet, bitcoind, third_party, .. } = build_test_nodes().await; - // get offer from third party node - let offer = if amountless { - third_party.bolt12_payment().receive_variable_amount("test", None).unwrap() - } else { - third_party.bolt12_payment().receive(amount.milli_sats(), "test", None, None).unwrap() - }; - - let instr = wallet.parse_payment_instructions(offer.to_string().as_str()).await.unwrap(); - let info = PaymentInfo::build(instr, Some(amount)).unwrap(); - wallet.pay(&info).await.unwrap(); - - let event = wait_next_event(&wallet).await; - assert!( - matches!(event, Event::PaymentSuccessful { .. }), - "Expected PaymentSuccessful event but got {event:?}" - ); - assert_eq!(wallet.next_event(), None); - - // check the payment is correct - let payments = wallet.list_transactions().await.unwrap(); - let payment = payments.into_iter().find(|p| p.outbound).unwrap(); + // get a channel so we can make a payment + open_channel_from_lsp(&wallet, Arc::clone(&third_party)).await; - // Comprehensive validation for outgoing lightning bolt12 payment - assert_eq!(payment.amount, Some(amount), "Amount should equal sent amount"); - assert!( - payment.fee.is_some_and(|f| f > Amount::ZERO), - "Lightning payment should have non-zero fees" - ); - assert!(payment.outbound, "Outgoing payment should be outbound"); - assert!( - matches!(payment.payment_type, PaymentType::OutgoingLightningBolt12 { .. }), - "Payment type should be OutgoingLightningBolt12" - ); - assert_eq!(payment.status, TxStatus::Completed, "Payment should be completed"); - assert_ne!(payment.time_since_epoch, Duration::ZERO, "Time should be set"); - - // Validate fee is reasonable - let fee_ratio = payment.fee.unwrap().milli_sats() as f64 / amount.milli_sats() as f64; - assert!( - fee_ratio < 0.1, - "Fee should be less than 10% of sent amount, got {:.2}%", - fee_ratio * 100.0 - ); - - // Check that payment_type contains payment_preimage for completed payments - if let PaymentType::OutgoingLightningBolt12 { payment_preimage } = &payment.payment_type { - assert!(payment_preimage.is_some(), "Completed payment should have payment_preimage"); - } - - // check balance left our wallet - let bal = wallet.get_balance().await.unwrap(); - assert_eq!(bal.pending_balance, Amount::ZERO); - assert!(bal.available_balance() <= starting_bal.available_balance().saturating_sub(amount)); - - // make sure 3rd party node got payment - let payments = third_party.list_payments(); - assert!(payments.iter().any(|p| p.status == PaymentStatus::Succeeded - && p.direction == PaymentDirection::Inbound - && p.amount_msat == Some(amount.milli_sats()))); + // wait for sync + generate_blocks(&bitcoind, 6); + test_utils::wait_for_condition("wallet sync after channel open", || async { + wallet.channels().iter().any(|a| a.confirmations.is_some_and(|c| c > 0) && a.is_usable) + && third_party + .list_channels() + .iter() + .any(|a| a.confirmations.is_some_and(|c| c > 0) && a.is_usable) }) + .await; + + let starting_bal = wallet.get_balance().await.unwrap(); + + let amount = Amount::from_sats(1_000).unwrap(); + + // get offer from third party node + let offer = if amountless { + third_party.bolt12_payment().receive_variable_amount("test", None).unwrap() + } else { + third_party.bolt12_payment().receive(amount.milli_sats(), "test", None, None).unwrap() + }; + + let instr = wallet.parse_payment_instructions(offer.to_string().as_str()).await.unwrap(); + let info = PaymentInfo::build(instr, Some(amount)).unwrap(); + wallet.pay(&info).await.unwrap(); + + let event = wait_next_event(&wallet).await; + assert!( + matches!(event, Event::PaymentSuccessful { .. }), + "Expected PaymentSuccessful event but got {event:?}" + ); + assert_eq!(wallet.next_event(), None); + + // check the payment is correct + let payments = wallet.list_transactions().await.unwrap(); + let payment = payments.into_iter().find(|p| p.outbound).unwrap(); + + // Comprehensive validation for outgoing lightning bolt12 payment + assert_eq!(payment.amount, Some(amount), "Amount should equal sent amount"); + assert!( + payment.fee.is_some_and(|f| f > Amount::ZERO), + "Lightning payment should have non-zero fees" + ); + assert!(payment.outbound, "Outgoing payment should be outbound"); + assert!( + matches!(payment.payment_type, PaymentType::OutgoingLightningBolt12 { .. }), + "Payment type should be OutgoingLightningBolt12" + ); + assert_eq!(payment.status, TxStatus::Completed, "Payment should be completed"); + assert_ne!(payment.time_since_epoch, Duration::ZERO, "Time should be set"); + + // Validate fee is reasonable + let fee_ratio = payment.fee.unwrap().milli_sats() as f64 / amount.milli_sats() as f64; + assert!( + fee_ratio < 0.1, + "Fee should be less than 10% of sent amount, got {:.2}%", + fee_ratio * 100.0 + ); + + // Check that payment_type contains payment_preimage for completed payments + if let PaymentType::OutgoingLightningBolt12 { payment_preimage } = &payment.payment_type { + assert!(payment_preimage.is_some(), "Completed payment should have payment_preimage"); + } + + // check balance left our wallet + let bal = wallet.get_balance().await.unwrap(); + assert_eq!(bal.pending_balance, Amount::ZERO); + assert!(bal.available_balance() <= starting_bal.available_balance().saturating_sub(amount)); + + // make sure 3rd party node got payment + let payments = third_party.list_payments(); + assert!(payments.iter().any(|p| p.status == PaymentStatus::Succeeded + && p.direction == PaymentDirection::Inbound + && p.amount_msat == Some(amount.milli_sats()))); } -#[test] -fn test_pay_bolt12_from_self_custody() { - run_test_pay_bolt12_from_self_custody(false); - run_test_pay_bolt12_from_self_custody(true); +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_pay_bolt12_from_self_custody() { + run_test_pay_bolt12_from_self_custody(false).await; + run_test_pay_bolt12_from_self_custody(true).await; } -#[test] -fn test_pay_onchain_from_self_custody() { - let TestParams { wallet, bitcoind, third_party, rt, .. } = build_test_nodes(); - - rt.block_on(async move { - // disable rebalancing so we have on-chain funds - wallet.set_rebalance_enabled(false); - - let starting_bal = wallet.get_balance().await.unwrap(); - assert_eq!(starting_bal.available_balance(), Amount::ZERO); - assert_eq!(starting_bal.pending_balance, Amount::ZERO); - - // fund wallet with on-chain - let recv_amount = Amount::from_sats(1_000_000).unwrap(); - let uri = wallet.get_single_use_receive_uri(Some(recv_amount)).await.unwrap(); - bitcoind - .client - .send_to_address( - &uri.address.unwrap(), - ldk_node::bitcoin::Amount::from_sat(recv_amount.sats().unwrap()), - ) - .unwrap(); +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_pay_onchain_from_self_custody() { + let TestParams { wallet, bitcoind, third_party, .. } = build_test_nodes().await; + + // disable rebalancing so we have on-chain funds + wallet.set_rebalance_enabled(false); + + let starting_bal = wallet.get_balance().await.unwrap(); + assert_eq!(starting_bal.available_balance(), Amount::ZERO); + assert_eq!(starting_bal.pending_balance, Amount::ZERO); + + // fund wallet with on-chain + let recv_amount = Amount::from_sats(1_000_000).unwrap(); + let uri = wallet.get_single_use_receive_uri(Some(recv_amount)).await.unwrap(); + bitcoind + .client + .send_to_address( + &uri.address.unwrap(), + ldk_node::bitcoin::Amount::from_sat(recv_amount.sats().unwrap()), + ) + .unwrap(); - // confirm tx - generate_blocks(&bitcoind, 6); + // confirm tx + generate_blocks(&bitcoind, 6); - // wait for node to sync and see the balance update - test_utils::wait_for_condition("wallet sync after on-chain receive", || async { - wallet.get_balance().await.unwrap().pending_balance > starting_bal.pending_balance - }) - .await; - - // get address from third party node - let addr = third_party.onchain_payment().new_address().unwrap(); - let send_amount = Amount::from_sats(100_000).unwrap(); - - let instr = wallet.parse_payment_instructions(addr.to_string().as_str()).await.unwrap(); - let info = PaymentInfo::build(instr, Some(send_amount)).unwrap(); - wallet.pay(&info).await.unwrap(); - - // confirm the tx - generate_blocks(&bitcoind, 6); - - // wait for payment to complete - test_utils::wait_for_condition("on-chain payment completion", || async { - let payments = wallet.list_transactions().await.unwrap(); - let payment = payments.into_iter().find(|p| p.outbound); - if payment.as_ref().is_some_and(|p| p.status == TxStatus::Failed) { - panic!("Payment failed"); - } - payment.is_some_and(|p| p.status == TxStatus::Completed) - }) - .await; + // wait for node to sync and see the balance update + test_utils::wait_for_condition("wallet sync after on-chain receive", || async { + wallet.get_balance().await.unwrap().pending_balance > starting_bal.pending_balance + }) + .await; - // check the payment is correct - let payments = wallet.list_transactions().await.unwrap(); - let payment = payments.into_iter().find(|p| p.outbound).unwrap(); + // get address from third party node + let addr = third_party.onchain_payment().new_address().unwrap(); + let send_amount = Amount::from_sats(100_000).unwrap(); - // Comprehensive validation for outgoing on-chain payment - assert_eq!(payment.amount, Some(send_amount), "Amount should equal sent amount"); - assert!( - payment.fee.is_some_and(|f| f > Amount::ZERO), - "On-chain payment should have non-zero fees" - ); - assert!(payment.outbound, "Outgoing payment should be outbound"); - assert!( - matches!(payment.payment_type, PaymentType::OutgoingOnChain { .. }), - "Payment type should be OutgoingOnChain" - ); - assert_eq!(payment.status, TxStatus::Completed, "Payment should be completed"); - assert_ne!(payment.time_since_epoch, Duration::ZERO, "Time should be set"); + let instr = wallet.parse_payment_instructions(addr.to_string().as_str()).await.unwrap(); + let info = PaymentInfo::build(instr, Some(send_amount)).unwrap(); + wallet.pay(&info).await.unwrap(); - // Validate fee is reasonable for on-chain (should be less than 1% of sent amount) - let fee_ratio = payment.fee.unwrap().milli_sats() as f64 / send_amount.milli_sats() as f64; - assert!( - fee_ratio < 0.01, - "On-chain fee should be less than 1% of sent amount, got {:.2}%", - fee_ratio * 100.0 - ); + // confirm the tx + generate_blocks(&bitcoind, 6); - // Check that payment_type contains txid for completed payments - if let PaymentType::OutgoingOnChain { txid } = &payment.payment_type { - assert!(txid.is_some(), "Completed on-chain payment should have txid"); + // wait for payment to complete + test_utils::wait_for_condition("on-chain payment completion", || async { + let payments = wallet.list_transactions().await.unwrap(); + let payment = payments.into_iter().find(|p| p.outbound); + if payment.as_ref().is_some_and(|p| p.status == TxStatus::Failed) { + panic!("Payment failed"); } - - // check balance left our wallet - let bal = wallet.get_balance().await.unwrap(); - assert_eq!( - bal.pending_balance, - recv_amount.saturating_sub(send_amount).saturating_sub(payment.fee.unwrap()) - ); - - // Wait for third party node to receive it - test_utils::wait_for_condition("on-chain payment received", || async { - let payments = third_party.list_payments(); - payments.iter().any(|p| { - p.status == PaymentStatus::Succeeded - && p.direction == PaymentDirection::Inbound - && p.amount_msat == Some(send_amount.milli_sats()) - }) + payment.is_some_and(|p| p.status == TxStatus::Completed) + }) + .await; + + // check the payment is correct + let payments = wallet.list_transactions().await.unwrap(); + let payment = payments.into_iter().find(|p| p.outbound).unwrap(); + + // Comprehensive validation for outgoing on-chain payment + assert_eq!(payment.amount, Some(send_amount), "Amount should equal sent amount"); + assert!( + payment.fee.is_some_and(|f| f > Amount::ZERO), + "On-chain payment should have non-zero fees" + ); + assert!(payment.outbound, "Outgoing payment should be outbound"); + assert!( + matches!(payment.payment_type, PaymentType::OutgoingOnChain { .. }), + "Payment type should be OutgoingOnChain" + ); + assert_eq!(payment.status, TxStatus::Completed, "Payment should be completed"); + assert_ne!(payment.time_since_epoch, Duration::ZERO, "Time should be set"); + + // Validate fee is reasonable for on-chain (should be less than 1% of sent amount) + let fee_ratio = payment.fee.unwrap().milli_sats() as f64 / send_amount.milli_sats() as f64; + assert!( + fee_ratio < 0.01, + "On-chain fee should be less than 1% of sent amount, got {:.2}%", + fee_ratio * 100.0 + ); + + // Check that payment_type contains txid for completed payments + if let PaymentType::OutgoingOnChain { txid } = &payment.payment_type { + assert!(txid.is_some(), "Completed on-chain payment should have txid"); + } + + // check balance left our wallet + let bal = wallet.get_balance().await.unwrap(); + assert_eq!( + bal.pending_balance, + recv_amount.saturating_sub(send_amount).saturating_sub(payment.fee.unwrap()) + ); + + // Wait for third party node to receive it + test_utils::wait_for_condition("on-chain payment received", || async { + let payments = third_party.list_payments(); + payments.iter().any(|p| { + p.status == PaymentStatus::Succeeded + && p.direction == PaymentDirection::Inbound + && p.amount_msat == Some(send_amount.milli_sats()) }) - .await; }) + .await; } -#[test] -fn test_force_close_handling() { - let TestParams { wallet, lsp, bitcoind, third_party, rt, .. } = build_test_nodes(); +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_force_close_handling() { + let TestParams { wallet, lsp, bitcoind, third_party, .. } = build_test_nodes().await; - rt.block_on(async move { - let starting_bal = wallet.get_balance().await.unwrap(); - assert_eq!(starting_bal.available_balance(), Amount::ZERO); - assert_eq!(starting_bal.pending_balance, Amount::ZERO); + let starting_bal = wallet.get_balance().await.unwrap(); + assert_eq!(starting_bal.available_balance(), Amount::ZERO); + assert_eq!(starting_bal.pending_balance, Amount::ZERO); - let rebalancing = wallet.get_rebalance_enabled(); - assert!(rebalancing); + let rebalancing = wallet.get_rebalance_enabled(); + assert!(rebalancing); - // get a channel so we can make a payment - open_channel_from_lsp(&wallet, Arc::clone(&third_party)).await; + // get a channel so we can make a payment + open_channel_from_lsp(&wallet, Arc::clone(&third_party)).await; - // mine some blocks to ensure the channel is confirmed - generate_blocks(&bitcoind, 6); - - // get channel details - let channel = lsp - .list_channels() - .into_iter() - .find(|c| c.counterparty_node_id == wallet.node_id()) - .unwrap(); + // mine some blocks to ensure the channel is confirmed + generate_blocks(&bitcoind, 6); - // force close the channel - lsp.force_close_channel(&channel.user_channel_id, channel.counterparty_node_id, None) - .unwrap(); - - // wait for the channel to be closed - let event = wait_next_event(&wallet).await; - match event { - Event::ChannelClosed { counterparty_node_id, .. } => { - assert_eq!(counterparty_node_id, lsp.node_id()); - }, - _ => panic!("Expected ChannelClosed event"), - } + // get channel details + let channel = lsp + .list_channels() + .into_iter() + .find(|c| c.counterparty_node_id == wallet.node_id()) + .unwrap(); - // rebalancing should be disabled after a force close - let rebalancing = wallet.get_rebalance_enabled(); - assert!(!rebalancing); - }) + // force close the channel + lsp.force_close_channel(&channel.user_channel_id, channel.counterparty_node_id, None).unwrap(); + + // wait for the channel to be closed + let event = wait_next_event(&wallet).await; + match event { + Event::ChannelClosed { counterparty_node_id, .. } => { + assert_eq!(counterparty_node_id, lsp.node_id()); + }, + _ => panic!("Expected ChannelClosed event"), + } + + // rebalancing should be disabled after a force close + let rebalancing = wallet.get_rebalance_enabled(); + assert!(!rebalancing); } -#[test] -fn test_close_all_channels() { - let TestParams { wallet, lsp, bitcoind, third_party, rt, .. } = build_test_nodes(); +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_close_all_channels() { + let TestParams { wallet, lsp, bitcoind, third_party, .. } = build_test_nodes().await; - rt.block_on(async move { - let starting_bal = wallet.get_balance().await.unwrap(); - assert_eq!(starting_bal.available_balance(), Amount::ZERO); - assert_eq!(starting_bal.pending_balance, Amount::ZERO); + let starting_bal = wallet.get_balance().await.unwrap(); + assert_eq!(starting_bal.available_balance(), Amount::ZERO); + assert_eq!(starting_bal.pending_balance, Amount::ZERO); - let rebalancing = wallet.get_rebalance_enabled(); - assert!(rebalancing); + let rebalancing = wallet.get_rebalance_enabled(); + assert!(rebalancing); - // get a channel so we can make a payment - open_channel_from_lsp(&wallet, Arc::clone(&third_party)).await; + // get a channel so we can make a payment + open_channel_from_lsp(&wallet, Arc::clone(&third_party)).await; - // mine some blocks to ensure the channel is confirmed - generate_blocks(&bitcoind, 6); + // mine some blocks to ensure the channel is confirmed + generate_blocks(&bitcoind, 6); - // init closing all channels - wallet.close_channels().unwrap(); + // init closing all channels + wallet.close_channels().unwrap(); - // wait for the channels to be closed - let event = wait_next_event(&wallet).await; - match event { - Event::ChannelClosed { counterparty_node_id, .. } => { - assert_eq!(counterparty_node_id, lsp.node_id()); - }, - _ => panic!("Expected ChannelClosed event"), - } + // wait for the channels to be closed + let event = wait_next_event(&wallet).await; + match event { + Event::ChannelClosed { counterparty_node_id, .. } => { + assert_eq!(counterparty_node_id, lsp.node_id()); + }, + _ => panic!("Expected ChannelClosed event"), + } - // rebalancing should be disabled after closing all channels - let rebalancing = wallet.get_rebalance_enabled(); - assert!(!rebalancing); - }) + // rebalancing should be disabled after closing all channels + let rebalancing = wallet.get_rebalance_enabled(); + assert!(!rebalancing); } -#[test] -fn test_threshold_boundary_trusted_balance_limit() { - let TestParams { wallet, third_party, rt, .. } = build_test_nodes(); - - rt.block_on(async move { - // we're not testing rebalancing here, so disable it to keep things simple - // on slow CI this can cause tests to fail if rebalancing kicks in - wallet.set_rebalance_enabled(false); - - let tunables = wallet.get_tunables(); +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_threshold_boundary_trusted_balance_limit() { + let TestParams { wallet, third_party, .. } = build_test_nodes().await; - // Test 1: Payment exactly at the trusted balance limit should use trusted wallet - let exact_limit_amount = tunables.trusted_balance_limit; - let uri = wallet.get_single_use_receive_uri(Some(exact_limit_amount)).await.unwrap(); - third_party.bolt11_payment().send(&uri.invoice, None).unwrap(); + // we're not testing rebalancing here, so disable it to keep things simple + // on slow CI this can cause tests to fail if rebalancing kicks in + wallet.set_rebalance_enabled(false); - test_utils::wait_for_condition("exact limit payment", || async { - wallet.get_balance().await.unwrap().available_balance() >= exact_limit_amount - }) - .await; + let tunables = wallet.get_tunables(); - // receive to trusted wallet - let event = wait_next_event(&wallet).await; - assert!( - matches!(event, Event::PaymentReceived { .. }), - "Expected PaymentReceived event but got {event:?}" - ); + // Test 1: Payment exactly at the trusted balance limit should use trusted wallet + let exact_limit_amount = tunables.trusted_balance_limit; + let uri = wallet.get_single_use_receive_uri(Some(exact_limit_amount)).await.unwrap(); + third_party.bolt11_payment().send(&uri.invoice, None).unwrap(); - let txs = wallet.list_transactions().await.unwrap(); - assert_eq!(txs.len(), 1); - let tx = &txs[0]; - assert_eq!(tx.payment_type, PaymentType::IncomingLightning {}); - assert_eq!( - tx.fee, - Some(Amount::ZERO), - "Payment at exact limit should use trusted wallet with zero fees" - ); + test_utils::wait_for_condition("exact limit payment", || async { + wallet.get_balance().await.unwrap().available_balance() >= exact_limit_amount + }) + .await; + + // receive to trusted wallet + let event = wait_next_event(&wallet).await; + assert!( + matches!(event, Event::PaymentReceived { .. }), + "Expected PaymentReceived event but got {event:?}" + ); + + let txs = wallet.list_transactions().await.unwrap(); + assert_eq!(txs.len(), 1); + let tx = &txs[0]; + assert_eq!(tx.payment_type, PaymentType::IncomingLightning {}); + assert_eq!( + tx.fee, + Some(Amount::ZERO), + "Payment at exact limit should use trusted wallet with zero fees" + ); + + // Test 2: Payment 1 sat above the limit should trigger Lightning channel + let above_limit_amount = exact_limit_amount.saturating_add(Amount::from_sats(1).unwrap()); + let uri = wallet.get_single_use_receive_uri(Some(above_limit_amount)).await.unwrap(); + third_party.bolt11_payment().send(&uri.invoice, None).unwrap(); + + // Wait for channel to be opened and payment to complete + test_utils::wait_for_condition("above limit payment with channel", || async { + let balance = wallet.get_balance().await.unwrap().available_balance(); + balance + >= exact_limit_amount.saturating_add( + above_limit_amount.saturating_sub(Amount::from_sats(50000).unwrap()), + ) + }) + .await; + + // Should have received a ChannelOpened event + let event = wait_next_event(&wallet).await; + match event { + Event::ChannelOpened { .. } => {}, + e => panic!("Expected ChannelOpened event, got {e:?}"), + } + + let event = wait_next_event(&wallet).await; + match event { + Event::PaymentReceived { amount_msat, lsp_fee_msats, .. } => { + assert_eq!(amount_msat + lsp_fee_msats.unwrap_or(0), above_limit_amount.milli_sats()); + }, + e => panic!("Expected PaymentReceived event, got {e:?}"), + } + + let txs = wallet.list_transactions().await.unwrap(); + let lightning_tx = txs.iter().find(|tx| tx.fee.is_some_and(|f| f > Amount::ZERO)).unwrap(); + assert_eq!(lightning_tx.payment_type, PaymentType::IncomingLightning {}); + assert!( + lightning_tx.fee.unwrap() > Amount::ZERO, + "Payment above limit should use Lightning with fees" + ); +} - // Test 2: Payment 1 sat above the limit should trigger Lightning channel - let above_limit_amount = exact_limit_amount.saturating_add(Amount::from_sats(1).unwrap()); - let uri = wallet.get_single_use_receive_uri(Some(above_limit_amount)).await.unwrap(); - third_party.bolt11_payment().send(&uri.invoice, None).unwrap(); - - // Wait for channel to be opened and payment to complete - test_utils::wait_for_condition("above limit payment with channel", || async { - let balance = wallet.get_balance().await.unwrap().available_balance(); - balance - >= exact_limit_amount.saturating_add( - above_limit_amount.saturating_sub(Amount::from_sats(50000).unwrap()), - ) - }) - .await; +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_threshold_boundary_rebalance_min() { + let TestParams { wallet, third_party, .. } = build_test_nodes().await; - // Should have received a ChannelOpened event - let event = wait_next_event(&wallet).await; - match event { - Event::ChannelOpened { .. } => {}, - e => panic!("Expected ChannelOpened event, got {e:?}"), - } + let starting_bal = wallet.get_balance().await.unwrap(); + let tunables = wallet.get_tunables(); + let rebalance_min = tunables.rebalance_min; - let event = wait_next_event(&wallet).await; - match event { - Event::PaymentReceived { amount_msat, lsp_fee_msats, .. } => { - assert_eq!( - amount_msat + lsp_fee_msats.unwrap_or(0), - above_limit_amount.milli_sats() - ); - }, - e => panic!("Expected PaymentReceived event, got {e:?}"), - } + // Test 1: Payment below rebalance_min should use trusted wallet + let below_rebalance = rebalance_min.saturating_sub(Amount::from_sats(1).unwrap()); + let uri = wallet.get_single_use_receive_uri(Some(below_rebalance)).await.unwrap(); + third_party.bolt11_payment().send(&uri.invoice, None).unwrap(); - let txs = wallet.list_transactions().await.unwrap(); - let lightning_tx = txs.iter().find(|tx| tx.fee.is_some_and(|f| f > Amount::ZERO)).unwrap(); - assert_eq!(lightning_tx.payment_type, PaymentType::IncomingLightning {}); - assert!( - lightning_tx.fee.unwrap() > Amount::ZERO, - "Payment above limit should use Lightning with fees" - ); + test_utils::wait_for_condition("below rebalance min payment", || async { + wallet.get_balance().await.unwrap().available_balance() >= starting_bal.available_balance() }) -} - -#[test] -fn test_threshold_boundary_rebalance_min() { - let TestParams { wallet, third_party, rt, .. } = build_test_nodes(); + .await; - rt.block_on(async move { - let starting_bal = wallet.get_balance().await.unwrap(); - let tunables = wallet.get_tunables(); - let rebalance_min = tunables.rebalance_min; - - // Test 1: Payment below rebalance_min should use trusted wallet - let below_rebalance = rebalance_min.saturating_sub(Amount::from_sats(1).unwrap()); - let uri = wallet.get_single_use_receive_uri(Some(below_rebalance)).await.unwrap(); - third_party.bolt11_payment().send(&uri.invoice, None).unwrap(); + test_utils::wait_for_condition("wait for transaction", || async { + !wallet.list_transactions().await.unwrap().is_empty() + }) + .await; + + let txs = wallet.list_transactions().await.unwrap(); + assert_eq!(txs.len(), 1); + let tx = &txs[0]; + assert_eq!( + tx.fee, + Some(Amount::ZERO), + "Below rebalance_min should use trusted wallet with zero fees" + ); + assert_eq!(tx.payment_type, PaymentType::IncomingLightning {}); + + // Test 2: Payment exactly at rebalance_min should use trusted wallet + let exact_rebalance = rebalance_min; + let uri = wallet.get_single_use_receive_uri(Some(exact_rebalance)).await.unwrap(); + third_party.bolt11_payment().send(&uri.invoice, None).unwrap(); + + test_utils::wait_for_condition("exact rebalance min payment", || async { + let balance = wallet.get_balance().await.unwrap().available_balance(); + balance >= below_rebalance.saturating_add(exact_rebalance) + }) + .await; - test_utils::wait_for_condition("below rebalance min payment", || async { - wallet.get_balance().await.unwrap().available_balance() - >= starting_bal.available_balance() - }) - .await; + let txs = wallet.list_transactions().await.unwrap(); + assert!(txs.len() >= 2, "Should have at least 2 transactions (may include rebalance)"); - test_utils::wait_for_condition("wait for transaction", || async { - !wallet.list_transactions().await.unwrap().is_empty() - }) - .await; + // Count incoming lightning transactions (our test payments) + let incoming_txs: Vec<_> = txs + .iter() + .filter(|tx| !tx.outbound && tx.payment_type == PaymentType::IncomingLightning {}) + .collect(); + assert_eq!(incoming_txs.len(), 2, "Should have exactly 2 incoming payments"); - let txs = wallet.list_transactions().await.unwrap(); - assert_eq!(txs.len(), 1); - let tx = &txs[0]; + // Both incoming transactions should be trusted wallet transactions with zero fees + for tx in incoming_txs { assert_eq!( tx.fee, Some(Amount::ZERO), - "Below rebalance_min should use trusted wallet with zero fees" + "Payments at/below rebalance_min should use trusted wallet" ); assert_eq!(tx.payment_type, PaymentType::IncomingLightning {}); + } + + // Test 3: Verify that the rebalance logic respects the minimum threshold + // The total balance should still be below what would trigger Lightning usage + let total_balance = wallet.get_balance().await.unwrap().available_balance(); + assert!( + total_balance < tunables.trusted_balance_limit, + "Total balance should still be below trusted_balance_limit" + ); +} - // Test 2: Payment exactly at rebalance_min should use trusted wallet - let exact_rebalance = rebalance_min; - let uri = wallet.get_single_use_receive_uri(Some(exact_rebalance)).await.unwrap(); - third_party.bolt11_payment().send(&uri.invoice, None).unwrap(); - - test_utils::wait_for_condition("exact rebalance min payment", || async { - let balance = wallet.get_balance().await.unwrap().available_balance(); - balance >= below_rebalance.saturating_add(exact_rebalance) - }) - .await; - - let txs = wallet.list_transactions().await.unwrap(); - assert!(txs.len() >= 2, "Should have at least 2 transactions (may include rebalance)"); - - // Count incoming lightning transactions (our test payments) - let incoming_txs: Vec<_> = txs - .iter() - .filter(|tx| !tx.outbound && tx.payment_type == PaymentType::IncomingLightning {}) - .collect(); - assert_eq!(incoming_txs.len(), 2, "Should have exactly 2 incoming payments"); - - // Both incoming transactions should be trusted wallet transactions with zero fees - for tx in incoming_txs { - assert_eq!( - tx.fee, - Some(Amount::ZERO), - "Payments at/below rebalance_min should use trusted wallet" +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_threshold_boundary_onchain_receive_threshold() { + let TestParams { wallet, .. } = build_test_nodes().await; + + let tunables = wallet.get_tunables(); + let onchain_threshold = tunables.onchain_receive_threshold; + + // Test 1: Amount below onchain_receive_threshold should not include on-chain address + let below_threshold = onchain_threshold.saturating_sub(Amount::from_sats(1).unwrap()); + let uri = wallet.get_single_use_receive_uri(Some(below_threshold)).await.unwrap(); + + assert!( + uri.address.is_none(), + "Payment below onchain threshold should not include on-chain address" + ); + assert!(uri.invoice.amount_milli_satoshis().is_some(), "Should include Lightning invoice"); + + // Test 2: Amount exactly at onchain_receive_threshold should include on-chain address + let exact_threshold = onchain_threshold; + let uri = wallet.get_single_use_receive_uri(Some(exact_threshold)).await.unwrap(); + + assert!( + uri.address.is_some(), + "Payment at exact onchain threshold should include on-chain address" + ); + assert!(uri.invoice.amount_milli_satoshis().is_some(), "Should also include Lightning invoice"); + + // Test 3: Amount above onchain_receive_threshold should include on-chain address + let above_threshold = onchain_threshold.saturating_add(Amount::from_sats(1000).unwrap()); + let uri = wallet.get_single_use_receive_uri(Some(above_threshold)).await.unwrap(); + + assert!( + uri.address.is_some(), + "Payment above onchain threshold should include on-chain address" + ); + assert!(uri.invoice.amount_milli_satoshis().is_some(), "Should also include Lightning invoice"); + + // Test 4: Amountless receive behavior with enable_amountless_receive_on_chain + #[cfg(not(feature = "_cashu-tests"))] // Cashu does not support amountless invoices + { + let uri = wallet.get_single_use_receive_uri(None).await.unwrap(); + + if tunables.enable_amountless_receive_on_chain { + assert!( + uri.address.is_some(), + "Amountless receive should include on-chain address when enabled" + ); + } else { + assert!( + uri.address.is_none(), + "Amountless receive should not include on-chain address when disabled" ); - assert_eq!(tx.payment_type, PaymentType::IncomingLightning {}); } - - // Test 3: Verify that the rebalance logic respects the minimum threshold - // The total balance should still be below what would trigger Lightning usage - let total_balance = wallet.get_balance().await.unwrap().available_balance(); assert!( - total_balance < tunables.trusted_balance_limit, - "Total balance should still be below trusted_balance_limit" + uri.invoice.amount_milli_satoshis().is_none(), + "Amountless invoice should have no fixed amount" ); - }) + } } -#[test] -fn test_threshold_boundary_onchain_receive_threshold() { - let TestParams { wallet, rt, .. } = build_test_nodes(); +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_threshold_combinations_and_edge_cases() { + let TestParams { wallet, .. } = build_test_nodes().await; - rt.block_on(async move { - let tunables = wallet.get_tunables(); - let onchain_threshold = tunables.onchain_receive_threshold; + let tunables = wallet.get_tunables(); - // Test 1: Amount below onchain_receive_threshold should not include on-chain address - let below_threshold = onchain_threshold.saturating_sub(Amount::from_sats(1).unwrap()); - let uri = wallet.get_single_use_receive_uri(Some(below_threshold)).await.unwrap(); + // Test edge case: ensure thresholds are properly ordered + assert!( + tunables.rebalance_min <= tunables.trusted_balance_limit, + "rebalance_min should be <= trusted_balance_limit for proper wallet operation" + ); - assert!( - uri.address.is_none(), - "Payment below onchain threshold should not include on-chain address" - ); - assert!(uri.invoice.amount_milli_satoshis().is_some(), "Should include Lightning invoice"); + // Test minimum amount handling (1 sat) + let min_amount = Amount::from_sats(1).unwrap(); + let uri = wallet.get_single_use_receive_uri(Some(min_amount)).await.unwrap(); - // Test 2: Amount exactly at onchain_receive_threshold should include on-chain address - let exact_threshold = onchain_threshold; - let uri = wallet.get_single_use_receive_uri(Some(exact_threshold)).await.unwrap(); + assert!(uri.invoice.amount_milli_satoshis().is_some(), "Should handle minimum 1 sat amount"); + assert!(uri.address.is_none(), "1 sat should be below onchain threshold"); - assert!( - uri.address.is_some(), - "Payment at exact onchain threshold should include on-chain address" - ); - assert!( - uri.invoice.amount_milli_satoshis().is_some(), - "Should also include Lightning invoice" - ); + // Test large amount (but reasonable for testing) + let large_amount = Amount::from_sats(1_000_000).unwrap(); // 1 BTC - large but reasonable + let uri = wallet.get_single_use_receive_uri(Some(large_amount)).await.unwrap(); - // Test 3: Amount above onchain_receive_threshold should include on-chain address - let above_threshold = onchain_threshold.saturating_add(Amount::from_sats(1000).unwrap()); - let uri = wallet.get_single_use_receive_uri(Some(above_threshold)).await.unwrap(); + assert!(uri.invoice.amount_milli_satoshis().is_some(), "Should handle large amounts"); + assert!(uri.address.is_some(), "Large amount should include on-chain address"); + // Test zero amount (should be handled by amountless logic) + #[cfg(not(feature = "_cashu-tests"))] // Cashu does not support amountless invoices + { + let uri = wallet.get_single_use_receive_uri(None).await.unwrap(); assert!( - uri.address.is_some(), - "Payment above onchain threshold should include on-chain address" - ); + uri.invoice.amount_milli_satoshis().is_none(), + "Amountless invoice should have no amount" + ); + } + + // Verify the wallet can handle payments at multiple threshold boundaries + let test_amounts = [ + tunables.rebalance_min.saturating_sub(Amount::from_sats(1).unwrap()), + tunables.rebalance_min, + tunables.rebalance_min.saturating_add(Amount::from_sats(1).unwrap()), + tunables.onchain_receive_threshold.saturating_sub(Amount::from_sats(1).unwrap()), + tunables.onchain_receive_threshold, + tunables.onchain_receive_threshold.saturating_add(Amount::from_sats(1).unwrap()), + tunables.trusted_balance_limit.saturating_sub(Amount::from_sats(1).unwrap()), + tunables.trusted_balance_limit, + ]; + + for amount in test_amounts { + let uri = wallet.get_single_use_receive_uri(Some(amount)).await.unwrap(); assert!( uri.invoice.amount_milli_satoshis().is_some(), - "Should also include Lightning invoice" + "Should generate valid invoice for amount: {} sats", + amount.sats().unwrap_or(0) ); - - // Test 4: Amountless receive behavior with enable_amountless_receive_on_chain - #[cfg(not(feature = "_cashu-tests"))] // Cashu does not support amountless invoices - { - let uri = wallet.get_single_use_receive_uri(None).await.unwrap(); - - if tunables.enable_amountless_receive_on_chain { - assert!( - uri.address.is_some(), - "Amountless receive should include on-chain address when enabled" - ); - } else { - assert!( - uri.address.is_none(), - "Amountless receive should not include on-chain address when disabled" - ); - } - assert!( - uri.invoice.amount_milli_satoshis().is_none(), - "Amountless invoice should have no fixed amount" - ); - } - }) + } } -#[test] -fn test_threshold_combinations_and_edge_cases() { - let TestParams { wallet, rt, .. } = build_test_nodes(); - - rt.block_on(async move { - let tunables = wallet.get_tunables(); - - // Test edge case: ensure thresholds are properly ordered - assert!( - tunables.rebalance_min <= tunables.trusted_balance_limit, - "rebalance_min should be <= trusted_balance_limit for proper wallet operation" - ); - - // Test minimum amount handling (1 sat) - let min_amount = Amount::from_sats(1).unwrap(); - let uri = wallet.get_single_use_receive_uri(Some(min_amount)).await.unwrap(); - - assert!( - uri.invoice.amount_milli_satoshis().is_some(), - "Should handle minimum 1 sat amount" - ); - assert!(uri.address.is_none(), "1 sat should be below onchain threshold"); - - // Test large amount (but reasonable for testing) - let large_amount = Amount::from_sats(1_000_000).unwrap(); // 1 BTC - large but reasonable - let uri = wallet.get_single_use_receive_uri(Some(large_amount)).await.unwrap(); - - assert!(uri.invoice.amount_milli_satoshis().is_some(), "Should handle large amounts"); - assert!(uri.address.is_some(), "Large amount should include on-chain address"); - - // Test zero amount (should be handled by amountless logic) - #[cfg(not(feature = "_cashu-tests"))] // Cashu does not support amountless invoices - { - let uri = wallet.get_single_use_receive_uri(None).await.unwrap(); - assert!( - uri.invoice.amount_milli_satoshis().is_none(), - "Amountless invoice should have no amount" - ); - } - - // Verify the wallet can handle payments at multiple threshold boundaries - let test_amounts = [ - tunables.rebalance_min.saturating_sub(Amount::from_sats(1).unwrap()), - tunables.rebalance_min, - tunables.rebalance_min.saturating_add(Amount::from_sats(1).unwrap()), - tunables.onchain_receive_threshold.saturating_sub(Amount::from_sats(1).unwrap()), - tunables.onchain_receive_threshold, - tunables.onchain_receive_threshold.saturating_add(Amount::from_sats(1).unwrap()), - tunables.trusted_balance_limit.saturating_sub(Amount::from_sats(1).unwrap()), - tunables.trusted_balance_limit, - ]; - - for amount in test_amounts { - let uri = wallet.get_single_use_receive_uri(Some(amount)).await.unwrap(); - assert!( - uri.invoice.amount_milli_satoshis().is_some(), - "Should generate valid invoice for amount: {} sats", - amount.sats().unwrap_or(0) - ); - } - }) +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_invalid_payment_instructions() { + let TestParams { wallet, third_party, .. } = build_test_nodes().await; + + // Test 1: Payment with insufficient balance + let amount = Amount::from_sats(1_000_000).unwrap(); // 1 BTC - more than we have + let desc = Bolt11InvoiceDescription::Direct(Description::empty()); + let invoice = third_party.bolt11_payment().receive(amount.milli_sats(), &desc, 300).unwrap(); + + let instr = wallet.parse_payment_instructions(invoice.to_string().as_str()).await.unwrap(); + let info = PaymentInfo::build(instr, Some(amount)).unwrap(); + + // This should fail due to insufficient balance + let result = wallet.pay(&info).await; + assert!( + matches!(result, Err(WalletError::LdkNodeFailure(NodeError::InsufficientFunds))), + "Payment with insufficient balance should fail with LDK error" + ); + + // Test 2: Invalid invoice parsing + let invalid_invoice = "lnbc1invalid_invoice_here"; + let result = wallet.parse_payment_instructions(invalid_invoice).await; + assert!( + matches!(result, Err(ParseError::UnknownPaymentInstructions)), + "Invalid invoice should fail with UnknownPaymentInstructions error" + ); + + // Test 3: Malformed Bitcoin address + let invalid_address = "not_a_bitcoin_address"; + let result = wallet.parse_payment_instructions(invalid_address).await; + assert!( + matches!(result, Err(ParseError::UnknownPaymentInstructions)), + "Invalid address should fail with UnknownPaymentInstructions error" + ); + + // Test 4: Zero amount payment (should be rejected) + let zero_amount = Amount::ZERO; + let desc = Bolt11InvoiceDescription::Direct(Description::empty()); + let invoice = third_party.bolt11_payment().receive(1000, &desc, 300).unwrap(); // 1 msat + + let instr = wallet.parse_payment_instructions(invoice.to_string().as_str()).await.unwrap(); + let result = PaymentInfo::build(instr, Some(zero_amount)); + assert!(result.is_err(), "Zero amount payment should be rejected"); + + // Test 5: Payment with mismatched amount (fixed amount invoice with different amount) + let fixed_amount = Amount::from_sats(5000).unwrap(); + let different_amount = Amount::from_sats(10000).unwrap(); + let desc = Bolt11InvoiceDescription::Direct(Description::empty()); + let fixed_invoice = + third_party.bolt11_payment().receive(fixed_amount.milli_sats(), &desc, 300).unwrap(); + + let instr = + wallet.parse_payment_instructions(fixed_invoice.to_string().as_str()).await.unwrap(); + let result = PaymentInfo::build(instr, Some(different_amount)); + assert!(result.is_err(), "Mismatched amount for fixed invoice should be rejected"); + + // Test 6: Verify no failed transactions are recorded + let txs = wallet.list_transactions().await.unwrap(); + assert_eq!(txs.len(), 0, "Failed payments should not be recorded in transaction list"); } -#[test] -fn test_invalid_payment_instructions() { - let TestParams { wallet, third_party, rt, .. } = build_test_nodes(); +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_payment_with_expired_invoice() { + let TestParams { wallet, third_party, .. } = build_test_nodes().await; - rt.block_on(async move { - // Test 1: Payment with insufficient balance - let amount = Amount::from_sats(1_000_000).unwrap(); // 1 BTC - more than we have - let desc = Bolt11InvoiceDescription::Direct(Description::empty()); - let invoice = - third_party.bolt11_payment().receive(amount.milli_sats(), &desc, 300).unwrap(); + // Add some balance first so the payment can theoretically succeed if not expired + let initial_amount = Amount::from_sats(5000).unwrap(); + let uri = wallet.get_single_use_receive_uri(Some(initial_amount)).await.unwrap(); + third_party.bolt11_payment().send(&uri.invoice, None).unwrap(); - let instr = wallet.parse_payment_instructions(invoice.to_string().as_str()).await.unwrap(); - let info = PaymentInfo::build(instr, Some(amount)).unwrap(); - - // This should fail due to insufficient balance - let result = wallet.pay(&info).await; - assert!( - matches!(result, Err(WalletError::LdkNodeFailure(NodeError::InsufficientFunds))), - "Payment with insufficient balance should fail with LDK error" - ); - - // Test 2: Invalid invoice parsing - let invalid_invoice = "lnbc1invalid_invoice_here"; - let result = wallet.parse_payment_instructions(invalid_invoice).await; - assert!( - matches!(result, Err(ParseError::UnknownPaymentInstructions)), - "Invalid invoice should fail with UnknownPaymentInstructions error" - ); - - // Test 3: Malformed Bitcoin address - let invalid_address = "not_a_bitcoin_address"; - let result = wallet.parse_payment_instructions(invalid_address).await; - assert!( - matches!(result, Err(ParseError::UnknownPaymentInstructions)), - "Invalid address should fail with UnknownPaymentInstructions error" - ); - - // Test 4: Zero amount payment (should be rejected) - let zero_amount = Amount::ZERO; - let desc = Bolt11InvoiceDescription::Direct(Description::empty()); - let invoice = third_party.bolt11_payment().receive(1000, &desc, 300).unwrap(); // 1 msat - - let instr = wallet.parse_payment_instructions(invoice.to_string().as_str()).await.unwrap(); - let result = PaymentInfo::build(instr, Some(zero_amount)); - assert!(result.is_err(), "Zero amount payment should be rejected"); - - // Test 5: Payment with mismatched amount (fixed amount invoice with different amount) - let fixed_amount = Amount::from_sats(5000).unwrap(); - let different_amount = Amount::from_sats(10000).unwrap(); - let desc = Bolt11InvoiceDescription::Direct(Description::empty()); - let fixed_invoice = - third_party.bolt11_payment().receive(fixed_amount.milli_sats(), &desc, 300).unwrap(); - - let instr = - wallet.parse_payment_instructions(fixed_invoice.to_string().as_str()).await.unwrap(); - let result = PaymentInfo::build(instr, Some(different_amount)); - assert!(result.is_err(), "Mismatched amount for fixed invoice should be rejected"); - - // Test 6: Verify no failed transactions are recorded - let txs = wallet.list_transactions().await.unwrap(); - assert_eq!(txs.len(), 0, "Failed payments should not be recorded in transaction list"); + // Wait for balance update + test_utils::wait_for_condition("wallet balance update", || async { + wallet.get_balance().await.unwrap().available_balance() > Amount::ZERO }) -} + .await; -#[test] -fn test_payment_with_expired_invoice() { - let TestParams { wallet, third_party, rt, .. } = build_test_nodes(); + // Create an invoice with very short expiry + let payment_amount = Amount::from_sats(1000).unwrap(); + let desc = Bolt11InvoiceDescription::Direct(Description::empty()); + let invoice = + third_party.bolt11_payment().receive(payment_amount.milli_sats(), &desc, 1).unwrap(); // 1 second expiry - rt.block_on(async move { - // Add some balance first so the payment can theoretically succeed if not expired - let initial_amount = Amount::from_sats(5000).unwrap(); - let uri = wallet.get_single_use_receive_uri(Some(initial_amount)).await.unwrap(); - third_party.bolt11_payment().send(&uri.invoice, None).unwrap(); - - // Wait for balance update - test_utils::wait_for_condition("wallet balance update", || async { - wallet.get_balance().await.unwrap().available_balance() > Amount::ZERO - }) - .await; + // Wait longer to ensure invoice expires + tokio::time::sleep(Duration::from_secs(5)).await; - // Create an invoice with very short expiry - let payment_amount = Amount::from_sats(1000).unwrap(); - let desc = Bolt11InvoiceDescription::Direct(Description::empty()); - let invoice = - third_party.bolt11_payment().receive(payment_amount.milli_sats(), &desc, 1).unwrap(); // 1 second expiry + // Try to parse and pay the expired invoice - it should either fail to parse or fail to pay + let parse_result = wallet.parse_payment_instructions(invoice.to_string().as_str()).await; + assert!(matches!(parse_result.unwrap_err(), ParseError::InstructionsExpired)); +} - // Wait longer to ensure invoice expires - tokio::time::sleep(Duration::from_secs(5)).await; +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_payment_network_mismatch() { + let TestParams { wallet, bitcoind, .. } = build_test_nodes().await; + + // disable rebalancing so we have on-chain funds + wallet.set_rebalance_enabled(false); + + // fund wallet with on-chain + let recv_amount = Amount::from_sats(1_000_000).unwrap(); + let uri = wallet.get_single_use_receive_uri(Some(recv_amount)).await.unwrap(); + bitcoind + .client + .send_to_address( + &uri.address.unwrap(), + ldk_node::bitcoin::Amount::from_sat(recv_amount.sats().unwrap()), + ) + .unwrap(); - // Try to parse and pay the expired invoice - it should either fail to parse or fail to pay - let parse_result = wallet.parse_payment_instructions(invoice.to_string().as_str()).await; - assert!(matches!(parse_result.unwrap_err(), ParseError::InstructionsExpired)); + // confirm tx + generate_blocks(&bitcoind, 6); + test_utils::wait_for_condition("wallet sync after on-chain receive", || async { + wallet.get_balance().await.unwrap().pending_balance >= recv_amount }) -} - -#[test] -fn test_payment_network_mismatch() { - let TestParams { wallet, bitcoind, rt, .. } = build_test_nodes(); - - rt.block_on(async move { - // disable rebalancing so we have on-chain funds - wallet.set_rebalance_enabled(false); - - // fund wallet with on-chain - let recv_amount = Amount::from_sats(1_000_000).unwrap(); - let uri = wallet.get_single_use_receive_uri(Some(recv_amount)).await.unwrap(); - bitcoind - .client - .send_to_address( - &uri.address.unwrap(), - ldk_node::bitcoin::Amount::from_sat(recv_amount.sats().unwrap()), - ) + .await; + + // Test 1: Mainnet invoice on regtest wallet (if we can construct one) + // This is tricky to test in practice since we're on regtest, but we can test + // the validation logic with known invalid network addresses + + // Test 2: Invalid network address format + let wrong_network = "bc1q2xmz60tlma5mnn6xet9r8zyl8ca2fn9rarjtpz"; // Valid mainnet address + let result = wallet.parse_payment_instructions(wrong_network).await; + assert!( + matches!(result, Err(ParseError::WrongNetwork)), + "Wrong network address should fail with WrongNetwork error" + ); + + // now force a correct parsing to ensure we fail when trying to pay + let instr = + PaymentInstructions::parse(wrong_network, Network::Bitcoin, &HTTPHrnResolver::new(), true) + .await .unwrap(); - // confirm tx - generate_blocks(&bitcoind, 6); - test_utils::wait_for_condition("wallet sync after on-chain receive", || async { - wallet.get_balance().await.unwrap().pending_balance >= recv_amount - }) - .await; + // If it parsed, trying to pay should fail due to network mismatch + let amount = Amount::from_sats(1000).unwrap(); + let info = PaymentInfo::build(instr, Some(amount)).unwrap(); + let pay_result = wallet.pay(&info).await; + assert!( + matches!(pay_result, Err(WalletError::LdkNodeFailure(NodeError::InvalidAddress))), + "Payment to wrong network address should fail with LDK error, got {pay_result:?}" + ); +} - // Test 1: Mainnet invoice on regtest wallet (if we can construct one) - // This is tricky to test in practice since we're on regtest, but we can test - // the validation logic with known invalid network addresses +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_concurrent_payments() { + let TestParams { wallet, bitcoind, third_party, .. } = build_test_nodes().await; - // Test 2: Invalid network address format - let wrong_network = "bc1q2xmz60tlma5mnn6xet9r8zyl8ca2fn9rarjtpz"; // Valid mainnet address - let result = wallet.parse_payment_instructions(wrong_network).await; - assert!( - matches!(result, Err(ParseError::WrongNetwork)), - "Wrong network address should fail with WrongNetwork error" - ); + // First, build up sufficient balance for concurrent sending + let _channel_amount = open_channel_from_lsp(&wallet, Arc::clone(&third_party)).await; - // now force a correct parsing to ensure we fail when trying to pay - let instr = PaymentInstructions::parse( - wrong_network, - Network::Bitcoin, - &HTTPHrnResolver::new(), - true, - ) - .await - .unwrap(); - - // If it parsed, trying to pay should fail due to network mismatch - let amount = Amount::from_sats(1000).unwrap(); - let info = PaymentInfo::build(instr, Some(amount)).unwrap(); - let pay_result = wallet.pay(&info).await; - assert!( - matches!(pay_result, Err(WalletError::LdkNodeFailure(NodeError::InvalidAddress))), - "Payment to wrong network address should fail with LDK error, got {pay_result:?}" - ); + // Wait for sync + generate_blocks(&bitcoind, 6); + test_utils::wait_for_condition("wallet sync after channel open", || async { + wallet.channels().iter().any(|a| a.confirmations.is_some_and(|c| c > 0) && a.is_usable) + && third_party + .list_channels() + .iter() + .any(|a| a.confirmations.is_some_and(|c| c > 0) && a.is_usable) }) -} - -#[test] -fn test_concurrent_payments() { - let TestParams { wallet, bitcoind, third_party, rt, .. } = build_test_nodes(); - - rt.block_on(async move { - // First, build up sufficient balance for concurrent sending - let _channel_amount = open_channel_from_lsp(&wallet, Arc::clone(&third_party)).await; - - // Wait for sync - generate_blocks(&bitcoind, 6); - test_utils::wait_for_condition("wallet sync after channel open", || async { - wallet.channels().iter().any(|a| a.confirmations.is_some_and(|c| c > 0) && a.is_usable) - && third_party - .list_channels() - .iter() - .any(|a| a.confirmations.is_some_and(|c| c > 0) && a.is_usable) - }) - .await; - - // receive to trusted wallet as well - let uri = - wallet.get_single_use_receive_uri(Some(Amount::from_sats(150).unwrap())).await.unwrap(); - third_party.bolt11_payment().send(&uri.invoice, None).unwrap(); - let ev = wait_next_event(&wallet).await; - match ev { - Event::PaymentReceived { .. } => {}, - e => panic!("Expected PaymentReceived event, got {e:?}"), + .await; + + // receive to trusted wallet as well + let uri = + wallet.get_single_use_receive_uri(Some(Amount::from_sats(150).unwrap())).await.unwrap(); + third_party.bolt11_payment().send(&uri.invoice, None).unwrap(); + let ev = wait_next_event(&wallet).await; + match ev { + Event::PaymentReceived { .. } => {}, + e => panic!("Expected PaymentReceived event, got {e:?}"), + } + + // Verify we have sufficient balance for multiple outgoing payments + let initial_balance = wallet.get_balance().await.unwrap(); + let payment_amount = Amount::from_sats(100).unwrap(); // Use small amounts to avoid routing issues + let total_payment_amount = + payment_amount.saturating_add(payment_amount).saturating_add(payment_amount); + + assert!( + initial_balance.available_balance() + >= total_payment_amount.saturating_add(Amount::from_sats(1000).unwrap()), // Extra buffer for fees + "Insufficient balance for concurrent payments test: have {}, need {}", + initial_balance.available_balance().sats().unwrap_or(0), + total_payment_amount.saturating_add(Amount::from_sats(1000).unwrap()).sats().unwrap_or(0) + ); + + // Create multiple invoices from third party for us to pay + let desc = Bolt11InvoiceDescription::Direct(Description::empty()); + let invoice1 = + third_party.bolt11_payment().receive(payment_amount.milli_sats(), &desc, 300).unwrap(); + let invoice2 = + third_party.bolt11_payment().receive(payment_amount.milli_sats(), &desc, 300).unwrap(); + let invoice3 = + third_party.bolt11_payment().receive(payment_amount.milli_sats(), &desc, 300).unwrap(); + + // Convert invoices to strings first to avoid borrowing issues + let invoice1_str = invoice1.to_string(); + let invoice2_str = invoice2.to_string(); + let invoice3_str = invoice3.to_string(); + + // Parse payment instructions concurrently + let (instr_result1, instr_result2, instr_result3) = tokio::join!( + wallet.parse_payment_instructions(&invoice1_str), + wallet.parse_payment_instructions(&invoice2_str), + wallet.parse_payment_instructions(&invoice3_str) + ); + + let instr1 = instr_result1.expect("First instruction parsing should succeed"); + let instr2 = instr_result2.expect("Second instruction parsing should succeed"); + let instr3 = instr_result3.expect("Third instruction parsing should succeed"); + + let info1 = PaymentInfo::build(instr1, Some(payment_amount)).unwrap(); + let info2 = PaymentInfo::build(instr2, Some(payment_amount)).unwrap(); + let info3 = PaymentInfo::build(instr3, Some(payment_amount)).unwrap(); + + // Test: Launch multiple payments concurrently + let (result1, result2, result3) = + tokio::join!(wallet.pay(&info1), wallet.pay(&info2), wallet.pay(&info3)); + + // Payment initiation should succeed since we verified sufficient balance + assert!(result1.is_ok(), "First concurrent payment initiation should succeed: {:?}", result1); + assert!(result2.is_ok(), "Second concurrent payment initiation should succeed: {:?}", result2); + assert!(result3.is_ok(), "Third concurrent payment initiation should succeed: {:?}", result3); + + // Now wait for all PaymentSuccessful events to confirm the payments actually completed + let mut payment_successes = 0; + + while payment_successes < 3 { + let event = wait_next_event(&wallet).await; + match event { + Event::PaymentSuccessful { .. } => { + payment_successes += 1; + }, + _ => { + panic!("Expected PaymentSuccessful event, got: {:?}", event); + }, } + } + + assert_eq!( + payment_successes, 3, + "Should receive exactly 3 PaymentSuccessful events, got {}", + payment_successes + ); + + // Verify all payments were recorded in transaction history + let final_txs = wallet.list_transactions().await.unwrap(); + let outgoing_txs: Vec<_> = final_txs.iter().filter(|tx| tx.outbound).collect(); + assert!(outgoing_txs.len() >= 3, "Should have at least 3 outgoing transactions"); + + // Verify all payments reached the third party + test_utils::wait_for_condition("third party to receive all payments", || async { + let current_payments = third_party.list_payments(); + let successful_payments = current_payments + .iter() + .filter(|p| { + p.status == PaymentStatus::Succeeded + && p.direction == PaymentDirection::Inbound + && p.amount_msat == Some(payment_amount.milli_sats()) + }) + .count(); + successful_payments >= 3 + }) + .await; + + // Verify final balance state (the main test is that concurrent payments succeeded) + let final_balance = wallet.get_balance().await.unwrap(); + let balance_decrease = + initial_balance.available_balance().saturating_sub(final_balance.available_balance()); + println!( + "Balance change: Initial: {}, Final: {}, Decrease: {}", + initial_balance.available_balance().sats().unwrap_or(0), + final_balance.available_balance().sats().unwrap_or(0), + balance_decrease.sats().unwrap_or(0) + ); + + // The balance should have decreased by some amount (allowing for complex routing and trusted/LN combinations) + assert!(balance_decrease > Amount::ZERO, "Balance should decrease after successful payments"); + + // Test concurrent balance queries (should still work during/after payments) + let balance_queries = + tokio::join!(wallet.get_balance(), wallet.get_balance(), wallet.get_balance()); + + // All balance queries should succeed and return consistent results + assert_eq!( + balance_queries.0.unwrap().available_balance(), + balance_queries.1.as_ref().unwrap().available_balance(), + "Concurrent balance queries should be consistent" + ); + assert_eq!( + balance_queries.1.unwrap().available_balance(), + balance_queries.2.unwrap().available_balance(), + "Concurrent balance queries should be consistent" + ); + + // Test concurrent transaction list queries + let tx_queries = tokio::join!( + wallet.list_transactions(), + wallet.list_transactions(), + wallet.list_transactions() + ); + + // All should succeed and return consistent results + let tx_lists = (tx_queries.0.unwrap(), tx_queries.1.unwrap(), tx_queries.2.unwrap()); + assert_eq!( + tx_lists.0.len(), + tx_lists.1.len(), + "Concurrent transaction queries should return same count" + ); + assert_eq!( + tx_lists.1.len(), + tx_lists.2.len(), + "Concurrent transaction queries should return same count" + ); +} - // Verify we have sufficient balance for multiple outgoing payments - let initial_balance = wallet.get_balance().await.unwrap(); - let payment_amount = Amount::from_sats(100).unwrap(); // Use small amounts to avoid routing issues - let total_payment_amount = - payment_amount.saturating_add(payment_amount).saturating_add(payment_amount); +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_concurrent_receive_operations() { + let TestParams { wallet, third_party, .. } = build_test_nodes().await; - assert!( - initial_balance.available_balance() - >= total_payment_amount.saturating_add(Amount::from_sats(1000).unwrap()), // Extra buffer for fees - "Insufficient balance for concurrent payments test: have {}, need {}", - initial_balance.available_balance().sats().unwrap_or(0), - total_payment_amount - .saturating_add(Amount::from_sats(1000).unwrap()) - .sats() - .unwrap_or(0) - ); + let amount = Amount::from_sats(1000).unwrap(); - // Create multiple invoices from third party for us to pay - let desc = Bolt11InvoiceDescription::Direct(Description::empty()); - let invoice1 = - third_party.bolt11_payment().receive(payment_amount.milli_sats(), &desc, 300).unwrap(); - let invoice2 = - third_party.bolt11_payment().receive(payment_amount.milli_sats(), &desc, 300).unwrap(); - let invoice3 = - third_party.bolt11_payment().receive(payment_amount.milli_sats(), &desc, 300).unwrap(); - - // Convert invoices to strings first to avoid borrowing issues - let invoice1_str = invoice1.to_string(); - let invoice2_str = invoice2.to_string(); - let invoice3_str = invoice3.to_string(); - - // Parse payment instructions concurrently - let (instr_result1, instr_result2, instr_result3) = tokio::join!( - wallet.parse_payment_instructions(&invoice1_str), - wallet.parse_payment_instructions(&invoice2_str), - wallet.parse_payment_instructions(&invoice3_str) - ); + // Test: Generate multiple receive URIs concurrently + let (uri1, uri2) = tokio::join!( + wallet.get_single_use_receive_uri(Some(amount)), + wallet.get_single_use_receive_uri(Some(amount)) + ); - let instr1 = instr_result1.expect("First instruction parsing should succeed"); - let instr2 = instr_result2.expect("Second instruction parsing should succeed"); - let instr3 = instr_result3.expect("Third instruction parsing should succeed"); + // Both should succeed + assert!(uri1.is_ok(), "Concurrent URI generation should succeed"); + assert!(uri2.is_ok(), "Concurrent URI generation should succeed"); - let info1 = PaymentInfo::build(instr1, Some(payment_amount)).unwrap(); - let info2 = PaymentInfo::build(instr2, Some(payment_amount)).unwrap(); - let info3 = PaymentInfo::build(instr3, Some(payment_amount)).unwrap(); + let uris = (uri1.unwrap(), uri2.unwrap()); - // Test: Launch multiple payments concurrently - let (result1, result2, result3) = - tokio::join!(wallet.pay(&info1), wallet.pay(&info2), wallet.pay(&info3)); + // URIs should be different (unique invoices) + assert_ne!(uris.0.invoice.to_string(), uris.1.invoice.to_string(), "URIs should be unique"); - // Payment initiation should succeed since we verified sufficient balance - assert!( - result1.is_ok(), - "First concurrent payment initiation should succeed: {:?}", - result1 - ); - assert!( - result2.is_ok(), - "Second concurrent payment initiation should succeed: {:?}", - result2 - ); - assert!( - result3.is_ok(), - "Third concurrent payment initiation should succeed: {:?}", - result3 - ); + // Test: Sequential payments to avoid routing issues + let payment_id_1 = third_party.bolt11_payment().send(&uris.0.invoice, None).unwrap(); - // Now wait for all PaymentSuccessful events to confirm the payments actually completed - let mut payment_successes = 0; - - while payment_successes < 3 { - let event = wait_next_event(&wallet).await; - match event { - Event::PaymentSuccessful { .. } => { - payment_successes += 1; - }, - _ => { - panic!("Expected PaymentSuccessful event, got: {:?}", event); - }, - } - } + // Wait for first payment to complete + test_utils::wait_for_condition("first payment to succeed", || async { + third_party.payment(&payment_id_1).is_some_and(|p| p.status == PaymentStatus::Succeeded) + }) + .await; - assert_eq!( - payment_successes, 3, - "Should receive exactly 3 PaymentSuccessful events, got {}", - payment_successes - ); + // Send second payment + let payment_id_2 = third_party.bolt11_payment().send(&uris.1.invoice, None).unwrap(); - // Verify all payments were recorded in transaction history - let final_txs = wallet.list_transactions().await.unwrap(); - let outgoing_txs: Vec<_> = final_txs.iter().filter(|tx| tx.outbound).collect(); - assert!(outgoing_txs.len() >= 3, "Should have at least 3 outgoing transactions"); + // Wait for second payment to complete + test_utils::wait_for_condition("second payment to succeed", || async { + third_party.payment(&payment_id_2).is_some_and(|p| p.status == PaymentStatus::Succeeded) + }) + .await; - // Verify all payments reached the third party - test_utils::wait_for_condition("third party to receive all payments", || async { - let current_payments = third_party.list_payments(); - let successful_payments = current_payments - .iter() - .filter(|p| { - p.status == PaymentStatus::Succeeded - && p.direction == PaymentDirection::Inbound - && p.amount_msat == Some(payment_amount.milli_sats()) - }) - .count(); - successful_payments >= 3 - }) - .await; - - // Verify final balance state (the main test is that concurrent payments succeeded) - let final_balance = wallet.get_balance().await.unwrap(); - let balance_decrease = - initial_balance.available_balance().saturating_sub(final_balance.available_balance()); - println!( - "Balance change: Initial: {}, Final: {}, Decrease: {}", - initial_balance.available_balance().sats().unwrap_or(0), - final_balance.available_balance().sats().unwrap_or(0), - balance_decrease.sats().unwrap_or(0) - ); + // Wait for wallet balance to reflect both payments + test_utils::wait_for_condition("wallet balance to update", || async { + let balance = wallet.get_balance().await.unwrap().available_balance(); + balance >= amount.saturating_add(amount) + }) + .await; - // The balance should have decreased by some amount (allowing for complex routing and trusted/LN combinations) - assert!( - balance_decrease > Amount::ZERO, - "Balance should decrease after successful payments" - ); + // Verify transactions were recorded + let txs = wallet.list_transactions().await.unwrap(); + assert!(txs.len() >= 2, "Should have at least 2 transactions"); - // Test concurrent balance queries (should still work during/after payments) - let balance_queries = - tokio::join!(wallet.get_balance(), wallet.get_balance(), wallet.get_balance()); + // Count incoming transactions + let incoming_count = txs.iter().filter(|tx| !tx.outbound).count(); + assert_eq!(incoming_count, 2, "Should have exactly 2 incoming transactions"); +} - // All balance queries should succeed and return consistent results - assert_eq!( - balance_queries.0.unwrap().available_balance(), - balance_queries.1.as_ref().unwrap().available_balance(), - "Concurrent balance queries should be consistent" - ); - assert_eq!( - balance_queries.1.unwrap().available_balance(), - balance_queries.2.unwrap().available_balance(), - "Concurrent balance queries should be consistent" - ); +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_balance_consistency_under_load() { + let TestParams { wallet, third_party, .. } = build_test_nodes().await; - // Test concurrent transaction list queries - let tx_queries = tokio::join!( - wallet.list_transactions(), - wallet.list_transactions(), - wallet.list_transactions() - ); + // Add some initial balance + let initial_amount = Amount::from_sats(10000).unwrap(); + let uri = wallet.get_single_use_receive_uri(Some(initial_amount)).await.unwrap(); + third_party.bolt11_payment().send(&uri.invoice, None).unwrap(); - // All should succeed and return consistent results - let tx_lists = (tx_queries.0.unwrap(), tx_queries.1.unwrap(), tx_queries.2.unwrap()); + test_utils::wait_for_condition("initial balance", || async { + wallet.get_balance().await.unwrap().available_balance() >= initial_amount + }) + .await; + + // Test: Many concurrent balance queries + let mut balance_tasks = Vec::new(); + for _ in 0..20 { + balance_tasks.push(wallet.get_balance()); + } + + // Join all balance tasks + let mut all_balances = Vec::new(); + for task in balance_tasks { + all_balances.push(task.await); + } + let balances = all_balances; + + // All queries should succeed + assert_eq!(balances.len(), 20); + for b in &balances { + let balance = b.as_ref().unwrap(); + assert!(balance.available_balance() >= Amount::ZERO); + assert!(balance.pending_balance >= Amount::ZERO); + } + + // All balances should be consistent (same values) + let first_balance = &balances[0].as_ref().unwrap(); + for b in &balances[1..] { + let balance = b.as_ref().unwrap(); assert_eq!( - tx_lists.0.len(), - tx_lists.1.len(), - "Concurrent transaction queries should return same count" + balance.available_balance(), + first_balance.available_balance(), + "Concurrent balance queries should return consistent results" ); assert_eq!( - tx_lists.1.len(), - tx_lists.2.len(), - "Concurrent transaction queries should return same count" + balance.pending_balance, first_balance.pending_balance, + "Concurrent balance queries should return consistent results" ); - }) + } } -#[test] -fn test_concurrent_receive_operations() { - let TestParams { wallet, third_party, rt, .. } = build_test_nodes(); - - rt.block_on(async move { - let amount = Amount::from_sats(1000).unwrap(); - - // Test: Generate multiple receive URIs concurrently - let (uri1, uri2) = tokio::join!( - wallet.get_single_use_receive_uri(Some(amount)), - wallet.get_single_use_receive_uri(Some(amount)) +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_invalid_tunables_relationships() { + let TestParams { wallet, .. } = build_test_nodes().await; + + let current_tunables = wallet.get_tunables(); + + // Test 1: Verify default tunables are valid + assert!( + current_tunables.rebalance_min <= current_tunables.trusted_balance_limit, + "Default tunables should have valid relationship: rebalance_min <= trusted_balance_limit" + ); + + // Test 2: Test edge case amounts with current tunables + // Zero amount (should work for URI generation but not payments) + #[cfg(not(feature = "_cashu-tests"))] // Cashu does not support amountless invoices + { + let uri_result = wallet.get_single_use_receive_uri(None).await; + assert!(uri_result.is_ok(), "Should be able to generate amountless URI"); + } + + // Test 3: Very small amounts + let tiny_amount = Amount::from_sats(1).unwrap(); + let uri_result = wallet.get_single_use_receive_uri(Some(tiny_amount)).await; + assert!(uri_result.is_ok(), "Should handle tiny amounts"); + + let uri = uri_result.unwrap(); + assert!( + uri.invoice.amount_milli_satoshis().is_some(), + "Tiny amount should have Lightning invoice" + ); + + // Should not include on-chain address if below threshold + if tiny_amount < current_tunables.onchain_receive_threshold { + assert!( + uri.address.is_none(), + "Tiny amount below threshold should not include on-chain address" ); + } - // Both should succeed - assert!(uri1.is_ok(), "Concurrent URI generation should succeed"); - assert!(uri2.is_ok(), "Concurrent URI generation should succeed"); - - let uris = (uri1.unwrap(), uri2.unwrap()); - - // URIs should be different (unique invoices) - assert_ne!(uris.0.invoice.to_string(), uris.1.invoice.to_string(), "URIs should be unique"); - - // Test: Sequential payments to avoid routing issues - let payment_id_1 = third_party.bolt11_payment().send(&uris.0.invoice, None).unwrap(); - - // Wait for first payment to complete - test_utils::wait_for_condition("first payment to succeed", || async { - third_party.payment(&payment_id_1).is_some_and(|p| p.status == PaymentStatus::Succeeded) - }) - .await; - - // Send second payment - let payment_id_2 = third_party.bolt11_payment().send(&uris.1.invoice, None).unwrap(); - - // Wait for second payment to complete - test_utils::wait_for_condition("second payment to succeed", || async { - third_party.payment(&payment_id_2).is_some_and(|p| p.status == PaymentStatus::Succeeded) - }) - .await; - - // Wait for wallet balance to reflect both payments - test_utils::wait_for_condition("wallet balance to update", || async { - let balance = wallet.get_balance().await.unwrap().available_balance(); - balance >= amount.saturating_add(amount) - }) - .await; - - // Verify transactions were recorded - let txs = wallet.list_transactions().await.unwrap(); - assert!(txs.len() >= 2, "Should have at least 2 transactions"); + // Test 4: Amounts exactly at boundaries + let boundary_amounts = [ + current_tunables.rebalance_min, + current_tunables.trusted_balance_limit, + current_tunables.onchain_receive_threshold, + ]; - // Count incoming transactions - let incoming_count = txs.iter().filter(|tx| !tx.outbound).count(); - assert_eq!(incoming_count, 2, "Should have exactly 2 incoming transactions"); - }) + for amount in boundary_amounts { + let uri_result = wallet.get_single_use_receive_uri(Some(amount)).await; + assert!( + uri_result.is_ok(), + "Should handle boundary amounts: {} sats", + amount.sats().unwrap_or(0) + ); + } + + // Test 5: Verify tunables consistency with wallet behavior + let below_limit = + current_tunables.trusted_balance_limit.saturating_sub(Amount::from_sats(1).unwrap()); + let above_limit = + current_tunables.trusted_balance_limit.saturating_add(Amount::from_sats(1).unwrap()); + + let uri_below = wallet.get_single_use_receive_uri(Some(below_limit)).await.unwrap(); + let uri_above = wallet.get_single_use_receive_uri(Some(above_limit)).await.unwrap(); + + // Both should have invoices + assert!(uri_below.invoice.amount_milli_satoshis().is_some(), "Below limit should have invoice"); + assert!(uri_above.invoice.amount_milli_satoshis().is_some(), "Above limit should have invoice"); + + // On-chain address inclusion should depend on onchain_receive_threshold, not trusted_balance_limit + if below_limit >= current_tunables.onchain_receive_threshold { + assert!(uri_below.address.is_some(), "Amount >= onchain_threshold should include address"); + } + if above_limit >= current_tunables.onchain_receive_threshold { + assert!(uri_above.address.is_some(), "Amount >= onchain_threshold should include address"); + } } -#[test] -fn test_balance_consistency_under_load() { - let TestParams { wallet, third_party, rt, .. } = build_test_nodes(); +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_extreme_amount_handling() { + let TestParams { wallet, .. } = build_test_nodes().await; - rt.block_on(async move { - // Add some initial balance - let initial_amount = Amount::from_sats(10000).unwrap(); - let uri = wallet.get_single_use_receive_uri(Some(initial_amount)).await.unwrap(); - third_party.bolt11_payment().send(&uri.invoice, None).unwrap(); + // Test 1: Large but reasonable Bitcoin amount + let large_reasonable = Amount::from_sats(1_000_000).unwrap(); // 1M sats = 0.01 BTC + let uri_result = wallet.get_single_use_receive_uri(Some(large_reasonable)).await; + assert!(uri_result.is_ok(), "Should handle large reasonable Bitcoin amount"); - test_utils::wait_for_condition("initial balance", || async { - wallet.get_balance().await.unwrap().available_balance() >= initial_amount - }) - .await; - - // Test: Many concurrent balance queries - let mut balance_tasks = Vec::new(); - for _ in 0..20 { - balance_tasks.push(wallet.get_balance()); - } - - // Join all balance tasks - let mut all_balances = Vec::new(); - for task in balance_tasks { - all_balances.push(task.await); - } - let balances = all_balances; - - // All queries should succeed - assert_eq!(balances.len(), 20); - for b in &balances { - let balance = b.as_ref().unwrap(); - assert!(balance.available_balance() >= Amount::ZERO); - assert!(balance.pending_balance >= Amount::ZERO); - } + // Test 2: Various large amounts (but still reasonable for testing) + let large_amounts = [ + Amount::from_sats(100_000).unwrap(), // 0.001 BTC + Amount::from_sats(500_000).unwrap(), // 0.005 BTC + Amount::from_sats(1_000_000).unwrap(), // 0.01 BTC + ]; - // All balances should be consistent (same values) - let first_balance = &balances[0].as_ref().unwrap(); - for b in &balances[1..] { - let balance = b.as_ref().unwrap(); - assert_eq!( - balance.available_balance(), - first_balance.available_balance(), - "Concurrent balance queries should return consistent results" - ); - assert_eq!( - balance.pending_balance, first_balance.pending_balance, - "Concurrent balance queries should return consistent results" - ); - } - }) -} - -#[test] -fn test_invalid_tunables_relationships() { - let TestParams { wallet, rt, .. } = build_test_nodes(); - - rt.block_on(async move { - let current_tunables = wallet.get_tunables(); - - // Test 1: Verify default tunables are valid + for amount in large_amounts { + let uri_result = wallet.get_single_use_receive_uri(Some(amount)).await; assert!( - current_tunables.rebalance_min <= current_tunables.trusted_balance_limit, - "Default tunables should have valid relationship: rebalance_min <= trusted_balance_limit" + uri_result.is_ok(), + "Should handle large amount: {} BTC", + amount.sats().unwrap() as f64 / 100_000_000.0 ); - // Test 2: Test edge case amounts with current tunables - // Zero amount (should work for URI generation but not payments) - #[cfg(not(feature = "_cashu-tests"))] // Cashu does not support amountless invoices - { - let uri_result = wallet.get_single_use_receive_uri(None).await; - assert!(uri_result.is_ok(), "Should be able to generate amountless URI"); - } - - // Test 3: Very small amounts - let tiny_amount = Amount::from_sats(1).unwrap(); - let uri_result = wallet.get_single_use_receive_uri(Some(tiny_amount)).await; - assert!(uri_result.is_ok(), "Should handle tiny amounts"); - let uri = uri_result.unwrap(); + assert!(uri.invoice.amount_milli_satoshis().is_some(), "Large amount should have invoice"); + // Large amounts should always include on-chain address + assert!(uri.address.is_some(), "Large amounts should include on-chain address"); + } + + // Test 3: Satoshi precision edge cases + let precision_amounts = [ + Amount::from_sats(1).unwrap(), // 1 sat + Amount::from_sats(10).unwrap(), // 10 sats + Amount::from_sats(100).unwrap(), // 100 sats + Amount::from_sats(1000).unwrap(), // 1000 sats (1 mBTC) + ]; + + for amount in precision_amounts { + let uri_result = wallet.get_single_use_receive_uri(Some(amount)).await; assert!( - uri.invoice.amount_milli_satoshis().is_some(), - "Tiny amount should have Lightning invoice" + uri_result.is_ok(), + "Should handle precision amount: {} sats", + amount.sats().unwrap() ); + } - // Should not include on-chain address if below threshold - if tiny_amount < current_tunables.onchain_receive_threshold { - assert!( - uri.address.is_none(), - "Tiny amount below threshold should not include on-chain address" - ); - } - - // Test 4: Amounts exactly at boundaries - let boundary_amounts = [ - current_tunables.rebalance_min, - current_tunables.trusted_balance_limit, - current_tunables.onchain_receive_threshold, - ]; + // Test 4: Milli-satoshi precision (if supported) + // Note: Bitcoin addresses can't handle milli-satoshi precision, only Lightning can + // Note: Cashu does not support msat precision + #[cfg(not(feature = "_cashu-tests"))] + { + let msat_amount = Amount::from_milli_sats(1500).unwrap(); // 1.5 sats + let uri_result = wallet.get_single_use_receive_uri(Some(msat_amount)).await; + assert!(uri_result.is_ok(), "Should handle milli-satoshi amounts"); - for amount in boundary_amounts { - let uri_result = wallet.get_single_use_receive_uri(Some(amount)).await; - assert!( - uri_result.is_ok(), - "Should handle boundary amounts: {} sats", - amount.sats().unwrap_or(0) - ); - } - - // Test 5: Verify tunables consistency with wallet behavior - let below_limit = - current_tunables.trusted_balance_limit.saturating_sub(Amount::from_sats(1).unwrap()); - let above_limit = - current_tunables.trusted_balance_limit.saturating_add(Amount::from_sats(1).unwrap()); - - let uri_below = wallet.get_single_use_receive_uri(Some(below_limit)).await.unwrap(); - let uri_above = wallet.get_single_use_receive_uri(Some(above_limit)).await.unwrap(); - - // Both should have invoices - assert!( - uri_below.invoice.amount_milli_satoshis().is_some(), - "Below limit should have invoice" - ); + let uri = uri_result.unwrap(); assert!( - uri_above.invoice.amount_milli_satoshis().is_some(), - "Above limit should have invoice" + uri.invoice.amount_milli_satoshis().is_some(), + "Milli-satoshi amount should have Lightning invoice" ); - - // On-chain address inclusion should depend on onchain_receive_threshold, not trusted_balance_limit - if below_limit >= current_tunables.onchain_receive_threshold { - assert!( - uri_below.address.is_some(), - "Amount >= onchain_threshold should include address" - ); - } - if above_limit >= current_tunables.onchain_receive_threshold { - assert!( - uri_above.address.is_some(), - "Amount >= onchain_threshold should include address" - ); - } - }) + } + // On-chain address depends on threshold, not msat precision } -#[test] -fn test_extreme_amount_handling() { - let TestParams { wallet, rt, .. } = build_test_nodes(); - - rt.block_on(async move { - // Test 1: Large but reasonable Bitcoin amount - let large_reasonable = Amount::from_sats(1_000_000).unwrap(); // 1M sats = 0.01 BTC - let uri_result = wallet.get_single_use_receive_uri(Some(large_reasonable)).await; - assert!(uri_result.is_ok(), "Should handle large reasonable Bitcoin amount"); - - // Test 2: Various large amounts (but still reasonable for testing) - let large_amounts = [ - Amount::from_sats(100_000).unwrap(), // 0.001 BTC - Amount::from_sats(500_000).unwrap(), // 0.005 BTC - Amount::from_sats(1_000_000).unwrap(), // 0.01 BTC - ]; - - for amount in large_amounts { - let uri_result = wallet.get_single_use_receive_uri(Some(amount)).await; - assert!( - uri_result.is_ok(), - "Should handle large amount: {} BTC", - amount.sats().unwrap() as f64 / 100_000_000.0 - ); - - let uri = uri_result.unwrap(); - assert!( - uri.invoice.amount_milli_satoshis().is_some(), - "Large amount should have invoice" - ); - // Large amounts should always include on-chain address - assert!(uri.address.is_some(), "Large amounts should include on-chain address"); - } - - // Test 3: Satoshi precision edge cases - let precision_amounts = [ - Amount::from_sats(1).unwrap(), // 1 sat - Amount::from_sats(10).unwrap(), // 10 sats - Amount::from_sats(100).unwrap(), // 100 sats - Amount::from_sats(1000).unwrap(), // 1000 sats (1 mBTC) - ]; - - for amount in precision_amounts { - let uri_result = wallet.get_single_use_receive_uri(Some(amount)).await; - assert!( - uri_result.is_ok(), - "Should handle precision amount: {} sats", - amount.sats().unwrap() - ); - } - - // Test 4: Milli-satoshi precision (if supported) - // Note: Bitcoin addresses can't handle milli-satoshi precision, only Lightning can - // Note: Cashu does not support msat precision - #[cfg(not(feature = "_cashu-tests"))] - { - let msat_amount = Amount::from_milli_sats(1500).unwrap(); // 1.5 sats - let uri_result = wallet.get_single_use_receive_uri(Some(msat_amount)).await; - assert!(uri_result.is_ok(), "Should handle milli-satoshi amounts"); - - let uri = uri_result.unwrap(); - assert!( - uri.invoice.amount_milli_satoshis().is_some(), - "Milli-satoshi amount should have Lightning invoice" - ); - } - // On-chain address depends on threshold, not msat precision - }) +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_wallet_configuration_validation() { + let TestParams { wallet, .. } = build_test_nodes().await; + + // Test 1: Verify wallet is using expected network + // This is more of a sanity check since we can't easily test invalid networks + // without creating new wallets + + // Test 2: Verify rebalancing can be toggled + let initial_rebalance_state = wallet.get_rebalance_enabled(); + assert!(initial_rebalance_state, "Rebalancing should be enabled by default"); + + wallet.set_rebalance_enabled(false); + assert!(!wallet.get_rebalance_enabled(), "Should be able to disable rebalancing"); + + wallet.set_rebalance_enabled(true); + assert!(wallet.get_rebalance_enabled(), "Should be able to re-enable rebalancing"); + + // Test 3: Verify tunables are consistent and reasonable + let tunables = wallet.get_tunables(); + + // Check for reasonable default values + assert!( + tunables.trusted_balance_limit > Amount::ZERO, + "Trusted balance limit should be positive" + ); + assert!(tunables.rebalance_min > Amount::ZERO, "Rebalance min should be positive"); + assert!( + tunables.onchain_receive_threshold > Amount::ZERO, + "Onchain threshold should be positive" + ); + + // Check relationships + assert!( + tunables.rebalance_min <= tunables.trusted_balance_limit, + "Rebalance min should not exceed trusted balance limit" + ); + + // Test 4: Test URI generation consistency across multiple calls + let amount = Amount::from_sats(5000).unwrap(); + let uri1 = wallet.get_single_use_receive_uri(Some(amount)).await.unwrap(); + let uri2 = wallet.get_single_use_receive_uri(Some(amount)).await.unwrap(); + + // Should generate different invoices (single use) + assert_ne!( + uri1.invoice.to_string(), + uri2.invoice.to_string(), + "Single-use URIs should be unique" + ); + + // But same amount and policy decisions + assert_eq!( + uri1.invoice.amount_milli_satoshis(), + uri2.invoice.amount_milli_satoshis(), + "Same amount should be preserved" + ); + assert_eq!( + uri1.address.is_some(), + uri2.address.is_some(), + "Address inclusion should be consistent" + ); } -#[test] -fn test_wallet_configuration_validation() { - let TestParams { wallet, rt, .. } = build_test_nodes(); - - rt.block_on(async move { - // Test 1: Verify wallet is using expected network - // This is more of a sanity check since we can't easily test invalid networks - // without creating new wallets - - // Test 2: Verify rebalancing can be toggled - let initial_rebalance_state = wallet.get_rebalance_enabled(); - assert!(initial_rebalance_state, "Rebalancing should be enabled by default"); - - wallet.set_rebalance_enabled(false); - assert!(!wallet.get_rebalance_enabled(), "Should be able to disable rebalancing"); - - wallet.set_rebalance_enabled(true); - assert!(wallet.get_rebalance_enabled(), "Should be able to re-enable rebalancing"); - - // Test 3: Verify tunables are consistent and reasonable - let tunables = wallet.get_tunables(); - - // Check for reasonable default values - assert!( - tunables.trusted_balance_limit > Amount::ZERO, - "Trusted balance limit should be positive" - ); - assert!(tunables.rebalance_min > Amount::ZERO, "Rebalance min should be positive"); - assert!( - tunables.onchain_receive_threshold > Amount::ZERO, - "Onchain threshold should be positive" - ); - - // Check relationships - assert!( - tunables.rebalance_min <= tunables.trusted_balance_limit, - "Rebalance min should not exceed trusted balance limit" - ); - - // Test 4: Test URI generation consistency across multiple calls - let amount = Amount::from_sats(5000).unwrap(); - let uri1 = wallet.get_single_use_receive_uri(Some(amount)).await.unwrap(); - let uri2 = wallet.get_single_use_receive_uri(Some(amount)).await.unwrap(); - - // Should generate different invoices (single use) - assert_ne!( - uri1.invoice.to_string(), - uri2.invoice.to_string(), - "Single-use URIs should be unique" - ); - - // But same amount and policy decisions - assert_eq!( - uri1.invoice.amount_milli_satoshis(), - uri2.invoice.amount_milli_satoshis(), - "Same amount should be preserved" - ); - assert_eq!( - uri1.address.is_some(), - uri2.address.is_some(), - "Address inclusion should be consistent" - ); - }) +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_edge_case_payment_instruction_parsing() { + let TestParams { wallet, third_party, .. } = build_test_nodes().await; + + // Test 1: Empty strings + let empty_result = wallet.parse_payment_instructions("").await; + assert!( + matches!(empty_result, Err(ParseError::UnknownPaymentInstructions)), + "Empty string should fail with UnknownPaymentInstructions error" + ); + + // Test 2: Whitespace-only strings + let whitespace_result = wallet.parse_payment_instructions(" \t\n ").await; + assert!( + matches!(whitespace_result, Err(ParseError::UnknownPaymentInstructions)), + "Whitespace-only string should fail with UnknownPaymentInstructions error" + ); + + // Test 3: Very long invalid strings + let long_invalid = "a".repeat(1000); + let long_result = wallet.parse_payment_instructions(&long_invalid).await; + assert!( + matches!(long_result, Err(ParseError::UnknownPaymentInstructions)), + "Very long invalid string should fail with UnknownPaymentInstructions error" + ); + + // Test 4: Mixed case handling + // Create a valid invoice first + let amount = Amount::from_sats(1000).unwrap(); + let desc = Bolt11InvoiceDescription::Direct(Description::empty()); + let invoice = third_party.bolt11_payment().receive(amount.milli_sats(), &desc, 300).unwrap(); + let invoice_str = invoice.to_string(); + + // Test uppercase + let _upper_result = wallet.parse_payment_instructions(&invoice_str.to_uppercase()).await; + let _lower_result = wallet.parse_payment_instructions(&invoice_str.to_lowercase()).await; + let original_result = wallet.parse_payment_instructions(&invoice_str).await; + + // At least the original should work + assert!(original_result.is_ok(), "Original invoice should parse successfully"); + + // Test 5: Invoices with special characters or encoding + let special_chars = ["lightning:", "bitcoin:?lightning=", "LIGHTNING:", "BITCOIN:?LIGHTNING="]; + for prefix in special_chars { + let prefixed = format!("{}{}", prefix, invoice_str); + let result = wallet.parse_payment_instructions(&prefixed).await; + assert!(result.is_ok(), "Failed to parse payment instructions"); + } } -#[test] -fn test_edge_case_payment_instruction_parsing() { - let TestParams { wallet, third_party, rt, .. } = build_test_nodes(); - - rt.block_on(async move { - // Test 1: Empty strings - let empty_result = wallet.parse_payment_instructions("").await; - assert!( - matches!(empty_result, Err(ParseError::UnknownPaymentInstructions)), - "Empty string should fail with UnknownPaymentInstructions error" - ); - - // Test 2: Whitespace-only strings - let whitespace_result = wallet.parse_payment_instructions(" \t\n ").await; - assert!( - matches!(whitespace_result, Err(ParseError::UnknownPaymentInstructions)), - "Whitespace-only string should fail with UnknownPaymentInstructions error" - ); +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_lsp_connectivity_fallback() { + let TestParams { wallet, lsp, bitcoind, third_party, .. } = build_test_nodes().await; - // Test 3: Very long invalid strings - let long_invalid = "a".repeat(1000); - let long_result = wallet.parse_payment_instructions(&long_invalid).await; - assert!( - matches!(long_result, Err(ParseError::UnknownPaymentInstructions)), - "Very long invalid string should fail with UnknownPaymentInstructions error" - ); + // open a channel with the LSP + open_channel_from_lsp(&wallet, Arc::clone(&third_party)).await; - // Test 4: Mixed case handling - // Create a valid invoice first - let amount = Amount::from_sats(1000).unwrap(); - let desc = Bolt11InvoiceDescription::Direct(Description::empty()); - let invoice = - third_party.bolt11_payment().receive(amount.milli_sats(), &desc, 300).unwrap(); - let invoice_str = invoice.to_string(); - - // Test uppercase - let _upper_result = wallet.parse_payment_instructions(&invoice_str.to_uppercase()).await; - let _lower_result = wallet.parse_payment_instructions(&invoice_str.to_lowercase()).await; - let original_result = wallet.parse_payment_instructions(&invoice_str).await; - - // At least the original should work - assert!(original_result.is_ok(), "Original invoice should parse successfully"); - - // Test 5: Invoices with special characters or encoding - let special_chars = - ["lightning:", "bitcoin:?lightning=", "LIGHTNING:", "BITCOIN:?LIGHTNING="]; - for prefix in special_chars { - let prefixed = format!("{}{}", prefix, invoice_str); - let result = wallet.parse_payment_instructions(&prefixed).await; - assert!(result.is_ok(), "Failed to parse payment instructions"); - } + // confirm channel + generate_blocks(&bitcoind, 6); + test_utils::wait_for_condition("wallet sync after channel open", || async { + wallet.channels().iter().any(|a| a.confirmations.is_some_and(|c| c > 0) && a.is_usable) + && third_party + .list_channels() + .iter() + .any(|a| a.confirmations.is_some_and(|c| c > 0) && a.is_usable) }) -} + .await; -#[test] -fn test_lsp_connectivity_fallback() { - let TestParams { wallet, lsp, bitcoind, third_party, rt, .. } = build_test_nodes(); - - rt.block_on(async move { - // open a channel with the LSP - open_channel_from_lsp(&wallet, Arc::clone(&third_party)).await; - - // confirm channel - generate_blocks(&bitcoind, 6); - test_utils::wait_for_condition("wallet sync after channel open", || async { - wallet.channels().iter().any(|a| a.confirmations.is_some_and(|c| c > 0) && a.is_usable) - && third_party - .list_channels() - .iter() - .any(|a| a.confirmations.is_some_and(|c| c > 0) && a.is_usable) - }) - .await; - - // spend some of the balance so we have some inbound capacity - let amount = Amount::from_sats(10_000).unwrap(); - let inv = third_party - .bolt11_payment() - .receive( - amount.milli_sats(), - &Bolt11InvoiceDescription::Direct(Description::empty()), - 300, - ) - .unwrap(); - let instr = wallet.parse_payment_instructions(inv.to_string().as_str()).await.unwrap(); - let info = PaymentInfo::build(instr, Some(amount)).unwrap(); - let _ = wallet.pay(&info).await; - - // Wait for the payment to be processed - let event = wait_next_event(&wallet).await; - match event { - Event::PaymentSuccessful { .. } => {}, - e => panic!("Expected PaymentSuccessful event, got: {e:?}"), - } - - // Get the wallet's tunables to find the trusted balance limit - let tunables = wallet.get_tunables(); - - // Use an amount that would normally go to Lightning (above trusted balance limit) - let additional_amount = Amount::from_sats(1000).unwrap(); - let large_recv_amt = Amount::from_milli_sats( - tunables.trusted_balance_limit.milli_sats() + additional_amount.milli_sats(), - ) + // spend some of the balance so we have some inbound capacity + let amount = Amount::from_sats(10_000).unwrap(); + let inv = third_party + .bolt11_payment() + .receive(amount.milli_sats(), &Bolt11InvoiceDescription::Direct(Description::empty()), 300) .unwrap(); - - // First, verify that with LSP online, this large amount would normally use Lightning - let uri_with_lsp = wallet.get_single_use_receive_uri(Some(large_recv_amt)).await.unwrap(); - // This should work when LSP is online - assert!(!uri_with_lsp.from_trusted); - - // Now simulate LSP being offline by stopping it - let _ = lsp.stop(); - - // Wait a moment for the stop to take effect - tokio::time::sleep(Duration::from_secs(2)).await; - - // Now try to receive the same large amount that would normally trigger Lightning usage - // but should fall back to trusted wallet due to LSP being offline - let uri_result = wallet.get_single_use_receive_uri(Some(large_recv_amt)).await.unwrap(); - assert!(uri_result.from_trusted); - - // test with small amount that should succeed - let small_recv_amt = Amount::from_sats(100).unwrap(); - let uri_small = wallet.get_single_use_receive_uri(Some(small_recv_amt)).await.unwrap(); - assert_eq!( - uri_small.invoice.amount_milli_satoshis(), - Some(small_recv_amt.milli_sats()), - "Small amount should still generate a valid invoice even with LSP offline" - ); - assert!(uri_small.from_trusted); - }); + let instr = wallet.parse_payment_instructions(inv.to_string().as_str()).await.unwrap(); + let info = PaymentInfo::build(instr, Some(amount)).unwrap(); + let _ = wallet.pay(&info).await; + + // Wait for the payment to be processed + let event = wait_next_event(&wallet).await; + match event { + Event::PaymentSuccessful { .. } => {}, + e => panic!("Expected PaymentSuccessful event, got: {e:?}"), + } + + // Get the wallet's tunables to find the trusted balance limit + let tunables = wallet.get_tunables(); + + // Use an amount that would normally go to Lightning (above trusted balance limit) + let additional_amount = Amount::from_sats(1000).unwrap(); + let large_recv_amt = Amount::from_milli_sats( + tunables.trusted_balance_limit.milli_sats() + additional_amount.milli_sats(), + ) + .unwrap(); + + // First, verify that with LSP online, this large amount would normally use Lightning + let uri_with_lsp = wallet.get_single_use_receive_uri(Some(large_recv_amt)).await.unwrap(); + // This should work when LSP is online + assert!(!uri_with_lsp.from_trusted); + + // Now simulate LSP being offline by stopping it + let _ = lsp.stop(); + + // Wait a moment for the stop to take effect + tokio::time::sleep(Duration::from_secs(2)).await; + + // Now try to receive the same large amount that would normally trigger Lightning usage + // but should fall back to trusted wallet due to LSP being offline + let uri_result = wallet.get_single_use_receive_uri(Some(large_recv_amt)).await.unwrap(); + assert!(uri_result.from_trusted); + + // test with small amount that should succeed + let small_recv_amt = Amount::from_sats(100).unwrap(); + let uri_small = wallet.get_single_use_receive_uri(Some(small_recv_amt)).await.unwrap(); + assert_eq!( + uri_small.invoice.amount_milli_satoshis(), + Some(small_recv_amt.milli_sats()), + "Small amount should still generate a valid invoice even with LSP offline" + ); + assert!(uri_small.from_trusted); } diff --git a/orange-sdk/tests/test_utils.rs b/orange-sdk/tests/test_utils.rs index cab41e8..1bb759a 100644 --- a/orange-sdk/tests/test_utils.rs +++ b/orange-sdk/tests/test_utils.rs @@ -28,7 +28,6 @@ use std::net::SocketAddr; use std::str::FromStr; use std::sync::Arc; use std::time::Duration; -use tokio::runtime::Runtime; use uuid::Uuid; /// Waits for an async condition to be true, polling at a specified interval until a timeout. @@ -112,7 +111,7 @@ pub fn generate_blocks(bitcoind: &Bitcoind, num: usize) { .unwrap_or_else(|_| panic!("failed to generate {num} blocks")); } -fn create_lsp(rt: Arc, uuid: Uuid, bitcoind: &Bitcoind) -> Arc { +fn create_lsp(uuid: Uuid, bitcoind: &Bitcoind) -> Arc { let mut builder = ldk_node::Builder::new(); builder.set_network(Network::Regtest); let mut seed: [u8; 64] = [0; 64]; @@ -156,7 +155,7 @@ fn create_lsp(rt: Arc, uuid: Uuid, bitcoind: &Bitcoind) -> Arc { ldk_node.start().unwrap(); let events_ref = Arc::clone(&ldk_node); - rt.spawn(async move { + tokio::spawn(async move { loop { let event = events_ref.next_event_async().await; println!("LSP: {event:?}"); @@ -169,7 +168,7 @@ fn create_lsp(rt: Arc, uuid: Uuid, bitcoind: &Bitcoind) -> Arc { ldk_node } -fn create_third_party(rt: Arc, uuid: Uuid, bitcoind: &Bitcoind) -> Arc { +fn create_third_party(uuid: Uuid, bitcoind: &Bitcoind) -> Arc { let mut builder = ldk_node::Builder::new(); builder.set_network(Network::Regtest); let mut seed: [u8; 64] = [0; 64]; @@ -200,7 +199,7 @@ fn create_third_party(rt: Arc, uuid: Uuid, bitcoind: &Bitcoind) -> Arc< ldk_node.start().unwrap(); let events_ref = Arc::clone(&ldk_node); - rt.spawn(async move { + tokio::spawn(async move { loop { let event = events_ref.next_event_async().await; println!("3rd party: {event:?}"); @@ -224,32 +223,27 @@ pub struct TestParams { pub lsp: Arc, pub third_party: Arc, pub bitcoind: Arc, - pub rt: Arc, #[cfg(feature = "_cashu-tests")] pub _mint: Arc, } -pub fn build_test_nodes() -> TestParams { +pub async fn build_test_nodes() -> TestParams { let test_id = Uuid::now_v7(); let bitcoind = Arc::new(create_bitcoind(test_id)); - let rt = Arc::new(tokio::runtime::Builder::new_multi_thread().enable_all().build().unwrap()); - - let lsp = create_lsp(Arc::clone(&rt), test_id, &bitcoind); + let lsp = create_lsp(test_id, &bitcoind); fund_node(&lsp, &bitcoind); - let third_party = create_third_party(Arc::clone(&rt), test_id, &bitcoind); + let third_party = create_third_party(test_id, &bitcoind); let start_bal = third_party.list_balances().total_onchain_balance_sats; fund_node(&third_party, &bitcoind); // wait for node to sync (needs blocking wait as we are not in async context here) let third = Arc::clone(&third_party); - rt.block_on(async move { - wait_for_condition("third_party node sync after funding", || { - let res = third.list_balances().total_onchain_balance_sats > start_bal; - async move { res } - }) - .await; - }); + wait_for_condition("third_party node sync after funding", || { + let res = third.list_balances().total_onchain_balance_sats > start_bal; + async move { res } + }) + .await; let lsp_listen = lsp.listening_addresses().unwrap().first().unwrap().clone(); @@ -260,13 +254,11 @@ pub fn build_test_nodes() -> TestParams { // wait for channel ready (needs blocking wait as we are not in async context here) let third_party_clone = Arc::clone(&third_party); - rt.block_on(async move { - wait_for_condition("channel to become usable", || { - let res = third_party_clone.list_channels().first().is_some_and(|c| c.is_usable); - async move { res } - }) - .await; - }); + wait_for_condition("channel to become usable", || { + let res = third_party_clone.list_channels().first().is_some_and(|c| c.is_usable); + async move { res } + }) + .await; // make sure it actually became usable assert!(third_party.list_channels().first().unwrap().is_usable); @@ -281,13 +273,11 @@ pub fn build_test_nodes() -> TestParams { uuid: test_id, lsp: Arc::clone(&lsp), bitcoind: Arc::clone(&bitcoind), - rt: Arc::clone(&rt), }; let tmp = temp_dir().join(format!("orange-test-{test_id}/ldk-node")); let cookie = bitcoind.params.get_cookie_values().unwrap().unwrap(); - let rt_clone = Arc::clone(&rt); let bitcoind_port = bitcoind.params.rpc_socket.port(); let wallet_config = WalletConfig { @@ -307,7 +297,8 @@ pub fn build_test_nodes() -> TestParams { seed: Seed::Seed64(seed), extra_config: ExtraConfig::Dummy(dummy_wallet_config), }; - rt.block_on(async move { Wallet::new_with_runtime(rt_clone, wallet_config).await.unwrap() }) + + Wallet::new(wallet_config).await.unwrap() }; #[cfg(feature = "_cashu-tests")] @@ -332,7 +323,7 @@ pub fn build_test_nodes() -> TestParams { tmp.to_str().unwrap().to_string(), FeeReserve { min_fee_reserve: Default::default(), percent_fee_reserve: 0.0 }, vec![cdk_addr.into()], - Some(rt.clone()), + None, ) .unwrap(); let cdk = Arc::new(cdk); @@ -345,7 +336,7 @@ pub fn build_test_nodes() -> TestParams { let bitcoind_clone = Arc::clone(&bitcoind); let lsp_listen_clone = lsp_listen.clone(); - let mint = rt.block_on(async move { + let mint = { // build mint let mem_db = Arc::new(cdk_sqlite::mint::memory::empty().await.unwrap()); let mut mint_seed: [u8; 64] = [0; 64]; @@ -417,9 +408,7 @@ pub fn build_test_nodes() -> TestParams { .await; mint - }); - - let rt_clone = Arc::clone(&rt); + }; let tmp = temp_dir().join(format!("orange-test-{test_id}/wallet")); let wallet_config = WalletConfig { @@ -442,16 +431,13 @@ pub fn build_test_nodes() -> TestParams { unit: orange_sdk::CurrencyUnit::Sat, }), }; - let wallet = - rt.block_on( - async move { Wallet::new_with_runtime(rt_clone, wallet_config).await.unwrap() }, - ); + let wallet = Wallet::new(wallet_config).await.unwrap(); - return TestParams { wallet, lsp, third_party, bitcoind, rt, _mint: mint }; + return TestParams { wallet, lsp, third_party, bitcoind, _mint: mint }; }; #[cfg(not(feature = "_cashu-tests"))] - TestParams { wallet, lsp, third_party, bitcoind, rt } + TestParams { wallet, lsp, third_party, bitcoind } } pub async fn open_channel_from_lsp(wallet: &orange_sdk::Wallet, payer: Arc) -> Amount { From 85e3a30fe6b26d37a65ea43f9751d02a0aa13fbd Mon Sep 17 00:00:00 2001 From: benthecarman Date: Wed, 22 Oct 2025 13:44:45 -0500 Subject: [PATCH 03/20] fix cashu tests --- orange-sdk/Cargo.toml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/orange-sdk/Cargo.toml b/orange-sdk/Cargo.toml index a026b30..bb063ff 100644 --- a/orange-sdk/Cargo.toml +++ b/orange-sdk/Cargo.toml @@ -32,18 +32,18 @@ reqwest = { version = "0.12.23", default-features = false, features = ["rustls-t breez-sdk-spark = { git = "https://github.com/breez/spark-sdk.git", rev = "1f2e9995230cd582d6b4aa7d06d76b99defb635e", 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 = "0d7c1b2b9b73964e497763c4a315133db83d1c53", default-features = false, features = ["wallet"], optional = true } +cdk = { git = "https://github.com/benthecarman/cdk.git", rev = "6a8f046a405137cfbb3a77f1f045c3a238e62c00", default-features = false, features = ["wallet"], optional = true } serde_json = { version = "1.0", optional = true } async-trait = "0.1" log = "0.4.28" corepc-node = { version = "0.8.0", features = ["29_0", "download"], optional = true } -cdk-ldk-node = { git = "https://github.com/benthecarman/cdk.git", rev = "0d7c1b2b9b73964e497763c4a315133db83d1c53", optional = true } -cdk-sqlite = { git = "https://github.com/benthecarman/cdk.git", rev = "0d7c1b2b9b73964e497763c4a315133db83d1c53", optional = true } -cdk-axum = { git = "https://github.com/benthecarman/cdk.git", rev = "0d7c1b2b9b73964e497763c4a315133db83d1c53", optional = true } +cdk-ldk-node = { git = "https://github.com/benthecarman/cdk.git", rev = "6a8f046a405137cfbb3a77f1f045c3a238e62c00", optional = true } +cdk-sqlite = { git = "https://github.com/benthecarman/cdk.git", rev = "6a8f046a405137cfbb3a77f1f045c3a238e62c00", optional = true } +cdk-axum = { git = "https://github.com/benthecarman/cdk.git", rev = "6a8f046a405137cfbb3a77f1f045c3a238e62c00", optional = true } axum = { version = "0.8.1", optional = true } uniffi = { version = "0.29", features = ["cli", "tokio"], optional = true } [dev-dependencies] -test-log = "0.2.18" \ No newline at end of file +test-log = "0.2.18" From ff3b6481d8f0c1c4504642042fa15191db3b7b36 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Mon, 27 Oct 2025 15:18:41 -0500 Subject: [PATCH 04/20] Fixup changes --- orange-sdk/src/event.rs | 15 +----- .../src/ffi/bitcoin_payment_instructions.rs | 3 ++ orange-sdk/src/lib.rs | 5 +- orange-sdk/tests/integration_tests.rs | 50 +++++++++---------- orange-sdk/tests/test_utils.rs | 2 +- 5 files changed, 35 insertions(+), 40 deletions(-) diff --git a/orange-sdk/src/event.rs b/orange-sdk/src/event.rs index decdf8e..af068bd 100644 --- a/orange-sdk/src/event.rs +++ b/orange-sdk/src/event.rs @@ -14,7 +14,7 @@ use ldk_node::payment::{ConfirmationStatus, PaymentKind}; use ldk_node::{CustomTlvRecord, DynStore, UserChannelId}; use std::collections::VecDeque; -use std::sync::{Arc, Condvar, Mutex}; +use std::sync::{Arc, Mutex}; use std::task::{Poll, Waker}; use tokio::sync::watch; @@ -190,7 +190,6 @@ impl_writeable_tlv_based_enum!(Event, pub struct EventQueue { queue: Arc>>, waker: Arc>>, - notifier: Condvar, kv_store: Arc, logger: Arc, } @@ -199,8 +198,7 @@ impl EventQueue { pub(crate) fn new(kv_store: Arc, logger: Arc) -> Self { let queue = Arc::new(Mutex::new(VecDeque::new())); let waker = Arc::new(Mutex::new(None)); - let notifier = Condvar::new(); - Self { queue, waker, notifier, kv_store, logger } + Self { queue, waker, kv_store, logger } } pub(crate) fn add_event(&self, event: Event) -> Result<(), ldk_node::lightning::io::Error> { @@ -210,8 +208,6 @@ impl EventQueue { self.persist_queue(&locked_queue)?; } - self.notifier.notify_one(); - if let Some(waker) = self.waker.lock().unwrap().take() { waker.wake(); } @@ -227,19 +223,12 @@ impl EventQueue { EventFuture { event_queue: Arc::clone(&self.queue), waker: Arc::clone(&self.waker) }.await } - pub(crate) fn wait_next_event(&self) -> Event { - let locked_queue = - self.notifier.wait_while(self.queue.lock().unwrap(), |queue| queue.is_empty()).unwrap(); - locked_queue.front().unwrap().clone() - } - pub(crate) fn event_handled(&self) -> Result<(), ldk_node::lightning::io::Error> { { let mut locked_queue = self.queue.lock().unwrap(); locked_queue.pop_front(); self.persist_queue(&locked_queue)?; } - self.notifier.notify_one(); if let Some(waker) = self.waker.lock().unwrap().take() { waker.wake(); diff --git a/orange-sdk/src/ffi/bitcoin_payment_instructions.rs b/orange-sdk/src/ffi/bitcoin_payment_instructions.rs index 2a49b09..97dbe08 100644 --- a/orange-sdk/src/ffi/bitcoin_payment_instructions.rs +++ b/orange-sdk/src/ffi/bitcoin_payment_instructions.rs @@ -85,6 +85,7 @@ pub enum ParseError { UnknownRequiredParameter, HrnResolutionError(String), InstructionsExpired, + InvalidLnurl(String), } impl Display for ParseError { @@ -108,6 +109,7 @@ impl Display for ParseError { write!(f, "Human readable name resolution error: {}", e) }, ParseError::InstructionsExpired => write!(f, "Payment instructions have expired"), + ParseError::InvalidLnurl(e) => write!(f, "Invalid LNURL: {}", e), } } } @@ -131,6 +133,7 @@ impl From for ParseError { ParseError::HrnResolutionError(msg.to_string()) }, BPIParseError::InstructionsExpired => ParseError::InstructionsExpired, + BPIParseError::InvalidLnurl(msg) => ParseError::InvalidLnurl(msg.to_string()), } } } diff --git a/orange-sdk/src/lib.rs b/orange-sdk/src/lib.rs index 1c5a072..3a08da8 100644 --- a/orange-sdk/src/lib.rs +++ b/orange-sdk/src/lib.rs @@ -1300,7 +1300,10 @@ impl Wallet { /// **Caution:** Users must handle events as quickly as possible to prevent a large event backlog, /// which can increase the memory footprint of [`Wallet`]. pub fn wait_next_event(&self) -> Event { - self.inner.event_queue.wait_next_event() + let fut = self.inner.event_queue.next_event_async(); + // We use our runtime for the sync variant to ensure `tokio::task::block_in_place` is + // always called if we'd ever hit this in an outer runtime context. + self.inner.runtime.block_on(fut) } /// Confirm the last retrieved event handled. diff --git a/orange-sdk/tests/integration_tests.rs b/orange-sdk/tests/integration_tests.rs index b662368..5ce31b8 100644 --- a/orange-sdk/tests/integration_tests.rs +++ b/orange-sdk/tests/integration_tests.rs @@ -18,7 +18,7 @@ use std::time::Duration; mod test_utils; -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[tokio::test(flavor = "multi_thread")] async fn test_node_start() { let TestParams { wallet, .. } = build_test_nodes().await; @@ -88,7 +88,7 @@ async fn test_receive_to_trusted() { lsp.stop().unwrap(); } -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[tokio::test(flavor = "multi_thread")] async fn test_pay_from_trusted() { let TestParams { wallet, third_party, lsp, .. } = build_test_nodes().await; @@ -167,7 +167,7 @@ async fn test_pay_from_trusted() { } } -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[tokio::test(flavor = "multi_thread")] async fn test_sweep_to_ln() { let TestParams { wallet, lsp, third_party, .. } = build_test_nodes().await; @@ -320,7 +320,7 @@ async fn test_sweep_to_ln() { ); } -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[tokio::test(flavor = "multi_thread")] async fn test_receive_to_ln() { let TestParams { wallet, third_party, .. } = build_test_nodes().await; @@ -358,7 +358,7 @@ async fn test_receive_to_ln() { ); } -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[tokio::test(flavor = "multi_thread")] async fn test_receive_to_onchain() { let TestParams { wallet, lsp, bitcoind, third_party, .. } = build_test_nodes().await; @@ -556,7 +556,7 @@ async fn run_test_pay_lightning_from_self_custody(amountless: bool) { && p.amount_msat == Some(amount.milli_sats()))); } -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[tokio::test(flavor = "multi_thread")] async fn test_pay_lightning_from_self_custody() { run_test_pay_lightning_from_self_custody(false).await; run_test_pay_lightning_from_self_custody(true).await; @@ -644,13 +644,13 @@ async fn run_test_pay_bolt12_from_self_custody(amountless: bool) { && p.amount_msat == Some(amount.milli_sats()))); } -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[tokio::test(flavor = "multi_thread")] async fn test_pay_bolt12_from_self_custody() { run_test_pay_bolt12_from_self_custody(false).await; run_test_pay_bolt12_from_self_custody(true).await; } -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[tokio::test(flavor = "multi_thread")] async fn test_pay_onchain_from_self_custody() { let TestParams { wallet, bitcoind, third_party, .. } = build_test_nodes().await; @@ -753,7 +753,7 @@ async fn test_pay_onchain_from_self_custody() { .await; } -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[tokio::test(flavor = "multi_thread")] async fn test_force_close_handling() { let TestParams { wallet, lsp, bitcoind, third_party, .. } = build_test_nodes().await; @@ -794,7 +794,7 @@ async fn test_force_close_handling() { assert!(!rebalancing); } -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[tokio::test(flavor = "multi_thread")] async fn test_close_all_channels() { let TestParams { wallet, lsp, bitcoind, third_party, .. } = build_test_nodes().await; @@ -828,7 +828,7 @@ async fn test_close_all_channels() { assert!(!rebalancing); } -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[tokio::test(flavor = "multi_thread")] async fn test_threshold_boundary_trusted_balance_limit() { let TestParams { wallet, third_party, .. } = build_test_nodes().await; @@ -904,7 +904,7 @@ async fn test_threshold_boundary_trusted_balance_limit() { ); } -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[tokio::test(flavor = "multi_thread")] async fn test_threshold_boundary_rebalance_min() { let TestParams { wallet, third_party, .. } = build_test_nodes().await; @@ -977,7 +977,7 @@ async fn test_threshold_boundary_rebalance_min() { ); } -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[tokio::test(flavor = "multi_thread")] async fn test_threshold_boundary_onchain_receive_threshold() { let TestParams { wallet, .. } = build_test_nodes().await; @@ -1037,7 +1037,7 @@ async fn test_threshold_boundary_onchain_receive_threshold() { } } -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[tokio::test(flavor = "multi_thread")] async fn test_threshold_combinations_and_edge_cases() { let TestParams { wallet, .. } = build_test_nodes().await; @@ -1095,7 +1095,7 @@ async fn test_threshold_combinations_and_edge_cases() { } } -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[tokio::test(flavor = "multi_thread")] async fn test_invalid_payment_instructions() { let TestParams { wallet, third_party, .. } = build_test_nodes().await; @@ -1156,7 +1156,7 @@ async fn test_invalid_payment_instructions() { assert_eq!(txs.len(), 0, "Failed payments should not be recorded in transaction list"); } -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[tokio::test(flavor = "multi_thread")] async fn test_payment_with_expired_invoice() { let TestParams { wallet, third_party, .. } = build_test_nodes().await; @@ -1185,7 +1185,7 @@ async fn test_payment_with_expired_invoice() { assert!(matches!(parse_result.unwrap_err(), ParseError::InstructionsExpired)); } -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[tokio::test(flavor = "multi_thread")] async fn test_payment_network_mismatch() { let TestParams { wallet, bitcoind, .. } = build_test_nodes().await; @@ -1238,7 +1238,7 @@ async fn test_payment_network_mismatch() { ); } -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[tokio::test(flavor = "multi_thread")] async fn test_concurrent_payments() { let TestParams { wallet, bitcoind, third_party, .. } = build_test_nodes().await; @@ -1410,7 +1410,7 @@ async fn test_concurrent_payments() { ); } -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[tokio::test(flavor = "multi_thread")] async fn test_concurrent_receive_operations() { let TestParams { wallet, third_party, .. } = build_test_nodes().await; @@ -1465,7 +1465,7 @@ async fn test_concurrent_receive_operations() { assert_eq!(incoming_count, 2, "Should have exactly 2 incoming transactions"); } -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[tokio::test(flavor = "multi_thread")] async fn test_balance_consistency_under_load() { let TestParams { wallet, third_party, .. } = build_test_nodes().await; @@ -1516,7 +1516,7 @@ async fn test_balance_consistency_under_load() { } } -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[tokio::test(flavor = "multi_thread")] async fn test_invalid_tunables_relationships() { let TestParams { wallet, .. } = build_test_nodes().await; @@ -1593,7 +1593,7 @@ async fn test_invalid_tunables_relationships() { } } -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[tokio::test(flavor = "multi_thread")] async fn test_extreme_amount_handling() { let TestParams { wallet, .. } = build_test_nodes().await; @@ -1658,7 +1658,7 @@ async fn test_extreme_amount_handling() { // On-chain address depends on threshold, not msat precision } -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[tokio::test(flavor = "multi_thread")] async fn test_wallet_configuration_validation() { let TestParams { wallet, .. } = build_test_nodes().await; @@ -1721,7 +1721,7 @@ async fn test_wallet_configuration_validation() { ); } -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[tokio::test(flavor = "multi_thread")] async fn test_edge_case_payment_instruction_parsing() { let TestParams { wallet, third_party, .. } = build_test_nodes().await; @@ -1771,7 +1771,7 @@ async fn test_edge_case_payment_instruction_parsing() { } } -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[tokio::test(flavor = "multi_thread")] async fn test_lsp_connectivity_fallback() { let TestParams { wallet, lsp, bitcoind, third_party, .. } = build_test_nodes().await; diff --git a/orange-sdk/tests/test_utils.rs b/orange-sdk/tests/test_utils.rs index 1bb759a..545ebdb 100644 --- a/orange-sdk/tests/test_utils.rs +++ b/orange-sdk/tests/test_utils.rs @@ -64,7 +64,7 @@ pub async fn wait_next_event(wallet: &orange_sdk::Wallet) -> orange_sdk::Event { fn create_bitcoind(uuid: Uuid) -> Bitcoind { let mut conf = Conf::default(); conf.args.push("-txindex"); - conf.args.push("-rpcworkqueue=100"); + conf.args.push("-rpcworkqueue=200"); conf.staticdir = Some(temp_dir().join(format!("orange-test-{uuid}/bitcoind"))); let bitcoind = Bitcoind::with_conf(corepc_node::downloaded_exe_path().unwrap(), &conf) .unwrap_or_else(|_| panic!("Failed to start bitcoind for test {uuid}")); From 2f9a9d00c2dea9ed0b37032ee7d6f25a8a1e2dad Mon Sep 17 00:00:00 2001 From: benthecarman Date: Tue, 28 Oct 2025 14:21:55 -0500 Subject: [PATCH 05/20] update fork with fixes --- orange-sdk/Cargo.toml | 10 +++++----- orange-sdk/src/runtime.rs | 12 +++++++++--- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/orange-sdk/Cargo.toml b/orange-sdk/Cargo.toml index bb063ff..85c0c43 100644 --- a/orange-sdk/Cargo.toml +++ b/orange-sdk/Cargo.toml @@ -23,7 +23,7 @@ _cashu-tests = ["_test-utils", "cdk-ldk-node", "cdk/mint", "cdk-sqlite", "cdk-ax [dependencies] graduated-rebalancer = { path = "../graduated-rebalancer", version = "0.1.0" } -ldk-node = { git = "https://github.com/lightningdevkit/ldk-node.git", rev = "dd519080318ba099280b4353fb6335c587a67cbd" } +ldk-node = { git = "https://github.com/lightningdevkit/ldk-node.git", rev = "6d91eabcb11bf3b32f0a2e5f43b55c98d84ba1f0" } lightning-macros = "0.2.0-beta1" bitcoin-payment-instructions = { workspace = true } chrono = { version = "0.4", default-features = false } @@ -32,15 +32,15 @@ reqwest = { version = "0.12.23", default-features = false, features = ["rustls-t breez-sdk-spark = { git = "https://github.com/breez/spark-sdk.git", rev = "1f2e9995230cd582d6b4aa7d06d76b99defb635e", 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 = "6a8f046a405137cfbb3a77f1f045c3a238e62c00", default-features = false, features = ["wallet"], optional = true } +cdk = { git = "https://github.com/benthecarman/cdk.git", rev = "62ac47d51d6c74c7b847ae0c19e7c84899efe872", default-features = false, features = ["wallet"], optional = true } serde_json = { version = "1.0", optional = true } async-trait = "0.1" log = "0.4.28" corepc-node = { version = "0.8.0", features = ["29_0", "download"], optional = true } -cdk-ldk-node = { git = "https://github.com/benthecarman/cdk.git", rev = "6a8f046a405137cfbb3a77f1f045c3a238e62c00", optional = true } -cdk-sqlite = { git = "https://github.com/benthecarman/cdk.git", rev = "6a8f046a405137cfbb3a77f1f045c3a238e62c00", optional = true } -cdk-axum = { git = "https://github.com/benthecarman/cdk.git", rev = "6a8f046a405137cfbb3a77f1f045c3a238e62c00", optional = true } +cdk-ldk-node = { git = "https://github.com/benthecarman/cdk.git", rev = "62ac47d51d6c74c7b847ae0c19e7c84899efe872", optional = true } +cdk-sqlite = { git = "https://github.com/benthecarman/cdk.git", rev = "62ac47d51d6c74c7b847ae0c19e7c84899efe872", optional = true } +cdk-axum = { git = "https://github.com/benthecarman/cdk.git", rev = "62ac47d51d6c74c7b847ae0c19e7c84899efe872", optional = true } axum = { version = "0.8.1", optional = true } uniffi = { version = "0.29", features = ["cli", "tokio"], optional = true } diff --git a/orange-sdk/src/runtime.rs b/orange-sdk/src/runtime.rs index ac1ed67..f26e8da 100644 --- a/orange-sdk/src/runtime.rs +++ b/orange-sdk/src/runtime.rs @@ -6,7 +6,7 @@ // accordance with one or both of these licenses. use ldk_node::lightning::util::logger::Logger as _; -use ldk_node::lightning::{log_debug, log_error, log_trace}; +use ldk_node::lightning::{log_debug, log_error, log_trace, log_warn}; use std::future::Future; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -95,14 +95,20 @@ impl Runtime { pub fn abort_cancellable_background_tasks(&self) { let mut tasks = core::mem::take(&mut *self.cancellable_background_tasks.lock().unwrap()); - debug_assert!(!tasks.is_empty(), "Expected some cancellable background_tasks"); + if tasks.is_empty() { + log_warn!(self.logger, "Stopping cancellable background tasks with no tasks"); + return; + } tasks.abort_all(); self.block_on(async { while tasks.join_next().await.is_some() {} }) } pub fn wait_on_background_tasks(&self) { let mut tasks = core::mem::take(&mut *self.background_tasks.lock().unwrap()); - debug_assert!(!tasks.is_empty(), "Expected some background_tasks"); + if tasks.is_empty() { + log_warn!(self.logger, "Stopping background tasks with no tasks"); + return; + } self.block_on(async { loop { let timeout_fut = tokio::time::timeout( From 21eaa0d61feb6c507f068f59a9ef550cbaff8dcb Mon Sep 17 00:00:00 2001 From: benthecarman Date: Tue, 28 Oct 2025 16:46:40 -0500 Subject: [PATCH 06/20] Tests passing! --- justfile | 10 +- orange-sdk/tests/integration_tests.rs | 3314 +++++++++++++------------ orange-sdk/tests/test_utils.rs | 40 +- 3 files changed, 1783 insertions(+), 1581 deletions(-) diff --git a/justfile b/justfile index e85fcab..e55c86f 100644 --- a/justfile +++ b/justfile @@ -2,10 +2,16 @@ default: @just --list test *args: - cargo test {{ args }} --features _test-utils -p orange-sdk -- --nocapture + #!/usr/bin/env bash + THREADS=$(($(nproc) / 2)) + if [ $THREADS -lt 1 ]; then THREADS=1; fi + cargo test {{ args }} --features _test-utils -p orange-sdk -- --test-threads=$THREADS test-cashu *args: - cargo test {{ args }} --features _cashu-tests -p orange-sdk + #!/usr/bin/env bash + THREADS=$(($(nproc) / 2)) + if [ $THREADS -lt 1 ]; then THREADS=1; fi + cargo test {{ args }} --features _cashu-tests -p orange-sdk -- --test-threads=$THREADS cli: cd examples/cli && cargo run diff --git a/orange-sdk/tests/integration_tests.rs b/orange-sdk/tests/integration_tests.rs index 5ce31b8..2c652b1 100644 --- a/orange-sdk/tests/integration_tests.rs +++ b/orange-sdk/tests/integration_tests.rs @@ -1,8 +1,6 @@ #![cfg(feature = "_test-utils")] -use crate::test_utils::{ - TestParams, build_test_nodes, generate_blocks, open_channel_from_lsp, wait_next_event, -}; +use crate::test_utils::{generate_blocks, open_channel_from_lsp, wait_next_event}; use bitcoin_payment_instructions::amount::Amount; use bitcoin_payment_instructions::http_resolver::HTTPHrnResolver; use bitcoin_payment_instructions::{ParseError, PaymentInstructions}; @@ -10,7 +8,7 @@ use ldk_node::NodeError; use ldk_node::bitcoin::Network; use ldk_node::lightning_invoice::{Bolt11InvoiceDescription, Description}; use ldk_node::payment::{ConfirmationStatus, PaymentDirection, PaymentStatus}; -use log::{debug, info}; +use log::info; use orange_sdk::bitcoin::hashes::Hash; use orange_sdk::{Event, PaymentInfo, PaymentType, TxStatus, WalletError}; use std::sync::Arc; @@ -20,540 +18,564 @@ mod test_utils; #[tokio::test(flavor = "multi_thread")] async fn test_node_start() { - let TestParams { wallet, .. } = build_test_nodes().await; - - let bal = wallet.get_balance().await.unwrap(); - assert_eq!(bal.available_balance(), Amount::ZERO); - assert_eq!(bal.pending_balance, Amount::ZERO); + test_utils::run_test(|params| async move { + let bal = params.wallet.get_balance().await.unwrap(); + assert_eq!(bal.available_balance(), Amount::ZERO); + assert_eq!(bal.pending_balance, Amount::ZERO); + }) + .await; } #[tokio::test(flavor = "multi_thread")] #[test_log::test] async fn test_receive_to_trusted() { - let TestParams { wallet, third_party, lsp, .. } = build_test_nodes().await; + test_utils::run_test(|params| async move { + let wallet = Arc::clone(¶ms.wallet); + let third_party = Arc::clone(¶ms.third_party); - let starting_bal = wallet.get_balance().await.unwrap(); - assert_eq!(starting_bal.available_balance(), Amount::ZERO); - assert_eq!(starting_bal.pending_balance, Amount::ZERO); + let starting_bal = wallet.get_balance().await.unwrap(); + assert_eq!(starting_bal.available_balance(), Amount::ZERO); + assert_eq!(starting_bal.pending_balance, Amount::ZERO); - let recv_amt = Amount::from_sats(100).unwrap(); + let recv_amt = Amount::from_sats(100).unwrap(); - let limit = wallet.get_tunables(); - assert!(recv_amt < limit.trusted_balance_limit); + let limit = wallet.get_tunables(); + assert!(recv_amt < limit.trusted_balance_limit); - let uri = wallet.get_single_use_receive_uri(Some(recv_amt)).await.unwrap(); - let payment_id = third_party.bolt11_payment().send(&uri.invoice, None).unwrap(); + let uri = wallet.get_single_use_receive_uri(Some(recv_amt)).await.unwrap(); + let payment_id = third_party.bolt11_payment().send(&uri.invoice, None).unwrap(); - // wait for payment success from payer side - let p = Arc::clone(&third_party); - test_utils::wait_for_condition("payer payment success", || { - let res = p.payment(&payment_id).is_some_and(|p| p.status == PaymentStatus::Succeeded); - async move { res } - }) - .await; + // wait for payment success from payer side + let p = Arc::clone(&third_party); + test_utils::wait_for_condition("payer payment success", || { + let res = p.payment(&payment_id).is_some_and(|p| p.status == PaymentStatus::Succeeded); + async move { res } + }) + .await; + + // wait for balance update on wallet side + test_utils::wait_for_condition("wallet balance update after receive", || async { + wallet.get_balance().await.unwrap().available_balance() > Amount::ZERO + }) + .await; - // wait for balance update on wallet side - test_utils::wait_for_condition("wallet balance update after receive", || async { - wallet.get_balance().await.unwrap().available_balance() > Amount::ZERO + let txs = wallet.list_transactions().await.unwrap(); + assert_eq!(txs.len(), 1); + let tx = txs.into_iter().next().unwrap(); + + // Comprehensive validation for trusted wallet receive + assert_eq!(tx.fee, Some(Amount::ZERO), "Trusted wallet receive should have zero fees"); + assert!(!tx.outbound, "Incoming payment should not be outbound"); + assert_eq!(tx.status, TxStatus::Completed, "Payment should be completed"); + assert_eq!( + tx.payment_type, + PaymentType::IncomingLightning {}, + "Payment type should be IncomingLightning" + ); + assert_ne!(tx.time_since_epoch, Duration::ZERO, "Time should be set"); + assert_eq!( + tx.amount, + Some(recv_amt), + "Amount should equal received amount for trusted wallet (no fees deducted)" + ); + + info!("test passed"); }) .await; - - let txs = wallet.list_transactions().await.unwrap(); - assert_eq!(txs.len(), 1); - let tx = txs.into_iter().next().unwrap(); - - // Comprehensive validation for trusted wallet receive - assert_eq!(tx.fee, Some(Amount::ZERO), "Trusted wallet receive should have zero fees"); - assert!(!tx.outbound, "Incoming payment should not be outbound"); - assert_eq!(tx.status, TxStatus::Completed, "Payment should be completed"); - assert_eq!( - tx.payment_type, - PaymentType::IncomingLightning {}, - "Payment type should be IncomingLightning" - ); - assert_ne!(tx.time_since_epoch, Duration::ZERO, "Time should be set"); - assert_eq!( - tx.amount, - Some(recv_amt), - "Amount should equal received amount for trusted wallet (no fees deducted)" - ); - - info!("test passed"); - - debug!("stopping wallet"); - wallet.stop().await; - debug!("stopping third party"); - third_party.stop().unwrap(); - debug!("stopping lsp"); - lsp.stop().unwrap(); } #[tokio::test(flavor = "multi_thread")] async fn test_pay_from_trusted() { - let TestParams { wallet, third_party, lsp, .. } = build_test_nodes().await; + test_utils::run_test(|params| async move { + let wallet = Arc::clone(¶ms.wallet); + let third_party = Arc::clone(¶ms.third_party); + let lsp = Arc::clone(¶ms.lsp); - let starting_bal = wallet.get_balance().await.unwrap(); - assert_eq!(starting_bal.available_balance(), Amount::ZERO); - assert_eq!(starting_bal.pending_balance, Amount::ZERO); + let starting_bal = wallet.get_balance().await.unwrap(); + assert_eq!(starting_bal.available_balance(), Amount::ZERO); + assert_eq!(starting_bal.pending_balance, Amount::ZERO); - let recv_amt = Amount::from_sats(100).unwrap(); + let recv_amt = Amount::from_sats(100).unwrap(); - let limit = wallet.get_tunables(); - assert!(recv_amt < limit.trusted_balance_limit); + let limit = wallet.get_tunables(); + assert!(recv_amt < limit.trusted_balance_limit); - let uri = wallet.get_single_use_receive_uri(Some(recv_amt)).await.unwrap(); - let payment_id = third_party.bolt11_payment().send(&uri.invoice, None).unwrap(); + let uri = wallet.get_single_use_receive_uri(Some(recv_amt)).await.unwrap(); + let payment_id = third_party.bolt11_payment().send(&uri.invoice, None).unwrap(); - // wait for payment success from payer side - let p = Arc::clone(&third_party); - test_utils::wait_for_condition("payer payment success", || { - let res = p.payment(&payment_id).is_some_and(|p| p.status == PaymentStatus::Succeeded); - async move { res } - }) - .await; + // wait for payment success from payer side + let p = Arc::clone(&third_party); + test_utils::wait_for_condition("payer payment success", || { + let res = p.payment(&payment_id).is_some_and(|p| p.status == PaymentStatus::Succeeded); + async move { res } + }) + .await; - // wait for balance update on wallet side - test_utils::wait_for_condition("wallet balance update after receive", || async { - wallet.get_balance().await.unwrap().trusted > Amount::ZERO - }) - .await; + // wait for balance update on wallet side + test_utils::wait_for_condition("wallet balance update after receive", || async { + wallet.get_balance().await.unwrap().trusted > Amount::ZERO + }) + .await; - let bal = wallet.get_balance().await.unwrap(); - - let event = wait_next_event(&wallet).await; - match event { - Event::PaymentReceived { .. } => {}, - e => panic!("Expected PaymentReceived event, got {e:?}"), - } - - let desc = Bolt11InvoiceDescription::Direct(Description::empty()); - let amount = Amount::from_sats(10).unwrap(); - let invoice = lsp.bolt11_payment().receive(amount.milli_sats(), &desc, 300).unwrap(); - - let instr = wallet.parse_payment_instructions(invoice.to_string().as_str()).await.unwrap(); - let info = PaymentInfo::build(instr, None).unwrap(); - wallet.pay(&info).await.unwrap(); - - let event = wait_next_event(&wallet).await; - match event { - Event::PaymentSuccessful { payment_hash, fee_paid_msat, .. } => { - assert!(fee_paid_msat.is_some()); - assert_eq!(payment_hash.0, invoice.payment_hash().to_byte_array()); - }, - e => panic!("Expected PaymentSuccessful event, got {e:?}"), - } - - // wait for balance update on wallet side - test_utils::wait_for_condition("wallet balance update after send", || async { - wallet.get_balance().await.unwrap().trusted < bal.trusted - }) - .await; + let bal = wallet.get_balance().await.unwrap(); + + let event = wait_next_event(&wallet).await; + match event { + Event::PaymentReceived { .. } => {}, + e => panic!("Expected PaymentReceived event, got {e:?}"), + } - let txs = wallet.list_transactions().await.unwrap(); - assert_eq!(txs.len(), 2); - let tx = txs.into_iter().find(|t| t.outbound).unwrap(); + let desc = Bolt11InvoiceDescription::Direct(Description::empty()); + let amount = Amount::from_sats(10).unwrap(); + let invoice = lsp.bolt11_payment().receive(amount.milli_sats(), &desc, 300).unwrap(); - assert!(tx.fee.is_some(), "Trusted wallet send should have fees set"); - assert!(tx.outbound, "Outgoing payment should be outbound"); - assert_eq!(tx.status, TxStatus::Completed, "Payment should be completed"); - match tx.payment_type { - PaymentType::OutgoingLightningBolt11 { payment_preimage } => { - assert!( - payment_preimage.is_some_and(|p| p.0 != [0; 32]), - "Completed payment should have payment_preimage" - ); - }, - pt => panic!("Payment type should be OutgoingLightningBolt11, got {pt:?}"), - } + let instr = wallet.parse_payment_instructions(invoice.to_string().as_str()).await.unwrap(); + let info = PaymentInfo::build(instr, None).unwrap(); + wallet.pay(&info).await.unwrap(); + + let event = wait_next_event(&wallet).await; + match event { + Event::PaymentSuccessful { payment_hash, fee_paid_msat, .. } => { + assert!(fee_paid_msat.is_some()); + assert_eq!(payment_hash.0, invoice.payment_hash().to_byte_array()); + }, + e => panic!("Expected PaymentSuccessful event, got {e:?}"), + } + + // wait for balance update on wallet side + test_utils::wait_for_condition("wallet balance update after send", || async { + wallet.get_balance().await.unwrap().trusted < bal.trusted + }) + .await; + + let txs = wallet.list_transactions().await.unwrap(); + assert_eq!(txs.len(), 2); + let tx = txs.into_iter().find(|t| t.outbound).unwrap(); + + assert!(tx.fee.is_some(), "Trusted wallet send should have fees set"); + assert!(tx.outbound, "Outgoing payment should be outbound"); + assert_eq!(tx.status, TxStatus::Completed, "Payment should be completed"); + match tx.payment_type { + PaymentType::OutgoingLightningBolt11 { payment_preimage } => { + assert!( + payment_preimage.is_some_and(|p| p.0 != [0; 32]), + "Completed payment should have payment_preimage" + ); + }, + pt => panic!("Payment type should be OutgoingLightningBolt11, got {pt:?}"), + } + }) + .await; } #[tokio::test(flavor = "multi_thread")] async fn test_sweep_to_ln() { - let TestParams { wallet, lsp, third_party, .. } = build_test_nodes().await; + test_utils::run_test(|params| async move { + let wallet = Arc::clone(¶ms.wallet); + let lsp = Arc::clone(¶ms.lsp); + let third_party = Arc::clone(¶ms.third_party); - let starting_bal = wallet.get_balance().await.unwrap(); - assert_eq!(starting_bal.available_balance(), Amount::ZERO); - assert_eq!(starting_bal.pending_balance, Amount::ZERO); + let starting_bal = wallet.get_balance().await.unwrap(); + assert_eq!(starting_bal.available_balance(), Amount::ZERO); + assert_eq!(starting_bal.pending_balance, Amount::ZERO); - let starting_lsp_channels = lsp.list_channels(); + let starting_lsp_channels = lsp.list_channels(); - // start with receiving half the limit - let limit = wallet.get_tunables(); - let recv_amt = Amount::from_milli_sats(limit.trusted_balance_limit.milli_sats() / 2).unwrap(); + // start with receiving half the limit + let limit = wallet.get_tunables(); + let recv_amt = + Amount::from_milli_sats(limit.trusted_balance_limit.milli_sats() / 2).unwrap(); - let uri = wallet.get_single_use_receive_uri(Some(recv_amt)).await.unwrap(); - third_party.bolt11_payment().send(&uri.invoice, None).unwrap(); + let uri = wallet.get_single_use_receive_uri(Some(recv_amt)).await.unwrap(); + third_party.bolt11_payment().send(&uri.invoice, None).unwrap(); - // wait for balance update on wallet side - test_utils::wait_for_condition("wallet balance update after receive", || async { - wallet.get_balance().await.unwrap().available_balance() > Amount::ZERO - }) - .await; + // wait for balance update on wallet side + test_utils::wait_for_condition("wallet balance update after receive", || async { + wallet.get_balance().await.unwrap().available_balance() > Amount::ZERO + }) + .await; - let event = wait_next_event(&wallet).await; - match event { - Event::PaymentReceived { .. } => {}, - e => panic!("Expected PaymentReceived event, got {e:?}"), - } + let event = wait_next_event(&wallet).await; + match event { + Event::PaymentReceived { .. } => {}, + e => panic!("Expected PaymentReceived event, got {e:?}"), + } - let intermediate_amt = wallet.get_balance().await.unwrap().available_balance(); + let intermediate_amt = wallet.get_balance().await.unwrap().available_balance(); - // next receive the limit to trigger the rebalance - let recv_amt = Amount::from_milli_sats(limit.trusted_balance_limit.milli_sats()).unwrap(); + // next receive the limit to trigger the rebalance + let recv_amt = Amount::from_milli_sats(limit.trusted_balance_limit.milli_sats()).unwrap(); - let uri = wallet.get_single_use_receive_uri(Some(recv_amt)).await.unwrap(); - third_party.bolt11_payment().send(&uri.invoice, None).unwrap(); + let uri = wallet.get_single_use_receive_uri(Some(recv_amt)).await.unwrap(); + third_party.bolt11_payment().send(&uri.invoice, None).unwrap(); - // wait for balance update on wallet side - test_utils::wait_for_condition("wallet balance update after receive", || async { - wallet.get_balance().await.unwrap().available_balance() > intermediate_amt - }) - .await; + // wait for balance update on wallet side + test_utils::wait_for_condition("wallet balance update after receive", || async { + wallet.get_balance().await.unwrap().available_balance() > intermediate_amt + }) + .await; + + // receive to trusted wallet + let event = wait_next_event(&wallet).await; + match event { + Event::PaymentReceived { .. } => {}, + e => panic!("Expected PaymentReceived event, got {e:?}"), + } + + let event = wait_next_event(&wallet).await; + assert!( + matches!(event, Event::RebalanceInitiated { .. }), + "Expected RebalanceInitiated event but got {event:?}" + ); + + // wait for rebalance + test_utils::wait_for_condition("wait for new channel to be opened", || async { + starting_lsp_channels.len() < lsp.list_channels().len() + }) + .await; + + // wait for payment received + let event = wait_next_event(&wallet).await; + match event { + Event::ChannelOpened { counterparty_node_id, .. } => { + assert_eq!(counterparty_node_id, lsp.node_id()); + }, + _ => panic!("Expected ChannelOpened event"), + } - // receive to trusted wallet - let event = wait_next_event(&wallet).await; - match event { - Event::PaymentReceived { .. } => {}, - e => panic!("Expected PaymentReceived event, got {e:?}"), - } - - let event = wait_next_event(&wallet).await; - assert!( - matches!(event, Event::RebalanceInitiated { .. }), - "Expected RebalanceInitiated event but got {event:?}" - ); - - // wait for rebalance - test_utils::wait_for_condition("wait for new channel to be opened", || async { - starting_lsp_channels.len() < lsp.list_channels().len() + let expect_amt = intermediate_amt.saturating_add(recv_amt); + + let event = wait_next_event(&wallet).await; + match event { + Event::PaymentReceived { payment_id, amount_msat, lsp_fee_msats, .. } => { + assert!(matches!(payment_id, orange_sdk::PaymentId::SelfCustodial(_))); + assert!(lsp_fee_msats.is_some()); + assert_eq!(amount_msat, expect_amt.milli_sats() - lsp_fee_msats.unwrap()); + }, + e => panic!("Expected RebalanceSuccessful event, got {e:?}"), + } + + let event = wait_next_event(&wallet).await; + match event { + Event::RebalanceSuccessful { amount_msat, fee_msat, .. } => { + assert!(fee_msat > 0); + assert_eq!(amount_msat, expect_amt.milli_sats()); + }, + e => panic!("Expected RebalanceSuccessful event, got {e:?}"), + } + + let event = wallet.next_event(); + assert!(event.is_none(), "No more events expected, got {event:?}"); + + // Verify transaction list has correct amounts + let txs = wallet.list_transactions().await.unwrap(); + + // Should have 2 incoming payments (the two receives) - rebalances should not appear as transactions + let incoming_txs: Vec<_> = txs.into_iter().filter(|tx| !tx.outbound).collect(); + assert_eq!(incoming_txs.len(), 2, "Should have exactly 2 incoming transactions"); + + // First transaction should be the smaller amount (intermediate_amt) + let first_tx = &incoming_txs[0]; + assert_eq!( + first_tx.amount, + Some(intermediate_amt), + "First transaction should be the intermediate amount" + ); + assert!(!first_tx.outbound, "First transaction should be incoming"); + assert_eq!(first_tx.status, TxStatus::Completed, "First transaction should be completed"); + assert_eq!( + first_tx.payment_type, + PaymentType::IncomingLightning {}, + "First transaction should be IncomingLightning" + ); + assert_eq!( + first_tx.fee, + Some(Amount::ZERO), + "First transaction should have zero fee (trusted wallet)" + ); + + // Second transaction should be the larger amount (recv_amt) but may have fees if it went through Lightning + let second_tx = &incoming_txs[1]; + assert_eq!( + second_tx.amount, + Some(recv_amt), + "Second transaction should be the received amount" + ); + assert!(!second_tx.outbound, "Second transaction should be incoming"); + assert_eq!(second_tx.status, TxStatus::Completed, "Second transaction should be completed"); + assert_eq!( + second_tx.payment_type, + PaymentType::IncomingLightning {}, + "Second transaction should be IncomingLightning" + ); + // The second payment triggers rebalance and should have a fee + assert!( + second_tx.fee.is_some_and(|a| a > Amount::ZERO), + "Second transaction should have fee greater than 0" + ); + + // Verify total amounts match expected + let total_tx_amount = first_tx.amount.unwrap().saturating_add(second_tx.amount.unwrap()); + let expected_total = intermediate_amt.saturating_add(recv_amt); + assert_eq!( + total_tx_amount, + expected_total, + "Total transaction amounts should match expected total: {} msat", + expected_total.milli_sats() + ); }) .await; - - // wait for payment received - let event = wait_next_event(&wallet).await; - match event { - Event::ChannelOpened { counterparty_node_id, .. } => { - assert_eq!(counterparty_node_id, lsp.node_id()); - }, - _ => panic!("Expected ChannelOpened event"), - } - - let expect_amt = intermediate_amt.saturating_add(recv_amt); - - let event = wait_next_event(&wallet).await; - match event { - Event::PaymentReceived { payment_id, amount_msat, lsp_fee_msats, .. } => { - assert!(matches!(payment_id, orange_sdk::PaymentId::SelfCustodial(_))); - assert!(lsp_fee_msats.is_some()); - assert_eq!(amount_msat, expect_amt.milli_sats() - lsp_fee_msats.unwrap()); - }, - e => panic!("Expected RebalanceSuccessful event, got {e:?}"), - } - - let event = wait_next_event(&wallet).await; - match event { - Event::RebalanceSuccessful { amount_msat, fee_msat, .. } => { - assert!(fee_msat > 0); - assert_eq!(amount_msat, expect_amt.milli_sats()); - }, - e => panic!("Expected RebalanceSuccessful event, got {e:?}"), - } - - let event = wallet.next_event(); - assert!(event.is_none(), "No more events expected, got {event:?}"); - - // Verify transaction list has correct amounts - let txs = wallet.list_transactions().await.unwrap(); - - // Should have 2 incoming payments (the two receives) - rebalances should not appear as transactions - let incoming_txs: Vec<_> = txs.into_iter().filter(|tx| !tx.outbound).collect(); - assert_eq!(incoming_txs.len(), 2, "Should have exactly 2 incoming transactions"); - - // First transaction should be the smaller amount (intermediate_amt) - let first_tx = &incoming_txs[0]; - assert_eq!( - first_tx.amount, - Some(intermediate_amt), - "First transaction should be the intermediate amount" - ); - assert!(!first_tx.outbound, "First transaction should be incoming"); - assert_eq!(first_tx.status, TxStatus::Completed, "First transaction should be completed"); - assert_eq!( - first_tx.payment_type, - PaymentType::IncomingLightning {}, - "First transaction should be IncomingLightning" - ); - assert_eq!( - first_tx.fee, - Some(Amount::ZERO), - "First transaction should have zero fee (trusted wallet)" - ); - - // Second transaction should be the larger amount (recv_amt) but may have fees if it went through Lightning - let second_tx = &incoming_txs[1]; - assert_eq!( - second_tx.amount, - Some(recv_amt), - "Second transaction should be the received amount" - ); - assert!(!second_tx.outbound, "Second transaction should be incoming"); - assert_eq!(second_tx.status, TxStatus::Completed, "Second transaction should be completed"); - assert_eq!( - second_tx.payment_type, - PaymentType::IncomingLightning {}, - "Second transaction should be IncomingLightning" - ); - // The second payment triggers rebalance and should have a fee - assert!( - second_tx.fee.is_some_and(|a| a > Amount::ZERO), - "Second transaction should have fee greater than 0" - ); - - // Verify total amounts match expected - let total_tx_amount = first_tx.amount.unwrap().saturating_add(second_tx.amount.unwrap()); - let expected_total = intermediate_amt.saturating_add(recv_amt); - assert_eq!( - total_tx_amount, - expected_total, - "Total transaction amounts should match expected total: {} msat", - expected_total.milli_sats() - ); } #[tokio::test(flavor = "multi_thread")] async fn test_receive_to_ln() { - let TestParams { wallet, third_party, .. } = build_test_nodes().await; - - let recv_amt = open_channel_from_lsp(&wallet, Arc::clone(&third_party)).await; - - let txs = wallet.list_transactions().await.unwrap(); - assert_eq!(txs.len(), 1); - let tx = txs.into_iter().next().unwrap(); - - // Comprehensive validation for lightning receive - assert!( - tx.fee.is_some_and(|f| f > Amount::ZERO), - "Lightning receive should have non-zero fees" - ); - assert!(!tx.outbound, "Incoming payment should not be outbound"); - assert_eq!(tx.status, TxStatus::Completed, "Payment should be completed"); - assert_eq!( - tx.payment_type, - PaymentType::IncomingLightning {}, - "Payment type should be IncomingLightning" - ); - assert_ne!(tx.time_since_epoch, Duration::ZERO, "Time should be set"); - assert_eq!( - tx.amount, - Some(recv_amt.saturating_sub(tx.fee.unwrap())), - "Amount should be received amount minus fees" - ); - - // Validate fee is reasonable (should be less than 10% of received amount) - let fee_ratio = tx.fee.unwrap().milli_sats() as f64 / recv_amt.milli_sats() as f64; - assert!( - fee_ratio < 0.1, - "Fee should be less than 10% of received amount, got {:.2}%", - fee_ratio * 100.0 - ); + test_utils::run_test(|params| async move { + let wallet = Arc::clone(¶ms.wallet); + let third_party = Arc::clone(¶ms.third_party); + + let recv_amt = open_channel_from_lsp(&wallet, Arc::clone(&third_party)).await; + + let txs = wallet.list_transactions().await.unwrap(); + assert_eq!(txs.len(), 1); + let tx = txs.into_iter().next().unwrap(); + + // Comprehensive validation for lightning receive + assert!( + tx.fee.is_some_and(|f| f > Amount::ZERO), + "Lightning receive should have non-zero fees" + ); + assert!(!tx.outbound, "Incoming payment should not be outbound"); + assert_eq!(tx.status, TxStatus::Completed, "Payment should be completed"); + assert_eq!( + tx.payment_type, + PaymentType::IncomingLightning {}, + "Payment type should be IncomingLightning" + ); + assert_ne!(tx.time_since_epoch, Duration::ZERO, "Time should be set"); + assert_eq!( + tx.amount, + Some(recv_amt.saturating_sub(tx.fee.unwrap())), + "Amount should be received amount minus fees" + ); + + // Validate fee is reasonable (should be less than 10% of received amount) + let fee_ratio = tx.fee.unwrap().milli_sats() as f64 / recv_amt.milli_sats() as f64; + assert!( + fee_ratio < 0.1, + "Fee should be less than 10% of received amount, got {:.2}%", + fee_ratio * 100.0 + ); + }) + .await; } #[tokio::test(flavor = "multi_thread")] async fn test_receive_to_onchain() { - let TestParams { wallet, lsp, bitcoind, third_party, .. } = build_test_nodes().await; + test_utils::run_test(|params| async move { + let wallet = Arc::clone(¶ms.wallet); + let lsp = Arc::clone(¶ms.lsp); + let bitcoind = Arc::clone(¶ms.bitcoind); + let third_party = Arc::clone(¶ms.third_party); + + let starting_bal = wallet.get_balance().await.unwrap(); + assert_eq!(starting_bal.available_balance(), Amount::ZERO); + assert_eq!(starting_bal.pending_balance, Amount::ZERO); + + let recv_amt = Amount::from_sats(200_000).unwrap(); + + let uri = wallet.get_single_use_receive_uri(Some(recv_amt)).await.unwrap(); + let sent_txid = third_party + .onchain_payment() + .send_to_address(&uri.address.unwrap(), recv_amt.sats().unwrap(), None) + .unwrap(); + + // confirm transaction + generate_blocks(&bitcoind, 6); - let starting_bal = wallet.get_balance().await.unwrap(); - assert_eq!(starting_bal.available_balance(), Amount::ZERO); - assert_eq!(starting_bal.pending_balance, Amount::ZERO); + // check we received on-chain, should be pending + // wait for payment success + test_utils::wait_for_condition("pending balance to update", || async { + // onchain balance is always listed as pending until we splice it into the channel. + wallet.get_balance().await.unwrap().pending_balance == recv_amt + }) + .await; - let recv_amt = Amount::from_sats(200_000).unwrap(); + let event = wait_next_event(&wallet).await; - let uri = wallet.get_single_use_receive_uri(Some(recv_amt)).await.unwrap(); - let sent_txid = third_party - .onchain_payment() - .send_to_address(&uri.address.unwrap(), recv_amt.sats().unwrap(), None) - .unwrap(); + match event { + Event::OnchainPaymentReceived { txid, amount_sat, status, .. } => { + assert_eq!(txid, sent_txid); + assert_eq!(amount_sat, recv_amt.sats().unwrap()); + assert!(matches!(status, ConfirmationStatus::Confirmed { .. })); + }, + _ => panic!("Expected OnchainPaymentReceived event"), + } + + assert!(wallet.next_event().is_none()); + + let txs = wallet.list_transactions().await.unwrap(); + assert_eq!(txs.len(), 1); + let tx = txs.into_iter().next().unwrap(); + + // Comprehensive validation for on-chain receive + assert!(!tx.outbound, "Incoming payment should not be outbound"); + assert_eq!(tx.status, TxStatus::Completed, "Payment should be completed"); + assert_eq!( + tx.payment_type, + PaymentType::IncomingOnChain { txid: Some(sent_txid) }, + "Payment type should be IncomingOnChain with correct txid" + ); + assert_ne!(tx.time_since_epoch, Duration::ZERO, "Time should be set"); + assert_eq!(tx.amount, Some(recv_amt), "Amount should equal received amount"); + assert_eq!( + tx.fee, + Some(Amount::ZERO), + "On-chain receive should have zero fees (paid by sender)" + ); + + // a rebalance should be initiated, we need to mine the channel opening transaction + // for it to be confirmed and reflected in the wallet's history + generate_blocks(&bitcoind, 6); + tokio::time::sleep(Duration::from_secs(5)).await; // wait for sync + generate_blocks(&bitcoind, 6); // confirm the channel opening transaction + tokio::time::sleep(Duration::from_secs(5)).await; // wait for sync - // confirm transaction - generate_blocks(&bitcoind, 6); + // wait for rebalance to be initiated + let event = wait_next_event(&wallet).await; + match event { + Event::ChannelOpened { counterparty_node_id, .. } => { + assert_eq!(counterparty_node_id, lsp.node_id()); + }, + _ => panic!("Expected ChannelOpened event"), + } + + let txs = wallet.list_transactions().await.unwrap(); + assert_eq!(txs.len(), 1); + let tx = txs.into_iter().next().unwrap(); + + // Comprehensive validation for on-chain receive after rebalance + assert!(!tx.outbound, "Incoming payment should not be outbound"); + assert_eq!(tx.status, TxStatus::Completed, "Payment should be completed"); + assert_eq!( + tx.payment_type, + PaymentType::IncomingOnChain { txid: Some(sent_txid) }, + "Payment type should be IncomingOnChain with correct txid" + ); + assert_ne!(tx.time_since_epoch, Duration::ZERO, "Time should be set"); + assert_eq!(tx.amount, Some(recv_amt), "Amount should equal received amount"); + assert!( + tx.fee.unwrap() > Amount::ZERO, + "On-chain receive should have rebalance fees after channel opening" + ); + + // Validate fee is reasonable (should be less than 5% of received amount for rebalance) + let fee_ratio = tx.fee.unwrap().milli_sats() as f64 / recv_amt.milli_sats() as f64; + assert!( + fee_ratio < 0.05, + "Rebalance fee should be less than 5% of received amount, got {:.2}%", + fee_ratio * 100.0 + ); - // check we received on-chain, should be pending - // wait for payment success - test_utils::wait_for_condition("pending balance to update", || async { - // onchain balance is always listed as pending until we splice it into the channel. - wallet.get_balance().await.unwrap().pending_balance == recv_amt + assert!(wallet.next_event().is_none()); }) .await; - - let event = wait_next_event(&wallet).await; - - match event { - Event::OnchainPaymentReceived { txid, amount_sat, status, .. } => { - assert_eq!(txid, sent_txid); - assert_eq!(amount_sat, recv_amt.sats().unwrap()); - assert!(matches!(status, ConfirmationStatus::Confirmed { .. })); - }, - _ => panic!("Expected OnchainPaymentReceived event"), - } - - assert!(wallet.next_event().is_none()); - - let txs = wallet.list_transactions().await.unwrap(); - assert_eq!(txs.len(), 1); - let tx = txs.into_iter().next().unwrap(); - - // Comprehensive validation for on-chain receive - assert!(!tx.outbound, "Incoming payment should not be outbound"); - assert_eq!(tx.status, TxStatus::Completed, "Payment should be completed"); - assert_eq!( - tx.payment_type, - PaymentType::IncomingOnChain { txid: Some(sent_txid) }, - "Payment type should be IncomingOnChain with correct txid" - ); - assert_ne!(tx.time_since_epoch, Duration::ZERO, "Time should be set"); - assert_eq!(tx.amount, Some(recv_amt), "Amount should equal received amount"); - assert_eq!( - tx.fee, - Some(Amount::ZERO), - "On-chain receive should have zero fees (paid by sender)" - ); - - // a rebalance should be initiated, we need to mine the channel opening transaction - // for it to be confirmed and reflected in the wallet's history - generate_blocks(&bitcoind, 6); - tokio::time::sleep(Duration::from_secs(5)).await; // wait for sync - generate_blocks(&bitcoind, 6); // confirm the channel opening transaction - tokio::time::sleep(Duration::from_secs(5)).await; // wait for sync - - // wait for rebalance to be initiated - let event = wait_next_event(&wallet).await; - match event { - Event::ChannelOpened { counterparty_node_id, .. } => { - assert_eq!(counterparty_node_id, lsp.node_id()); - }, - _ => panic!("Expected ChannelOpened event"), - } - - let txs = wallet.list_transactions().await.unwrap(); - assert_eq!(txs.len(), 1); - let tx = txs.into_iter().next().unwrap(); - - // Comprehensive validation for on-chain receive after rebalance - assert!(!tx.outbound, "Incoming payment should not be outbound"); - assert_eq!(tx.status, TxStatus::Completed, "Payment should be completed"); - assert_eq!( - tx.payment_type, - PaymentType::IncomingOnChain { txid: Some(sent_txid) }, - "Payment type should be IncomingOnChain with correct txid" - ); - assert_ne!(tx.time_since_epoch, Duration::ZERO, "Time should be set"); - assert_eq!(tx.amount, Some(recv_amt), "Amount should equal received amount"); - assert!( - tx.fee.unwrap() > Amount::ZERO, - "On-chain receive should have rebalance fees after channel opening" - ); - - // Validate fee is reasonable (should be less than 5% of received amount for rebalance) - let fee_ratio = tx.fee.unwrap().milli_sats() as f64 / recv_amt.milli_sats() as f64; - assert!( - fee_ratio < 0.05, - "Rebalance fee should be less than 5% of received amount, got {:.2}%", - fee_ratio * 100.0 - ); - - assert!(wallet.next_event().is_none()); } async fn run_test_pay_lightning_from_self_custody(amountless: bool) { - let TestParams { wallet, bitcoind, third_party, .. } = build_test_nodes().await; + test_utils::run_test(|params| async move { + let wallet = Arc::clone(¶ms.wallet); + let bitcoind = Arc::clone(¶ms.bitcoind); + let third_party = Arc::clone(¶ms.third_party); + + // get a channel so we can make a payment + open_channel_from_lsp(&wallet, Arc::clone(&third_party)).await; + + // wait for sync + generate_blocks(&bitcoind, 6); + test_utils::wait_for_condition("wallet sync after channel open", || async { + wallet.channels().iter().any(|a| a.confirmations.is_some_and(|c| c > 0) && a.is_usable) + && third_party + .list_channels() + .iter() + .any(|a| a.confirmations.is_some_and(|c| c > 0) && a.is_usable) + }) + .await; - // get a channel so we can make a payment - open_channel_from_lsp(&wallet, Arc::clone(&third_party)).await; + let starting_bal = wallet.get_balance().await.unwrap(); - // wait for sync - generate_blocks(&bitcoind, 6); - test_utils::wait_for_condition("wallet sync after channel open", || async { - wallet.channels().iter().any(|a| a.confirmations.is_some_and(|c| c > 0) && a.is_usable) - && third_party - .list_channels() - .iter() - .any(|a| a.confirmations.is_some_and(|c| c > 0) && a.is_usable) + let amount = Amount::from_sats(1_000).unwrap(); + + // get invoice from third party node + let desc = Bolt11InvoiceDescription::Direct(Description::empty()); + let invoice = if amountless { + third_party.bolt11_payment().receive_variable_amount(&desc, 300).unwrap() + } else { + third_party.bolt11_payment().receive(amount.milli_sats(), &desc, 300).unwrap() + }; + + let instr = wallet.parse_payment_instructions(invoice.to_string().as_str()).await.unwrap(); + let info = PaymentInfo::build(instr, Some(amount)).unwrap(); + wallet.pay(&info).await.unwrap(); + + let event = wait_next_event(&wallet).await; + assert!( + matches!(event, Event::PaymentSuccessful { .. }), + "Expected PaymentSuccessful event but got {event:?}" + ); + assert_eq!(wallet.next_event(), None); + + // check the payment is correct + let payments = wallet.list_transactions().await.unwrap(); + let payment = payments.into_iter().find(|p| p.outbound).unwrap(); + + // Comprehensive validation for outgoing lightning bolt11 payment + assert_eq!(payment.amount, Some(amount), "Amount should equal sent amount"); + assert!( + payment.fee.is_some_and(|f| f > Amount::ZERO), + "Lightning payment should have non-zero fees" + ); + assert!(payment.outbound, "Outgoing payment should be outbound"); + assert!( + matches!(payment.payment_type, PaymentType::OutgoingLightningBolt11 { .. }), + "Payment type should be OutgoingLightningBolt11" + ); + assert_eq!(payment.status, TxStatus::Completed, "Payment should be completed"); + assert_ne!(payment.time_since_epoch, Duration::ZERO, "Time should be set"); + match payment.payment_type { + PaymentType::OutgoingLightningBolt11 { payment_preimage } => { + assert!( + payment_preimage.is_some_and(|p| p.0 != [0; 32]), + "Completed payment should have payment_preimage" + ); + }, + pt => panic!("Payment type should be OutgoingLightningBolt11, got {pt:?}"), + } + + // Validate fee is reasonable + let fee_ratio = payment.fee.unwrap().milli_sats() as f64 / amount.milli_sats() as f64; + assert!( + fee_ratio < 0.1, + "Fee should be less than 10% of sent amount, got {:.2}%", + fee_ratio * 100.0 + ); + + // Check that payment_type contains payment_preimage for completed payments + if let PaymentType::OutgoingLightningBolt11 { payment_preimage } = &payment.payment_type { + assert!(payment_preimage.is_some(), "Completed payment should have payment_preimage"); + } + + // check balance left our wallet + let bal = wallet.get_balance().await.unwrap(); + assert_eq!(bal.pending_balance, Amount::ZERO); + assert!(bal.available_balance() <= starting_bal.available_balance().saturating_sub(amount)); + + // make sure 3rd party node got payment + let payments = third_party.list_payments(); + assert!(payments.iter().any(|p| p.status == PaymentStatus::Succeeded + && p.direction == PaymentDirection::Inbound + && p.amount_msat == Some(amount.milli_sats()))); }) .await; - - let starting_bal = wallet.get_balance().await.unwrap(); - - let amount = Amount::from_sats(1_000).unwrap(); - - // get invoice from third party node - let desc = Bolt11InvoiceDescription::Direct(Description::empty()); - let invoice = if amountless { - third_party.bolt11_payment().receive_variable_amount(&desc, 300).unwrap() - } else { - third_party.bolt11_payment().receive(amount.milli_sats(), &desc, 300).unwrap() - }; - - let instr = wallet.parse_payment_instructions(invoice.to_string().as_str()).await.unwrap(); - let info = PaymentInfo::build(instr, Some(amount)).unwrap(); - wallet.pay(&info).await.unwrap(); - - let event = wait_next_event(&wallet).await; - assert!( - matches!(event, Event::PaymentSuccessful { .. }), - "Expected PaymentSuccessful event but got {event:?}" - ); - assert_eq!(wallet.next_event(), None); - - // check the payment is correct - let payments = wallet.list_transactions().await.unwrap(); - let payment = payments.into_iter().find(|p| p.outbound).unwrap(); - - // Comprehensive validation for outgoing lightning bolt11 payment - assert_eq!(payment.amount, Some(amount), "Amount should equal sent amount"); - assert!( - payment.fee.is_some_and(|f| f > Amount::ZERO), - "Lightning payment should have non-zero fees" - ); - assert!(payment.outbound, "Outgoing payment should be outbound"); - assert!( - matches!(payment.payment_type, PaymentType::OutgoingLightningBolt11 { .. }), - "Payment type should be OutgoingLightningBolt11" - ); - assert_eq!(payment.status, TxStatus::Completed, "Payment should be completed"); - assert_ne!(payment.time_since_epoch, Duration::ZERO, "Time should be set"); - match payment.payment_type { - PaymentType::OutgoingLightningBolt11 { payment_preimage } => { - assert!( - payment_preimage.is_some_and(|p| p.0 != [0; 32]), - "Completed payment should have payment_preimage" - ); - }, - pt => panic!("Payment type should be OutgoingLightningBolt11, got {pt:?}"), - } - - // Validate fee is reasonable - let fee_ratio = payment.fee.unwrap().milli_sats() as f64 / amount.milli_sats() as f64; - assert!( - fee_ratio < 0.1, - "Fee should be less than 10% of sent amount, got {:.2}%", - fee_ratio * 100.0 - ); - - // Check that payment_type contains payment_preimage for completed payments - if let PaymentType::OutgoingLightningBolt11 { payment_preimage } = &payment.payment_type { - assert!(payment_preimage.is_some(), "Completed payment should have payment_preimage"); - } - - // check balance left our wallet - let bal = wallet.get_balance().await.unwrap(); - assert_eq!(bal.pending_balance, Amount::ZERO); - assert!(bal.available_balance() <= starting_bal.available_balance().saturating_sub(amount)); - - // make sure 3rd party node got payment - let payments = third_party.list_payments(); - assert!(payments.iter().any(|p| p.status == PaymentStatus::Succeeded - && p.direction == PaymentDirection::Inbound - && p.amount_msat == Some(amount.milli_sats()))); } #[tokio::test(flavor = "multi_thread")] @@ -563,85 +585,90 @@ async fn test_pay_lightning_from_self_custody() { } async fn run_test_pay_bolt12_from_self_custody(amountless: bool) { - let TestParams { wallet, bitcoind, third_party, .. } = build_test_nodes().await; + test_utils::run_test(|params| async move { + let wallet = Arc::clone(¶ms.wallet); + let bitcoind = Arc::clone(¶ms.bitcoind); + let third_party = Arc::clone(¶ms.third_party); + + // get a channel so we can make a payment + open_channel_from_lsp(&wallet, Arc::clone(&third_party)).await; + + // wait for sync + generate_blocks(&bitcoind, 6); + test_utils::wait_for_condition("wallet sync after channel open", || async { + wallet.channels().iter().any(|a| a.confirmations.is_some_and(|c| c > 0) && a.is_usable) + && third_party + .list_channels() + .iter() + .any(|a| a.confirmations.is_some_and(|c| c > 0) && a.is_usable) + }) + .await; - // get a channel so we can make a payment - open_channel_from_lsp(&wallet, Arc::clone(&third_party)).await; + let starting_bal = wallet.get_balance().await.unwrap(); - // wait for sync - generate_blocks(&bitcoind, 6); - test_utils::wait_for_condition("wallet sync after channel open", || async { - wallet.channels().iter().any(|a| a.confirmations.is_some_and(|c| c > 0) && a.is_usable) - && third_party - .list_channels() - .iter() - .any(|a| a.confirmations.is_some_and(|c| c > 0) && a.is_usable) + let amount = Amount::from_sats(1_000).unwrap(); + + // get offer from third party node + let offer = if amountless { + third_party.bolt12_payment().receive_variable_amount("test", None).unwrap() + } else { + third_party.bolt12_payment().receive(amount.milli_sats(), "test", None, None).unwrap() + }; + + let instr = wallet.parse_payment_instructions(offer.to_string().as_str()).await.unwrap(); + let info = PaymentInfo::build(instr, Some(amount)).unwrap(); + wallet.pay(&info).await.unwrap(); + + let event = wait_next_event(&wallet).await; + assert!( + matches!(event, Event::PaymentSuccessful { .. }), + "Expected PaymentSuccessful event but got {event:?}" + ); + assert_eq!(wallet.next_event(), None); + + // check the payment is correct + let payments = wallet.list_transactions().await.unwrap(); + let payment = payments.into_iter().find(|p| p.outbound).unwrap(); + + // Comprehensive validation for outgoing lightning bolt12 payment + assert_eq!(payment.amount, Some(amount), "Amount should equal sent amount"); + assert!( + payment.fee.is_some_and(|f| f > Amount::ZERO), + "Lightning payment should have non-zero fees" + ); + assert!(payment.outbound, "Outgoing payment should be outbound"); + assert!( + matches!(payment.payment_type, PaymentType::OutgoingLightningBolt12 { .. }), + "Payment type should be OutgoingLightningBolt12" + ); + assert_eq!(payment.status, TxStatus::Completed, "Payment should be completed"); + assert_ne!(payment.time_since_epoch, Duration::ZERO, "Time should be set"); + + // Validate fee is reasonable + let fee_ratio = payment.fee.unwrap().milli_sats() as f64 / amount.milli_sats() as f64; + assert!( + fee_ratio < 0.1, + "Fee should be less than 10% of sent amount, got {:.2}%", + fee_ratio * 100.0 + ); + + // Check that payment_type contains payment_preimage for completed payments + if let PaymentType::OutgoingLightningBolt12 { payment_preimage } = &payment.payment_type { + assert!(payment_preimage.is_some(), "Completed payment should have payment_preimage"); + } + + // check balance left our wallet + let bal = wallet.get_balance().await.unwrap(); + assert_eq!(bal.pending_balance, Amount::ZERO); + assert!(bal.available_balance() <= starting_bal.available_balance().saturating_sub(amount)); + + // make sure 3rd party node got payment + let payments = third_party.list_payments(); + assert!(payments.iter().any(|p| p.status == PaymentStatus::Succeeded + && p.direction == PaymentDirection::Inbound + && p.amount_msat == Some(amount.milli_sats()))); }) .await; - - let starting_bal = wallet.get_balance().await.unwrap(); - - let amount = Amount::from_sats(1_000).unwrap(); - - // get offer from third party node - let offer = if amountless { - third_party.bolt12_payment().receive_variable_amount("test", None).unwrap() - } else { - third_party.bolt12_payment().receive(amount.milli_sats(), "test", None, None).unwrap() - }; - - let instr = wallet.parse_payment_instructions(offer.to_string().as_str()).await.unwrap(); - let info = PaymentInfo::build(instr, Some(amount)).unwrap(); - wallet.pay(&info).await.unwrap(); - - let event = wait_next_event(&wallet).await; - assert!( - matches!(event, Event::PaymentSuccessful { .. }), - "Expected PaymentSuccessful event but got {event:?}" - ); - assert_eq!(wallet.next_event(), None); - - // check the payment is correct - let payments = wallet.list_transactions().await.unwrap(); - let payment = payments.into_iter().find(|p| p.outbound).unwrap(); - - // Comprehensive validation for outgoing lightning bolt12 payment - assert_eq!(payment.amount, Some(amount), "Amount should equal sent amount"); - assert!( - payment.fee.is_some_and(|f| f > Amount::ZERO), - "Lightning payment should have non-zero fees" - ); - assert!(payment.outbound, "Outgoing payment should be outbound"); - assert!( - matches!(payment.payment_type, PaymentType::OutgoingLightningBolt12 { .. }), - "Payment type should be OutgoingLightningBolt12" - ); - assert_eq!(payment.status, TxStatus::Completed, "Payment should be completed"); - assert_ne!(payment.time_since_epoch, Duration::ZERO, "Time should be set"); - - // Validate fee is reasonable - let fee_ratio = payment.fee.unwrap().milli_sats() as f64 / amount.milli_sats() as f64; - assert!( - fee_ratio < 0.1, - "Fee should be less than 10% of sent amount, got {:.2}%", - fee_ratio * 100.0 - ); - - // Check that payment_type contains payment_preimage for completed payments - if let PaymentType::OutgoingLightningBolt12 { payment_preimage } = &payment.payment_type { - assert!(payment_preimage.is_some(), "Completed payment should have payment_preimage"); - } - - // check balance left our wallet - let bal = wallet.get_balance().await.unwrap(); - assert_eq!(bal.pending_balance, Amount::ZERO); - assert!(bal.available_balance() <= starting_bal.available_balance().saturating_sub(amount)); - - // make sure 3rd party node got payment - let payments = third_party.list_payments(); - assert!(payments.iter().any(|p| p.status == PaymentStatus::Succeeded - && p.direction == PaymentDirection::Inbound - && p.amount_msat == Some(amount.milli_sats()))); } #[tokio::test(flavor = "multi_thread")] @@ -652,1193 +679,1332 @@ async fn test_pay_bolt12_from_self_custody() { #[tokio::test(flavor = "multi_thread")] async fn test_pay_onchain_from_self_custody() { - let TestParams { wallet, bitcoind, third_party, .. } = build_test_nodes().await; - - // disable rebalancing so we have on-chain funds - wallet.set_rebalance_enabled(false); - - let starting_bal = wallet.get_balance().await.unwrap(); - assert_eq!(starting_bal.available_balance(), Amount::ZERO); - assert_eq!(starting_bal.pending_balance, Amount::ZERO); - - // fund wallet with on-chain - let recv_amount = Amount::from_sats(1_000_000).unwrap(); - let uri = wallet.get_single_use_receive_uri(Some(recv_amount)).await.unwrap(); - bitcoind - .client - .send_to_address( - &uri.address.unwrap(), - ldk_node::bitcoin::Amount::from_sat(recv_amount.sats().unwrap()), - ) - .unwrap(); + test_utils::run_test(|params| async move { + let wallet = Arc::clone(¶ms.wallet); + let bitcoind = Arc::clone(¶ms.bitcoind); + let third_party = Arc::clone(¶ms.third_party); + + // disable rebalancing so we have on-chain funds + wallet.set_rebalance_enabled(false); + + let starting_bal = wallet.get_balance().await.unwrap(); + assert_eq!(starting_bal.available_balance(), Amount::ZERO); + assert_eq!(starting_bal.pending_balance, Amount::ZERO); + + // fund wallet with on-chain + let recv_amount = Amount::from_sats(1_000_000).unwrap(); + let uri = wallet.get_single_use_receive_uri(Some(recv_amount)).await.unwrap(); + bitcoind + .client + .send_to_address( + &uri.address.unwrap(), + ldk_node::bitcoin::Amount::from_sat(recv_amount.sats().unwrap()), + ) + .unwrap(); - // confirm tx - generate_blocks(&bitcoind, 6); + // confirm tx + generate_blocks(&bitcoind, 6); - // wait for node to sync and see the balance update - test_utils::wait_for_condition("wallet sync after on-chain receive", || async { - wallet.get_balance().await.unwrap().pending_balance > starting_bal.pending_balance - }) - .await; + // wait for node to sync and see the balance update + test_utils::wait_for_condition("wallet sync after on-chain receive", || async { + wallet.get_balance().await.unwrap().pending_balance > starting_bal.pending_balance + }) + .await; + + // get address from third party node + let addr = third_party.onchain_payment().new_address().unwrap(); + let send_amount = Amount::from_sats(100_000).unwrap(); - // get address from third party node - let addr = third_party.onchain_payment().new_address().unwrap(); - let send_amount = Amount::from_sats(100_000).unwrap(); + let instr = wallet.parse_payment_instructions(addr.to_string().as_str()).await.unwrap(); + let info = PaymentInfo::build(instr, Some(send_amount)).unwrap(); + wallet.pay(&info).await.unwrap(); - let instr = wallet.parse_payment_instructions(addr.to_string().as_str()).await.unwrap(); - let info = PaymentInfo::build(instr, Some(send_amount)).unwrap(); - wallet.pay(&info).await.unwrap(); + // sleep for a second to wait for proper broadcast + tokio::time::sleep(Duration::from_secs(1)).await; - // confirm the tx - generate_blocks(&bitcoind, 6); + // confirm the tx + generate_blocks(&bitcoind, 6); - // wait for payment to complete - test_utils::wait_for_condition("on-chain payment completion", || async { + // sleep for a second to wait for sync + tokio::time::sleep(Duration::from_secs(1)).await; + + // wait for payment to complete + test_utils::wait_for_condition("on-chain payment completion", || async { + let payments = wallet.list_transactions().await.unwrap(); + let payment = payments.into_iter().find(|p| p.outbound); + if payment.as_ref().is_some_and(|p| p.status == TxStatus::Failed) { + panic!("Payment failed"); + } + payment.is_some_and(|p| p.status == TxStatus::Completed) + }) + .await; + + // check the payment is correct let payments = wallet.list_transactions().await.unwrap(); - let payment = payments.into_iter().find(|p| p.outbound); - if payment.as_ref().is_some_and(|p| p.status == TxStatus::Failed) { - panic!("Payment failed"); + let payment = payments.into_iter().find(|p| p.outbound).unwrap(); + + // Comprehensive validation for outgoing on-chain payment + assert_eq!(payment.amount, Some(send_amount), "Amount should equal sent amount"); + assert!( + payment.fee.is_some_and(|f| f > Amount::ZERO), + "On-chain payment should have non-zero fees" + ); + assert!(payment.outbound, "Outgoing payment should be outbound"); + assert!( + matches!(payment.payment_type, PaymentType::OutgoingOnChain { .. }), + "Payment type should be OutgoingOnChain" + ); + assert_eq!(payment.status, TxStatus::Completed, "Payment should be completed"); + assert_ne!(payment.time_since_epoch, Duration::ZERO, "Time should be set"); + + // Validate fee is reasonable for on-chain (should be less than 1% of sent amount) + let fee_ratio = payment.fee.unwrap().milli_sats() as f64 / send_amount.milli_sats() as f64; + assert!( + fee_ratio < 0.01, + "On-chain fee should be less than 1% of sent amount, got {:.2}%", + fee_ratio * 100.0 + ); + + // Check that payment_type contains txid for completed payments + if let PaymentType::OutgoingOnChain { txid } = &payment.payment_type { + assert!(txid.is_some(), "Completed on-chain payment should have txid"); } - payment.is_some_and(|p| p.status == TxStatus::Completed) - }) - .await; - // check the payment is correct - let payments = wallet.list_transactions().await.unwrap(); - let payment = payments.into_iter().find(|p| p.outbound).unwrap(); - - // Comprehensive validation for outgoing on-chain payment - assert_eq!(payment.amount, Some(send_amount), "Amount should equal sent amount"); - assert!( - payment.fee.is_some_and(|f| f > Amount::ZERO), - "On-chain payment should have non-zero fees" - ); - assert!(payment.outbound, "Outgoing payment should be outbound"); - assert!( - matches!(payment.payment_type, PaymentType::OutgoingOnChain { .. }), - "Payment type should be OutgoingOnChain" - ); - assert_eq!(payment.status, TxStatus::Completed, "Payment should be completed"); - assert_ne!(payment.time_since_epoch, Duration::ZERO, "Time should be set"); - - // Validate fee is reasonable for on-chain (should be less than 1% of sent amount) - let fee_ratio = payment.fee.unwrap().milli_sats() as f64 / send_amount.milli_sats() as f64; - assert!( - fee_ratio < 0.01, - "On-chain fee should be less than 1% of sent amount, got {:.2}%", - fee_ratio * 100.0 - ); - - // Check that payment_type contains txid for completed payments - if let PaymentType::OutgoingOnChain { txid } = &payment.payment_type { - assert!(txid.is_some(), "Completed on-chain payment should have txid"); - } - - // check balance left our wallet - let bal = wallet.get_balance().await.unwrap(); - assert_eq!( - bal.pending_balance, - recv_amount.saturating_sub(send_amount).saturating_sub(payment.fee.unwrap()) - ); - - // Wait for third party node to receive it - test_utils::wait_for_condition("on-chain payment received", || async { - let payments = third_party.list_payments(); - payments.iter().any(|p| { - p.status == PaymentStatus::Succeeded - && p.direction == PaymentDirection::Inbound - && p.amount_msat == Some(send_amount.milli_sats()) + // check balance left our wallet + let bal = wallet.get_balance().await.unwrap(); + assert_eq!( + bal.pending_balance, + recv_amount.saturating_sub(send_amount).saturating_sub(payment.fee.unwrap()) + ); + + // Wait for third party node to receive it + test_utils::wait_for_condition("on-chain payment received", || async { + let payments = third_party.list_payments(); + payments.iter().any(|p| { + p.status == PaymentStatus::Succeeded + && p.direction == PaymentDirection::Inbound + && p.amount_msat == Some(send_amount.milli_sats()) + }) }) + .await; }) .await; } #[tokio::test(flavor = "multi_thread")] async fn test_force_close_handling() { - let TestParams { wallet, lsp, bitcoind, third_party, .. } = build_test_nodes().await; - - let starting_bal = wallet.get_balance().await.unwrap(); - assert_eq!(starting_bal.available_balance(), Amount::ZERO); - assert_eq!(starting_bal.pending_balance, Amount::ZERO); - - let rebalancing = wallet.get_rebalance_enabled(); - assert!(rebalancing); - - // get a channel so we can make a payment - open_channel_from_lsp(&wallet, Arc::clone(&third_party)).await; + test_utils::run_test(|params| async move { + let wallet = Arc::clone(¶ms.wallet); + let lsp = Arc::clone(¶ms.lsp); + let bitcoind = Arc::clone(¶ms.bitcoind); + let third_party = Arc::clone(¶ms.third_party); + + let starting_bal = wallet.get_balance().await.unwrap(); + assert_eq!(starting_bal.available_balance(), Amount::ZERO); + assert_eq!(starting_bal.pending_balance, Amount::ZERO); + + let rebalancing = wallet.get_rebalance_enabled(); + assert!(rebalancing); + + // get a channel so we can make a payment + open_channel_from_lsp(&wallet, Arc::clone(&third_party)).await; + + // mine some blocks to ensure the channel is confirmed + generate_blocks(&bitcoind, 6); + + // get channel details + let channel = lsp + .list_channels() + .into_iter() + .find(|c| c.counterparty_node_id == wallet.node_id()) + .unwrap(); - // mine some blocks to ensure the channel is confirmed - generate_blocks(&bitcoind, 6); + // force close the channel + lsp.force_close_channel(&channel.user_channel_id, channel.counterparty_node_id, None) + .unwrap(); - // get channel details - let channel = lsp - .list_channels() - .into_iter() - .find(|c| c.counterparty_node_id == wallet.node_id()) - .unwrap(); + // wait for the channel to be closed + let event = wait_next_event(&wallet).await; + match event { + Event::ChannelClosed { counterparty_node_id, .. } => { + assert_eq!(counterparty_node_id, lsp.node_id()); + }, + _ => panic!("Expected ChannelClosed event"), + } - // force close the channel - lsp.force_close_channel(&channel.user_channel_id, channel.counterparty_node_id, None).unwrap(); - - // wait for the channel to be closed - let event = wait_next_event(&wallet).await; - match event { - Event::ChannelClosed { counterparty_node_id, .. } => { - assert_eq!(counterparty_node_id, lsp.node_id()); - }, - _ => panic!("Expected ChannelClosed event"), - } - - // rebalancing should be disabled after a force close - let rebalancing = wallet.get_rebalance_enabled(); - assert!(!rebalancing); + // rebalancing should be disabled after a force close + let rebalancing = wallet.get_rebalance_enabled(); + assert!(!rebalancing); + }) + .await; } #[tokio::test(flavor = "multi_thread")] async fn test_close_all_channels() { - let TestParams { wallet, lsp, bitcoind, third_party, .. } = build_test_nodes().await; + test_utils::run_test(|params| async move { + let wallet = Arc::clone(¶ms.wallet); + let lsp = Arc::clone(¶ms.lsp); + let bitcoind = Arc::clone(¶ms.bitcoind); + let third_party = Arc::clone(¶ms.third_party); - let starting_bal = wallet.get_balance().await.unwrap(); - assert_eq!(starting_bal.available_balance(), Amount::ZERO); - assert_eq!(starting_bal.pending_balance, Amount::ZERO); + let starting_bal = wallet.get_balance().await.unwrap(); + assert_eq!(starting_bal.available_balance(), Amount::ZERO); + assert_eq!(starting_bal.pending_balance, Amount::ZERO); - let rebalancing = wallet.get_rebalance_enabled(); - assert!(rebalancing); + let rebalancing = wallet.get_rebalance_enabled(); + assert!(rebalancing); - // get a channel so we can make a payment - open_channel_from_lsp(&wallet, Arc::clone(&third_party)).await; + // get a channel so we can make a payment + open_channel_from_lsp(&wallet, Arc::clone(&third_party)).await; - // mine some blocks to ensure the channel is confirmed - generate_blocks(&bitcoind, 6); + // mine some blocks to ensure the channel is confirmed + generate_blocks(&bitcoind, 6); - // init closing all channels - wallet.close_channels().unwrap(); + // init closing all channels + wallet.close_channels().unwrap(); - // wait for the channels to be closed - let event = wait_next_event(&wallet).await; - match event { - Event::ChannelClosed { counterparty_node_id, .. } => { - assert_eq!(counterparty_node_id, lsp.node_id()); - }, - _ => panic!("Expected ChannelClosed event"), - } + // wait for the channels to be closed + let event = wait_next_event(&wallet).await; + match event { + Event::ChannelClosed { counterparty_node_id, .. } => { + assert_eq!(counterparty_node_id, lsp.node_id()); + }, + _ => panic!("Expected ChannelClosed event"), + } - // rebalancing should be disabled after closing all channels - let rebalancing = wallet.get_rebalance_enabled(); - assert!(!rebalancing); + // rebalancing should be disabled after closing all channels + let rebalancing = wallet.get_rebalance_enabled(); + assert!(!rebalancing); + }) + .await; } #[tokio::test(flavor = "multi_thread")] async fn test_threshold_boundary_trusted_balance_limit() { - let TestParams { wallet, third_party, .. } = build_test_nodes().await; - - // we're not testing rebalancing here, so disable it to keep things simple - // on slow CI this can cause tests to fail if rebalancing kicks in - wallet.set_rebalance_enabled(false); + test_utils::run_test(|params| async move { + let wallet = Arc::clone(¶ms.wallet); + let third_party = Arc::clone(¶ms.third_party); - let tunables = wallet.get_tunables(); + // we're not testing rebalancing here, so disable it to keep things simple + // on slow CI this can cause tests to fail if rebalancing kicks in + wallet.set_rebalance_enabled(false); - // Test 1: Payment exactly at the trusted balance limit should use trusted wallet - let exact_limit_amount = tunables.trusted_balance_limit; - let uri = wallet.get_single_use_receive_uri(Some(exact_limit_amount)).await.unwrap(); - third_party.bolt11_payment().send(&uri.invoice, None).unwrap(); + let tunables = wallet.get_tunables(); - test_utils::wait_for_condition("exact limit payment", || async { - wallet.get_balance().await.unwrap().available_balance() >= exact_limit_amount - }) - .await; + // Test 1: Payment exactly at the trusted balance limit should use trusted wallet + let exact_limit_amount = tunables.trusted_balance_limit; + let uri = wallet.get_single_use_receive_uri(Some(exact_limit_amount)).await.unwrap(); + third_party.bolt11_payment().send(&uri.invoice, None).unwrap(); - // receive to trusted wallet - let event = wait_next_event(&wallet).await; - assert!( - matches!(event, Event::PaymentReceived { .. }), - "Expected PaymentReceived event but got {event:?}" - ); - - let txs = wallet.list_transactions().await.unwrap(); - assert_eq!(txs.len(), 1); - let tx = &txs[0]; - assert_eq!(tx.payment_type, PaymentType::IncomingLightning {}); - assert_eq!( - tx.fee, - Some(Amount::ZERO), - "Payment at exact limit should use trusted wallet with zero fees" - ); - - // Test 2: Payment 1 sat above the limit should trigger Lightning channel - let above_limit_amount = exact_limit_amount.saturating_add(Amount::from_sats(1).unwrap()); - let uri = wallet.get_single_use_receive_uri(Some(above_limit_amount)).await.unwrap(); - third_party.bolt11_payment().send(&uri.invoice, None).unwrap(); - - // Wait for channel to be opened and payment to complete - test_utils::wait_for_condition("above limit payment with channel", || async { - let balance = wallet.get_balance().await.unwrap().available_balance(); - balance - >= exact_limit_amount.saturating_add( - above_limit_amount.saturating_sub(Amount::from_sats(50000).unwrap()), - ) - }) - .await; + test_utils::wait_for_condition("exact limit payment", || async { + wallet.get_balance().await.unwrap().available_balance() >= exact_limit_amount + }) + .await; - // Should have received a ChannelOpened event - let event = wait_next_event(&wallet).await; - match event { - Event::ChannelOpened { .. } => {}, - e => panic!("Expected ChannelOpened event, got {e:?}"), - } - - let event = wait_next_event(&wallet).await; - match event { - Event::PaymentReceived { amount_msat, lsp_fee_msats, .. } => { - assert_eq!(amount_msat + lsp_fee_msats.unwrap_or(0), above_limit_amount.milli_sats()); - }, - e => panic!("Expected PaymentReceived event, got {e:?}"), - } - - let txs = wallet.list_transactions().await.unwrap(); - let lightning_tx = txs.iter().find(|tx| tx.fee.is_some_and(|f| f > Amount::ZERO)).unwrap(); - assert_eq!(lightning_tx.payment_type, PaymentType::IncomingLightning {}); - assert!( - lightning_tx.fee.unwrap() > Amount::ZERO, - "Payment above limit should use Lightning with fees" - ); -} + // receive to trusted wallet + let event = wait_next_event(&wallet).await; + assert!( + matches!(event, Event::PaymentReceived { .. }), + "Expected PaymentReceived event but got {event:?}" + ); -#[tokio::test(flavor = "multi_thread")] -async fn test_threshold_boundary_rebalance_min() { - let TestParams { wallet, third_party, .. } = build_test_nodes().await; - - let starting_bal = wallet.get_balance().await.unwrap(); - let tunables = wallet.get_tunables(); - let rebalance_min = tunables.rebalance_min; + let txs = wallet.list_transactions().await.unwrap(); + assert_eq!(txs.len(), 1); + let tx = &txs[0]; + assert_eq!(tx.payment_type, PaymentType::IncomingLightning {}); + assert_eq!( + tx.fee, + Some(Amount::ZERO), + "Payment at exact limit should use trusted wallet with zero fees" + ); - // Test 1: Payment below rebalance_min should use trusted wallet - let below_rebalance = rebalance_min.saturating_sub(Amount::from_sats(1).unwrap()); - let uri = wallet.get_single_use_receive_uri(Some(below_rebalance)).await.unwrap(); - third_party.bolt11_payment().send(&uri.invoice, None).unwrap(); + // Test 2: Payment 1 sat above the limit should trigger Lightning channel + let above_limit_amount = exact_limit_amount.saturating_add(Amount::from_sats(1).unwrap()); + let uri = wallet.get_single_use_receive_uri(Some(above_limit_amount)).await.unwrap(); + third_party.bolt11_payment().send(&uri.invoice, None).unwrap(); + + // Wait for channel to be opened and payment to complete + test_utils::wait_for_condition("above limit payment with channel", || async { + let balance = wallet.get_balance().await.unwrap().available_balance(); + balance + >= exact_limit_amount.saturating_add( + above_limit_amount.saturating_sub(Amount::from_sats(50000).unwrap()), + ) + }) + .await; - test_utils::wait_for_condition("below rebalance min payment", || async { - wallet.get_balance().await.unwrap().available_balance() >= starting_bal.available_balance() - }) - .await; + // Should have received a ChannelOpened event + let event = wait_next_event(&wallet).await; + match event { + Event::ChannelOpened { .. } => {}, + e => panic!("Expected ChannelOpened event, got {e:?}"), + } - test_utils::wait_for_condition("wait for transaction", || async { - !wallet.list_transactions().await.unwrap().is_empty() - }) - .await; + let event = wait_next_event(&wallet).await; + match event { + Event::PaymentReceived { amount_msat, lsp_fee_msats, .. } => { + assert_eq!( + amount_msat + lsp_fee_msats.unwrap_or(0), + above_limit_amount.milli_sats() + ); + }, + e => panic!("Expected PaymentReceived event, got {e:?}"), + } - let txs = wallet.list_transactions().await.unwrap(); - assert_eq!(txs.len(), 1); - let tx = &txs[0]; - assert_eq!( - tx.fee, - Some(Amount::ZERO), - "Below rebalance_min should use trusted wallet with zero fees" - ); - assert_eq!(tx.payment_type, PaymentType::IncomingLightning {}); - - // Test 2: Payment exactly at rebalance_min should use trusted wallet - let exact_rebalance = rebalance_min; - let uri = wallet.get_single_use_receive_uri(Some(exact_rebalance)).await.unwrap(); - third_party.bolt11_payment().send(&uri.invoice, None).unwrap(); - - test_utils::wait_for_condition("exact rebalance min payment", || async { - let balance = wallet.get_balance().await.unwrap().available_balance(); - balance >= below_rebalance.saturating_add(exact_rebalance) + let txs = wallet.list_transactions().await.unwrap(); + let lightning_tx = txs.iter().find(|tx| tx.fee.is_some_and(|f| f > Amount::ZERO)).unwrap(); + assert_eq!(lightning_tx.payment_type, PaymentType::IncomingLightning {}); + assert!( + lightning_tx.fee.unwrap() > Amount::ZERO, + "Payment above limit should use Lightning with fees" + ); }) .await; +} - let txs = wallet.list_transactions().await.unwrap(); - assert!(txs.len() >= 2, "Should have at least 2 transactions (may include rebalance)"); +#[tokio::test(flavor = "multi_thread")] +async fn test_threshold_boundary_rebalance_min() { + test_utils::run_test(|params| async move { + let wallet = Arc::clone(¶ms.wallet); + let third_party = Arc::clone(¶ms.third_party); + + let starting_bal = wallet.get_balance().await.unwrap(); + let tunables = wallet.get_tunables(); + let rebalance_min = tunables.rebalance_min; + + // Test 1: Payment below rebalance_min should use trusted wallet + let below_rebalance = rebalance_min.saturating_sub(Amount::from_sats(1).unwrap()); + let uri = wallet.get_single_use_receive_uri(Some(below_rebalance)).await.unwrap(); + third_party.bolt11_payment().send(&uri.invoice, None).unwrap(); + + test_utils::wait_for_condition("below rebalance min payment", || async { + wallet.get_balance().await.unwrap().available_balance() + >= starting_bal.available_balance() + }) + .await; - // Count incoming lightning transactions (our test payments) - let incoming_txs: Vec<_> = txs - .iter() - .filter(|tx| !tx.outbound && tx.payment_type == PaymentType::IncomingLightning {}) - .collect(); - assert_eq!(incoming_txs.len(), 2, "Should have exactly 2 incoming payments"); + test_utils::wait_for_condition("wait for transaction", || async { + !wallet.list_transactions().await.unwrap().is_empty() + }) + .await; - // Both incoming transactions should be trusted wallet transactions with zero fees - for tx in incoming_txs { + let txs = wallet.list_transactions().await.unwrap(); + assert_eq!(txs.len(), 1); + let tx = &txs[0]; assert_eq!( tx.fee, Some(Amount::ZERO), - "Payments at/below rebalance_min should use trusted wallet" + "Below rebalance_min should use trusted wallet with zero fees" ); assert_eq!(tx.payment_type, PaymentType::IncomingLightning {}); - } - - // Test 3: Verify that the rebalance logic respects the minimum threshold - // The total balance should still be below what would trigger Lightning usage - let total_balance = wallet.get_balance().await.unwrap().available_balance(); - assert!( - total_balance < tunables.trusted_balance_limit, - "Total balance should still be below trusted_balance_limit" - ); -} -#[tokio::test(flavor = "multi_thread")] -async fn test_threshold_boundary_onchain_receive_threshold() { - let TestParams { wallet, .. } = build_test_nodes().await; - - let tunables = wallet.get_tunables(); - let onchain_threshold = tunables.onchain_receive_threshold; - - // Test 1: Amount below onchain_receive_threshold should not include on-chain address - let below_threshold = onchain_threshold.saturating_sub(Amount::from_sats(1).unwrap()); - let uri = wallet.get_single_use_receive_uri(Some(below_threshold)).await.unwrap(); - - assert!( - uri.address.is_none(), - "Payment below onchain threshold should not include on-chain address" - ); - assert!(uri.invoice.amount_milli_satoshis().is_some(), "Should include Lightning invoice"); - - // Test 2: Amount exactly at onchain_receive_threshold should include on-chain address - let exact_threshold = onchain_threshold; - let uri = wallet.get_single_use_receive_uri(Some(exact_threshold)).await.unwrap(); - - assert!( - uri.address.is_some(), - "Payment at exact onchain threshold should include on-chain address" - ); - assert!(uri.invoice.amount_milli_satoshis().is_some(), "Should also include Lightning invoice"); - - // Test 3: Amount above onchain_receive_threshold should include on-chain address - let above_threshold = onchain_threshold.saturating_add(Amount::from_sats(1000).unwrap()); - let uri = wallet.get_single_use_receive_uri(Some(above_threshold)).await.unwrap(); - - assert!( - uri.address.is_some(), - "Payment above onchain threshold should include on-chain address" - ); - assert!(uri.invoice.amount_milli_satoshis().is_some(), "Should also include Lightning invoice"); - - // Test 4: Amountless receive behavior with enable_amountless_receive_on_chain - #[cfg(not(feature = "_cashu-tests"))] // Cashu does not support amountless invoices - { - let uri = wallet.get_single_use_receive_uri(None).await.unwrap(); - - if tunables.enable_amountless_receive_on_chain { - assert!( - uri.address.is_some(), - "Amountless receive should include on-chain address when enabled" - ); - } else { - assert!( - uri.address.is_none(), - "Amountless receive should not include on-chain address when disabled" + // Test 2: Payment exactly at rebalance_min should use trusted wallet + let exact_rebalance = rebalance_min; + let uri = wallet.get_single_use_receive_uri(Some(exact_rebalance)).await.unwrap(); + third_party.bolt11_payment().send(&uri.invoice, None).unwrap(); + + test_utils::wait_for_condition("exact rebalance min payment", || async { + let balance = wallet.get_balance().await.unwrap().available_balance(); + balance >= below_rebalance.saturating_add(exact_rebalance) + }) + .await; + + let txs = wallet.list_transactions().await.unwrap(); + assert!(txs.len() >= 2, "Should have at least 2 transactions (may include rebalance)"); + + // Count incoming lightning transactions (our test payments) + let incoming_txs: Vec<_> = txs + .iter() + .filter(|tx| !tx.outbound && tx.payment_type == PaymentType::IncomingLightning {}) + .collect(); + assert_eq!(incoming_txs.len(), 2, "Should have exactly 2 incoming payments"); + + // Both incoming transactions should be trusted wallet transactions with zero fees + for tx in incoming_txs { + assert_eq!( + tx.fee, + Some(Amount::ZERO), + "Payments at/below rebalance_min should use trusted wallet" ); + assert_eq!(tx.payment_type, PaymentType::IncomingLightning {}); } + + // Test 3: Verify that the rebalance logic respects the minimum threshold + // The total balance should still be below what would trigger Lightning usage + let total_balance = wallet.get_balance().await.unwrap().available_balance(); assert!( - uri.invoice.amount_milli_satoshis().is_none(), - "Amountless invoice should have no fixed amount" + total_balance < tunables.trusted_balance_limit, + "Total balance should still be below trusted_balance_limit" ); - } + }) + .await; } #[tokio::test(flavor = "multi_thread")] -async fn test_threshold_combinations_and_edge_cases() { - let TestParams { wallet, .. } = build_test_nodes().await; +async fn test_threshold_boundary_onchain_receive_threshold() { + test_utils::run_test(|params| async move { + let wallet = Arc::clone(¶ms.wallet); - let tunables = wallet.get_tunables(); + let tunables = wallet.get_tunables(); + let onchain_threshold = tunables.onchain_receive_threshold; - // Test edge case: ensure thresholds are properly ordered - assert!( - tunables.rebalance_min <= tunables.trusted_balance_limit, - "rebalance_min should be <= trusted_balance_limit for proper wallet operation" - ); + // Test 1: Amount below onchain_receive_threshold should not include on-chain address + let below_threshold = onchain_threshold.saturating_sub(Amount::from_sats(1).unwrap()); + let uri = wallet.get_single_use_receive_uri(Some(below_threshold)).await.unwrap(); - // Test minimum amount handling (1 sat) - let min_amount = Amount::from_sats(1).unwrap(); - let uri = wallet.get_single_use_receive_uri(Some(min_amount)).await.unwrap(); + assert!( + uri.address.is_none(), + "Payment below onchain threshold should not include on-chain address" + ); + assert!(uri.invoice.amount_milli_satoshis().is_some(), "Should include Lightning invoice"); - assert!(uri.invoice.amount_milli_satoshis().is_some(), "Should handle minimum 1 sat amount"); - assert!(uri.address.is_none(), "1 sat should be below onchain threshold"); + // Test 2: Amount exactly at onchain_receive_threshold should include on-chain address + let exact_threshold = onchain_threshold; + let uri = wallet.get_single_use_receive_uri(Some(exact_threshold)).await.unwrap(); - // Test large amount (but reasonable for testing) - let large_amount = Amount::from_sats(1_000_000).unwrap(); // 1 BTC - large but reasonable - let uri = wallet.get_single_use_receive_uri(Some(large_amount)).await.unwrap(); + assert!( + uri.address.is_some(), + "Payment at exact onchain threshold should include on-chain address" + ); + assert!( + uri.invoice.amount_milli_satoshis().is_some(), + "Should also include Lightning invoice" + ); - assert!(uri.invoice.amount_milli_satoshis().is_some(), "Should handle large amounts"); - assert!(uri.address.is_some(), "Large amount should include on-chain address"); + // Test 3: Amount above onchain_receive_threshold should include on-chain address + let above_threshold = onchain_threshold.saturating_add(Amount::from_sats(1000).unwrap()); + let uri = wallet.get_single_use_receive_uri(Some(above_threshold)).await.unwrap(); - // Test zero amount (should be handled by amountless logic) - #[cfg(not(feature = "_cashu-tests"))] // Cashu does not support amountless invoices - { - let uri = wallet.get_single_use_receive_uri(None).await.unwrap(); assert!( - uri.invoice.amount_milli_satoshis().is_none(), - "Amountless invoice should have no amount" - ); - } - - // Verify the wallet can handle payments at multiple threshold boundaries - let test_amounts = [ - tunables.rebalance_min.saturating_sub(Amount::from_sats(1).unwrap()), - tunables.rebalance_min, - tunables.rebalance_min.saturating_add(Amount::from_sats(1).unwrap()), - tunables.onchain_receive_threshold.saturating_sub(Amount::from_sats(1).unwrap()), - tunables.onchain_receive_threshold, - tunables.onchain_receive_threshold.saturating_add(Amount::from_sats(1).unwrap()), - tunables.trusted_balance_limit.saturating_sub(Amount::from_sats(1).unwrap()), - tunables.trusted_balance_limit, - ]; - - for amount in test_amounts { - let uri = wallet.get_single_use_receive_uri(Some(amount)).await.unwrap(); + uri.address.is_some(), + "Payment above onchain threshold should include on-chain address" + ); assert!( uri.invoice.amount_milli_satoshis().is_some(), - "Should generate valid invoice for amount: {} sats", - amount.sats().unwrap_or(0) + "Should also include Lightning invoice" ); - } + + // Test 4: Amountless receive behavior with enable_amountless_receive_on_chain + #[cfg(not(feature = "_cashu-tests"))] // Cashu does not support amountless invoices + { + let uri = wallet.get_single_use_receive_uri(None).await.unwrap(); + + if tunables.enable_amountless_receive_on_chain { + assert!( + uri.address.is_some(), + "Amountless receive should include on-chain address when enabled" + ); + } else { + assert!( + uri.address.is_none(), + "Amountless receive should not include on-chain address when disabled" + ); + } + assert!( + uri.invoice.amount_milli_satoshis().is_none(), + "Amountless invoice should have no fixed amount" + ); + } + }) + .await; } #[tokio::test(flavor = "multi_thread")] -async fn test_invalid_payment_instructions() { - let TestParams { wallet, third_party, .. } = build_test_nodes().await; - - // Test 1: Payment with insufficient balance - let amount = Amount::from_sats(1_000_000).unwrap(); // 1 BTC - more than we have - let desc = Bolt11InvoiceDescription::Direct(Description::empty()); - let invoice = third_party.bolt11_payment().receive(amount.milli_sats(), &desc, 300).unwrap(); - - let instr = wallet.parse_payment_instructions(invoice.to_string().as_str()).await.unwrap(); - let info = PaymentInfo::build(instr, Some(amount)).unwrap(); - - // This should fail due to insufficient balance - let result = wallet.pay(&info).await; - assert!( - matches!(result, Err(WalletError::LdkNodeFailure(NodeError::InsufficientFunds))), - "Payment with insufficient balance should fail with LDK error" - ); - - // Test 2: Invalid invoice parsing - let invalid_invoice = "lnbc1invalid_invoice_here"; - let result = wallet.parse_payment_instructions(invalid_invoice).await; - assert!( - matches!(result, Err(ParseError::UnknownPaymentInstructions)), - "Invalid invoice should fail with UnknownPaymentInstructions error" - ); - - // Test 3: Malformed Bitcoin address - let invalid_address = "not_a_bitcoin_address"; - let result = wallet.parse_payment_instructions(invalid_address).await; - assert!( - matches!(result, Err(ParseError::UnknownPaymentInstructions)), - "Invalid address should fail with UnknownPaymentInstructions error" - ); - - // Test 4: Zero amount payment (should be rejected) - let zero_amount = Amount::ZERO; - let desc = Bolt11InvoiceDescription::Direct(Description::empty()); - let invoice = third_party.bolt11_payment().receive(1000, &desc, 300).unwrap(); // 1 msat - - let instr = wallet.parse_payment_instructions(invoice.to_string().as_str()).await.unwrap(); - let result = PaymentInfo::build(instr, Some(zero_amount)); - assert!(result.is_err(), "Zero amount payment should be rejected"); - - // Test 5: Payment with mismatched amount (fixed amount invoice with different amount) - let fixed_amount = Amount::from_sats(5000).unwrap(); - let different_amount = Amount::from_sats(10000).unwrap(); - let desc = Bolt11InvoiceDescription::Direct(Description::empty()); - let fixed_invoice = - third_party.bolt11_payment().receive(fixed_amount.milli_sats(), &desc, 300).unwrap(); - - let instr = - wallet.parse_payment_instructions(fixed_invoice.to_string().as_str()).await.unwrap(); - let result = PaymentInfo::build(instr, Some(different_amount)); - assert!(result.is_err(), "Mismatched amount for fixed invoice should be rejected"); - - // Test 6: Verify no failed transactions are recorded - let txs = wallet.list_transactions().await.unwrap(); - assert_eq!(txs.len(), 0, "Failed payments should not be recorded in transaction list"); +async fn test_threshold_combinations_and_edge_cases() { + test_utils::run_test(|params| async move { + let wallet = Arc::clone(¶ms.wallet); + + let tunables = wallet.get_tunables(); + + // Test edge case: ensure thresholds are properly ordered + assert!( + tunables.rebalance_min <= tunables.trusted_balance_limit, + "rebalance_min should be <= trusted_balance_limit for proper wallet operation" + ); + + // Test minimum amount handling (1 sat) + let min_amount = Amount::from_sats(1).unwrap(); + let uri = wallet.get_single_use_receive_uri(Some(min_amount)).await.unwrap(); + + assert!( + uri.invoice.amount_milli_satoshis().is_some(), + "Should handle minimum 1 sat amount" + ); + assert!(uri.address.is_none(), "1 sat should be below onchain threshold"); + + // Test large amount (but reasonable for testing) + let large_amount = Amount::from_sats(1_000_000).unwrap(); // 1 BTC - large but reasonable + let uri = wallet.get_single_use_receive_uri(Some(large_amount)).await.unwrap(); + + assert!(uri.invoice.amount_milli_satoshis().is_some(), "Should handle large amounts"); + assert!(uri.address.is_some(), "Large amount should include on-chain address"); + + // Test zero amount (should be handled by amountless logic) + #[cfg(not(feature = "_cashu-tests"))] // Cashu does not support amountless invoices + { + let uri = wallet.get_single_use_receive_uri(None).await.unwrap(); + assert!( + uri.invoice.amount_milli_satoshis().is_none(), + "Amountless invoice should have no amount" + ); + } + + // Verify the wallet can handle payments at multiple threshold boundaries + let test_amounts = [ + tunables.rebalance_min.saturating_sub(Amount::from_sats(1).unwrap()), + tunables.rebalance_min, + tunables.rebalance_min.saturating_add(Amount::from_sats(1).unwrap()), + tunables.onchain_receive_threshold.saturating_sub(Amount::from_sats(1).unwrap()), + tunables.onchain_receive_threshold, + tunables.onchain_receive_threshold.saturating_add(Amount::from_sats(1).unwrap()), + tunables.trusted_balance_limit.saturating_sub(Amount::from_sats(1).unwrap()), + tunables.trusted_balance_limit, + ]; + + for amount in test_amounts { + let uri = wallet.get_single_use_receive_uri(Some(amount)).await.unwrap(); + assert!( + uri.invoice.amount_milli_satoshis().is_some(), + "Should generate valid invoice for amount: {} sats", + amount.sats().unwrap_or(0) + ); + } + }) + .await; } #[tokio::test(flavor = "multi_thread")] -async fn test_payment_with_expired_invoice() { - let TestParams { wallet, third_party, .. } = build_test_nodes().await; +async fn test_invalid_payment_instructions() { + test_utils::run_test(|params| async move { + let wallet = Arc::clone(¶ms.wallet); + let third_party = Arc::clone(¶ms.third_party); + + // Test 1: Payment with insufficient balance + let amount = Amount::from_sats(1_000_000).unwrap(); // 1 BTC - more than we have + let desc = Bolt11InvoiceDescription::Direct(Description::empty()); + let invoice = + third_party.bolt11_payment().receive(amount.milli_sats(), &desc, 300).unwrap(); - // Add some balance first so the payment can theoretically succeed if not expired - let initial_amount = Amount::from_sats(5000).unwrap(); - let uri = wallet.get_single_use_receive_uri(Some(initial_amount)).await.unwrap(); - third_party.bolt11_payment().send(&uri.invoice, None).unwrap(); + let instr = wallet.parse_payment_instructions(invoice.to_string().as_str()).await.unwrap(); + let info = PaymentInfo::build(instr, Some(amount)).unwrap(); - // Wait for balance update - test_utils::wait_for_condition("wallet balance update", || async { - wallet.get_balance().await.unwrap().available_balance() > Amount::ZERO + // This should fail due to insufficient balance + let result = wallet.pay(&info).await; + assert!( + matches!(result, Err(WalletError::LdkNodeFailure(NodeError::InsufficientFunds))), + "Payment with insufficient balance should fail with LDK error" + ); + + // Test 2: Invalid invoice parsing + let invalid_invoice = "lnbc1invalid_invoice_here"; + let result = wallet.parse_payment_instructions(invalid_invoice).await; + assert!( + matches!(result, Err(ParseError::UnknownPaymentInstructions)), + "Invalid invoice should fail with UnknownPaymentInstructions error" + ); + + // Test 3: Malformed Bitcoin address + let invalid_address = "not_a_bitcoin_address"; + let result = wallet.parse_payment_instructions(invalid_address).await; + assert!( + matches!(result, Err(ParseError::UnknownPaymentInstructions)), + "Invalid address should fail with UnknownPaymentInstructions error" + ); + + // Test 4: Zero amount payment (should be rejected) + let zero_amount = Amount::ZERO; + let desc = Bolt11InvoiceDescription::Direct(Description::empty()); + let invoice = third_party.bolt11_payment().receive(1000, &desc, 300).unwrap(); // 1 msat + + let instr = wallet.parse_payment_instructions(invoice.to_string().as_str()).await.unwrap(); + let result = PaymentInfo::build(instr, Some(zero_amount)); + assert!(result.is_err(), "Zero amount payment should be rejected"); + + // Test 5: Payment with mismatched amount (fixed amount invoice with different amount) + let fixed_amount = Amount::from_sats(5000).unwrap(); + let different_amount = Amount::from_sats(10000).unwrap(); + let desc = Bolt11InvoiceDescription::Direct(Description::empty()); + let fixed_invoice = + third_party.bolt11_payment().receive(fixed_amount.milli_sats(), &desc, 300).unwrap(); + + let instr = + wallet.parse_payment_instructions(fixed_invoice.to_string().as_str()).await.unwrap(); + let result = PaymentInfo::build(instr, Some(different_amount)); + assert!(result.is_err(), "Mismatched amount for fixed invoice should be rejected"); + + // Test 6: Verify no failed transactions are recorded + let txs = wallet.list_transactions().await.unwrap(); + assert_eq!(txs.len(), 0, "Failed payments should not be recorded in transaction list"); }) .await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_payment_with_expired_invoice() { + test_utils::run_test(|params| async move { + let wallet = Arc::clone(¶ms.wallet); + let third_party = Arc::clone(¶ms.third_party); + + // Add some balance first so the payment can theoretically succeed if not expired + let initial_amount = Amount::from_sats(5000).unwrap(); + let uri = wallet.get_single_use_receive_uri(Some(initial_amount)).await.unwrap(); + third_party.bolt11_payment().send(&uri.invoice, None).unwrap(); + + // Wait for balance update + test_utils::wait_for_condition("wallet balance update", || async { + wallet.get_balance().await.unwrap().available_balance() > Amount::ZERO + }) + .await; - // Create an invoice with very short expiry - let payment_amount = Amount::from_sats(1000).unwrap(); - let desc = Bolt11InvoiceDescription::Direct(Description::empty()); - let invoice = - third_party.bolt11_payment().receive(payment_amount.milli_sats(), &desc, 1).unwrap(); // 1 second expiry + // Create an invoice with very short expiry + let payment_amount = Amount::from_sats(1000).unwrap(); + let desc = Bolt11InvoiceDescription::Direct(Description::empty()); + let invoice = + third_party.bolt11_payment().receive(payment_amount.milli_sats(), &desc, 1).unwrap(); // 1 second expiry - // Wait longer to ensure invoice expires - tokio::time::sleep(Duration::from_secs(5)).await; + // Wait longer to ensure invoice expires + tokio::time::sleep(Duration::from_secs(5)).await; - // Try to parse and pay the expired invoice - it should either fail to parse or fail to pay - let parse_result = wallet.parse_payment_instructions(invoice.to_string().as_str()).await; - assert!(matches!(parse_result.unwrap_err(), ParseError::InstructionsExpired)); + // Try to parse and pay the expired invoice - it should either fail to parse or fail to pay + let parse_result = wallet.parse_payment_instructions(invoice.to_string().as_str()).await; + assert!(matches!(parse_result.unwrap_err(), ParseError::InstructionsExpired)); + }) + .await; } #[tokio::test(flavor = "multi_thread")] async fn test_payment_network_mismatch() { - let TestParams { wallet, bitcoind, .. } = build_test_nodes().await; - - // disable rebalancing so we have on-chain funds - wallet.set_rebalance_enabled(false); - - // fund wallet with on-chain - let recv_amount = Amount::from_sats(1_000_000).unwrap(); - let uri = wallet.get_single_use_receive_uri(Some(recv_amount)).await.unwrap(); - bitcoind - .client - .send_to_address( - &uri.address.unwrap(), - ldk_node::bitcoin::Amount::from_sat(recv_amount.sats().unwrap()), + test_utils::run_test(|params| async move { + let wallet = Arc::clone(¶ms.wallet); + let bitcoind = Arc::clone(¶ms.bitcoind); + + // disable rebalancing so we have on-chain funds + wallet.set_rebalance_enabled(false); + + // fund wallet with on-chain + let recv_amount = Amount::from_sats(1_000_000).unwrap(); + let uri = wallet.get_single_use_receive_uri(Some(recv_amount)).await.unwrap(); + bitcoind + .client + .send_to_address( + &uri.address.unwrap(), + ldk_node::bitcoin::Amount::from_sat(recv_amount.sats().unwrap()), + ) + .unwrap(); + + // confirm tx + generate_blocks(&bitcoind, 6); + test_utils::wait_for_condition("wallet sync after on-chain receive", || async { + wallet.get_balance().await.unwrap().pending_balance >= recv_amount + }) + .await; + + // Test 1: Mainnet invoice on regtest wallet (if we can construct one) + // This is tricky to test in practice since we're on regtest, but we can test + // the validation logic with known invalid network addresses + + // Test 2: Invalid network address format + let wrong_network = "bc1q2xmz60tlma5mnn6xet9r8zyl8ca2fn9rarjtpz"; // Valid mainnet address + let result = wallet.parse_payment_instructions(wrong_network).await; + assert!( + matches!(result, Err(ParseError::WrongNetwork)), + "Wrong network address should fail with WrongNetwork error" + ); + + // now force a correct parsing to ensure we fail when trying to pay + let instr = PaymentInstructions::parse( + wrong_network, + Network::Bitcoin, + &HTTPHrnResolver::new(), + true, ) + .await .unwrap(); - // confirm tx - generate_blocks(&bitcoind, 6); - test_utils::wait_for_condition("wallet sync after on-chain receive", || async { - wallet.get_balance().await.unwrap().pending_balance >= recv_amount + // If it parsed, trying to pay should fail due to network mismatch + let amount = Amount::from_sats(1000).unwrap(); + let info = PaymentInfo::build(instr, Some(amount)).unwrap(); + let pay_result = wallet.pay(&info).await; + assert!( + matches!(pay_result, Err(WalletError::LdkNodeFailure(NodeError::InvalidAddress))), + "Payment to wrong network address should fail with LDK error, got {pay_result:?}" + ); }) .await; - - // Test 1: Mainnet invoice on regtest wallet (if we can construct one) - // This is tricky to test in practice since we're on regtest, but we can test - // the validation logic with known invalid network addresses - - // Test 2: Invalid network address format - let wrong_network = "bc1q2xmz60tlma5mnn6xet9r8zyl8ca2fn9rarjtpz"; // Valid mainnet address - let result = wallet.parse_payment_instructions(wrong_network).await; - assert!( - matches!(result, Err(ParseError::WrongNetwork)), - "Wrong network address should fail with WrongNetwork error" - ); - - // now force a correct parsing to ensure we fail when trying to pay - let instr = - PaymentInstructions::parse(wrong_network, Network::Bitcoin, &HTTPHrnResolver::new(), true) - .await - .unwrap(); - - // If it parsed, trying to pay should fail due to network mismatch - let amount = Amount::from_sats(1000).unwrap(); - let info = PaymentInfo::build(instr, Some(amount)).unwrap(); - let pay_result = wallet.pay(&info).await; - assert!( - matches!(pay_result, Err(WalletError::LdkNodeFailure(NodeError::InvalidAddress))), - "Payment to wrong network address should fail with LDK error, got {pay_result:?}" - ); } #[tokio::test(flavor = "multi_thread")] async fn test_concurrent_payments() { - let TestParams { wallet, bitcoind, third_party, .. } = build_test_nodes().await; + test_utils::run_test(|params| async move { + let wallet = Arc::clone(¶ms.wallet); + let bitcoind = Arc::clone(¶ms.bitcoind); + let third_party = Arc::clone(¶ms.third_party); + + // First, build up sufficient balance for concurrent sending + let _channel_amount = open_channel_from_lsp(&wallet, Arc::clone(&third_party)).await; + + // Wait for sync + generate_blocks(&bitcoind, 6); + test_utils::wait_for_condition("wallet sync after channel open", || async { + wallet.channels().iter().any(|a| a.confirmations.is_some_and(|c| c > 0) && a.is_usable) + && third_party + .list_channels() + .iter() + .any(|a| a.confirmations.is_some_and(|c| c > 0) && a.is_usable) + }) + .await; + + // receive to trusted wallet as well + let uri = + wallet.get_single_use_receive_uri(Some(Amount::from_sats(150).unwrap())).await.unwrap(); + third_party.bolt11_payment().send(&uri.invoice, None).unwrap(); + let ev = wait_next_event(&wallet).await; + match ev { + Event::PaymentReceived { .. } => {}, + e => panic!("Expected PaymentReceived event, got {e:?}"), + } - // First, build up sufficient balance for concurrent sending - let _channel_amount = open_channel_from_lsp(&wallet, Arc::clone(&third_party)).await; + // Verify we have sufficient balance for multiple outgoing payments + let initial_balance = wallet.get_balance().await.unwrap(); + let payment_amount = Amount::from_sats(100).unwrap(); // Use small amounts to avoid routing issues + let total_payment_amount = + payment_amount.saturating_add(payment_amount).saturating_add(payment_amount); - // Wait for sync - generate_blocks(&bitcoind, 6); - test_utils::wait_for_condition("wallet sync after channel open", || async { - wallet.channels().iter().any(|a| a.confirmations.is_some_and(|c| c > 0) && a.is_usable) - && third_party - .list_channels() - .iter() - .any(|a| a.confirmations.is_some_and(|c| c > 0) && a.is_usable) - }) - .await; + assert!( + initial_balance.available_balance() + >= total_payment_amount.saturating_add(Amount::from_sats(1000).unwrap()), // Extra buffer for fees + "Insufficient balance for concurrent payments test: have {}, need {}", + initial_balance.available_balance().sats().unwrap_or(0), + total_payment_amount + .saturating_add(Amount::from_sats(1000).unwrap()) + .sats() + .unwrap_or(0) + ); - // receive to trusted wallet as well - let uri = - wallet.get_single_use_receive_uri(Some(Amount::from_sats(150).unwrap())).await.unwrap(); - third_party.bolt11_payment().send(&uri.invoice, None).unwrap(); - let ev = wait_next_event(&wallet).await; - match ev { - Event::PaymentReceived { .. } => {}, - e => panic!("Expected PaymentReceived event, got {e:?}"), - } - - // Verify we have sufficient balance for multiple outgoing payments - let initial_balance = wallet.get_balance().await.unwrap(); - let payment_amount = Amount::from_sats(100).unwrap(); // Use small amounts to avoid routing issues - let total_payment_amount = - payment_amount.saturating_add(payment_amount).saturating_add(payment_amount); - - assert!( - initial_balance.available_balance() - >= total_payment_amount.saturating_add(Amount::from_sats(1000).unwrap()), // Extra buffer for fees - "Insufficient balance for concurrent payments test: have {}, need {}", - initial_balance.available_balance().sats().unwrap_or(0), - total_payment_amount.saturating_add(Amount::from_sats(1000).unwrap()).sats().unwrap_or(0) - ); - - // Create multiple invoices from third party for us to pay - let desc = Bolt11InvoiceDescription::Direct(Description::empty()); - let invoice1 = - third_party.bolt11_payment().receive(payment_amount.milli_sats(), &desc, 300).unwrap(); - let invoice2 = - third_party.bolt11_payment().receive(payment_amount.milli_sats(), &desc, 300).unwrap(); - let invoice3 = - third_party.bolt11_payment().receive(payment_amount.milli_sats(), &desc, 300).unwrap(); - - // Convert invoices to strings first to avoid borrowing issues - let invoice1_str = invoice1.to_string(); - let invoice2_str = invoice2.to_string(); - let invoice3_str = invoice3.to_string(); - - // Parse payment instructions concurrently - let (instr_result1, instr_result2, instr_result3) = tokio::join!( - wallet.parse_payment_instructions(&invoice1_str), - wallet.parse_payment_instructions(&invoice2_str), - wallet.parse_payment_instructions(&invoice3_str) - ); - - let instr1 = instr_result1.expect("First instruction parsing should succeed"); - let instr2 = instr_result2.expect("Second instruction parsing should succeed"); - let instr3 = instr_result3.expect("Third instruction parsing should succeed"); - - let info1 = PaymentInfo::build(instr1, Some(payment_amount)).unwrap(); - let info2 = PaymentInfo::build(instr2, Some(payment_amount)).unwrap(); - let info3 = PaymentInfo::build(instr3, Some(payment_amount)).unwrap(); - - // Test: Launch multiple payments concurrently - let (result1, result2, result3) = - tokio::join!(wallet.pay(&info1), wallet.pay(&info2), wallet.pay(&info3)); - - // Payment initiation should succeed since we verified sufficient balance - assert!(result1.is_ok(), "First concurrent payment initiation should succeed: {:?}", result1); - assert!(result2.is_ok(), "Second concurrent payment initiation should succeed: {:?}", result2); - assert!(result3.is_ok(), "Third concurrent payment initiation should succeed: {:?}", result3); - - // Now wait for all PaymentSuccessful events to confirm the payments actually completed - let mut payment_successes = 0; - - while payment_successes < 3 { - let event = wait_next_event(&wallet).await; - match event { - Event::PaymentSuccessful { .. } => { - payment_successes += 1; - }, - _ => { - panic!("Expected PaymentSuccessful event, got: {:?}", event); - }, + // Create multiple invoices from third party for us to pay + let desc = Bolt11InvoiceDescription::Direct(Description::empty()); + let invoice1 = + third_party.bolt11_payment().receive(payment_amount.milli_sats(), &desc, 300).unwrap(); + let invoice2 = + third_party.bolt11_payment().receive(payment_amount.milli_sats(), &desc, 300).unwrap(); + let invoice3 = + third_party.bolt11_payment().receive(payment_amount.milli_sats(), &desc, 300).unwrap(); + + // Convert invoices to strings first to avoid borrowing issues + let invoice1_str = invoice1.to_string(); + let invoice2_str = invoice2.to_string(); + let invoice3_str = invoice3.to_string(); + + // Parse payment instructions concurrently + let (instr_result1, instr_result2, instr_result3) = tokio::join!( + wallet.parse_payment_instructions(&invoice1_str), + wallet.parse_payment_instructions(&invoice2_str), + wallet.parse_payment_instructions(&invoice3_str) + ); + + let instr1 = instr_result1.expect("First instruction parsing should succeed"); + let instr2 = instr_result2.expect("Second instruction parsing should succeed"); + let instr3 = instr_result3.expect("Third instruction parsing should succeed"); + + let info1 = PaymentInfo::build(instr1, Some(payment_amount)).unwrap(); + let info2 = PaymentInfo::build(instr2, Some(payment_amount)).unwrap(); + let info3 = PaymentInfo::build(instr3, Some(payment_amount)).unwrap(); + + // Test: Launch multiple payments concurrently + let (result1, result2, result3) = + tokio::join!(wallet.pay(&info1), wallet.pay(&info2), wallet.pay(&info3)); + + // Payment initiation should succeed since we verified sufficient balance + assert!( + result1.is_ok(), + "First concurrent payment initiation should succeed: {:?}", + result1 + ); + assert!( + result2.is_ok(), + "Second concurrent payment initiation should succeed: {:?}", + result2 + ); + assert!( + result3.is_ok(), + "Third concurrent payment initiation should succeed: {:?}", + result3 + ); + + // Now wait for all PaymentSuccessful events to confirm the payments actually completed + let mut payment_successes = 0; + + while payment_successes < 3 { + let event = wait_next_event(&wallet).await; + match event { + Event::PaymentSuccessful { .. } => { + payment_successes += 1; + }, + _ => { + panic!("Expected PaymentSuccessful event, got: {:?}", event); + }, + } } - } - - assert_eq!( - payment_successes, 3, - "Should receive exactly 3 PaymentSuccessful events, got {}", - payment_successes - ); - - // Verify all payments were recorded in transaction history - let final_txs = wallet.list_transactions().await.unwrap(); - let outgoing_txs: Vec<_> = final_txs.iter().filter(|tx| tx.outbound).collect(); - assert!(outgoing_txs.len() >= 3, "Should have at least 3 outgoing transactions"); - - // Verify all payments reached the third party - test_utils::wait_for_condition("third party to receive all payments", || async { - let current_payments = third_party.list_payments(); - let successful_payments = current_payments - .iter() - .filter(|p| { - p.status == PaymentStatus::Succeeded - && p.direction == PaymentDirection::Inbound - && p.amount_msat == Some(payment_amount.milli_sats()) - }) - .count(); - successful_payments >= 3 + + assert_eq!( + payment_successes, 3, + "Should receive exactly 3 PaymentSuccessful events, got {}", + payment_successes + ); + + // Verify all payments were recorded in transaction history + let final_txs = wallet.list_transactions().await.unwrap(); + let outgoing_txs: Vec<_> = final_txs.iter().filter(|tx| tx.outbound).collect(); + assert!(outgoing_txs.len() >= 3, "Should have at least 3 outgoing transactions"); + + // Verify all payments reached the third party + test_utils::wait_for_condition("third party to receive all payments", || async { + let current_payments = third_party.list_payments(); + let successful_payments = current_payments + .iter() + .filter(|p| { + p.status == PaymentStatus::Succeeded + && p.direction == PaymentDirection::Inbound + && p.amount_msat == Some(payment_amount.milli_sats()) + }) + .count(); + successful_payments >= 3 + }) + .await; + + // Verify final balance state (the main test is that concurrent payments succeeded) + let final_balance = wallet.get_balance().await.unwrap(); + let balance_decrease = + initial_balance.available_balance().saturating_sub(final_balance.available_balance()); + println!( + "Balance change: Initial: {}, Final: {}, Decrease: {}", + initial_balance.available_balance().sats().unwrap_or(0), + final_balance.available_balance().sats().unwrap_or(0), + balance_decrease.sats().unwrap_or(0) + ); + + // The balance should have decreased by some amount (allowing for complex routing and trusted/LN combinations) + assert!( + balance_decrease > Amount::ZERO, + "Balance should decrease after successful payments" + ); + + // Test concurrent balance queries (should still work during/after payments) + let balance_queries = + tokio::join!(wallet.get_balance(), wallet.get_balance(), wallet.get_balance()); + + // All balance queries should succeed and return consistent results + assert_eq!( + balance_queries.0.unwrap().available_balance(), + balance_queries.1.as_ref().unwrap().available_balance(), + "Concurrent balance queries should be consistent" + ); + assert_eq!( + balance_queries.1.unwrap().available_balance(), + balance_queries.2.unwrap().available_balance(), + "Concurrent balance queries should be consistent" + ); + + // Test concurrent transaction list queries + let tx_queries = tokio::join!( + wallet.list_transactions(), + wallet.list_transactions(), + wallet.list_transactions() + ); + + // All should succeed and return consistent results + let tx_lists = (tx_queries.0.unwrap(), tx_queries.1.unwrap(), tx_queries.2.unwrap()); + assert_eq!( + tx_lists.0.len(), + tx_lists.1.len(), + "Concurrent transaction queries should return same count" + ); + assert_eq!( + tx_lists.1.len(), + tx_lists.2.len(), + "Concurrent transaction queries should return same count" + ); }) .await; - - // Verify final balance state (the main test is that concurrent payments succeeded) - let final_balance = wallet.get_balance().await.unwrap(); - let balance_decrease = - initial_balance.available_balance().saturating_sub(final_balance.available_balance()); - println!( - "Balance change: Initial: {}, Final: {}, Decrease: {}", - initial_balance.available_balance().sats().unwrap_or(0), - final_balance.available_balance().sats().unwrap_or(0), - balance_decrease.sats().unwrap_or(0) - ); - - // The balance should have decreased by some amount (allowing for complex routing and trusted/LN combinations) - assert!(balance_decrease > Amount::ZERO, "Balance should decrease after successful payments"); - - // Test concurrent balance queries (should still work during/after payments) - let balance_queries = - tokio::join!(wallet.get_balance(), wallet.get_balance(), wallet.get_balance()); - - // All balance queries should succeed and return consistent results - assert_eq!( - balance_queries.0.unwrap().available_balance(), - balance_queries.1.as_ref().unwrap().available_balance(), - "Concurrent balance queries should be consistent" - ); - assert_eq!( - balance_queries.1.unwrap().available_balance(), - balance_queries.2.unwrap().available_balance(), - "Concurrent balance queries should be consistent" - ); - - // Test concurrent transaction list queries - let tx_queries = tokio::join!( - wallet.list_transactions(), - wallet.list_transactions(), - wallet.list_transactions() - ); - - // All should succeed and return consistent results - let tx_lists = (tx_queries.0.unwrap(), tx_queries.1.unwrap(), tx_queries.2.unwrap()); - assert_eq!( - tx_lists.0.len(), - tx_lists.1.len(), - "Concurrent transaction queries should return same count" - ); - assert_eq!( - tx_lists.1.len(), - tx_lists.2.len(), - "Concurrent transaction queries should return same count" - ); } #[tokio::test(flavor = "multi_thread")] async fn test_concurrent_receive_operations() { - let TestParams { wallet, third_party, .. } = build_test_nodes().await; + test_utils::run_test(|params| async move { + let wallet = Arc::clone(¶ms.wallet); + let third_party = Arc::clone(¶ms.third_party); - let amount = Amount::from_sats(1000).unwrap(); + let amount = Amount::from_sats(1000).unwrap(); - // Test: Generate multiple receive URIs concurrently - let (uri1, uri2) = tokio::join!( - wallet.get_single_use_receive_uri(Some(amount)), - wallet.get_single_use_receive_uri(Some(amount)) - ); + // Test: Generate multiple receive URIs concurrently + let (uri1, uri2) = tokio::join!( + wallet.get_single_use_receive_uri(Some(amount)), + wallet.get_single_use_receive_uri(Some(amount)) + ); - // Both should succeed - assert!(uri1.is_ok(), "Concurrent URI generation should succeed"); - assert!(uri2.is_ok(), "Concurrent URI generation should succeed"); + // Both should succeed + assert!(uri1.is_ok(), "Concurrent URI generation should succeed"); + assert!(uri2.is_ok(), "Concurrent URI generation should succeed"); - let uris = (uri1.unwrap(), uri2.unwrap()); + let uris = (uri1.unwrap(), uri2.unwrap()); - // URIs should be different (unique invoices) - assert_ne!(uris.0.invoice.to_string(), uris.1.invoice.to_string(), "URIs should be unique"); + // URIs should be different (unique invoices) + assert_ne!(uris.0.invoice.to_string(), uris.1.invoice.to_string(), "URIs should be unique"); - // Test: Sequential payments to avoid routing issues - let payment_id_1 = third_party.bolt11_payment().send(&uris.0.invoice, None).unwrap(); + // Test: Sequential payments to avoid routing issues + let payment_id_1 = third_party.bolt11_payment().send(&uris.0.invoice, None).unwrap(); - // Wait for first payment to complete - test_utils::wait_for_condition("first payment to succeed", || async { - third_party.payment(&payment_id_1).is_some_and(|p| p.status == PaymentStatus::Succeeded) - }) - .await; + // Wait for first payment to complete + test_utils::wait_for_condition("first payment to succeed", || async { + third_party.payment(&payment_id_1).is_some_and(|p| p.status == PaymentStatus::Succeeded) + }) + .await; - // Send second payment - let payment_id_2 = third_party.bolt11_payment().send(&uris.1.invoice, None).unwrap(); + // Send second payment + let payment_id_2 = third_party.bolt11_payment().send(&uris.1.invoice, None).unwrap(); - // Wait for second payment to complete - test_utils::wait_for_condition("second payment to succeed", || async { - third_party.payment(&payment_id_2).is_some_and(|p| p.status == PaymentStatus::Succeeded) - }) - .await; + // Wait for second payment to complete + test_utils::wait_for_condition("second payment to succeed", || async { + third_party.payment(&payment_id_2).is_some_and(|p| p.status == PaymentStatus::Succeeded) + }) + .await; - // Wait for wallet balance to reflect both payments - test_utils::wait_for_condition("wallet balance to update", || async { - let balance = wallet.get_balance().await.unwrap().available_balance(); - balance >= amount.saturating_add(amount) - }) - .await; + // Wait for wallet balance to reflect both payments + test_utils::wait_for_condition("wallet balance to update", || async { + let balance = wallet.get_balance().await.unwrap().available_balance(); + balance >= amount.saturating_add(amount) + }) + .await; - // Verify transactions were recorded - let txs = wallet.list_transactions().await.unwrap(); - assert!(txs.len() >= 2, "Should have at least 2 transactions"); + // Verify transactions were recorded + let txs = wallet.list_transactions().await.unwrap(); + assert!(txs.len() >= 2, "Should have at least 2 transactions"); - // Count incoming transactions - let incoming_count = txs.iter().filter(|tx| !tx.outbound).count(); - assert_eq!(incoming_count, 2, "Should have exactly 2 incoming transactions"); + // Count incoming transactions + let incoming_count = txs.iter().filter(|tx| !tx.outbound).count(); + assert_eq!(incoming_count, 2, "Should have exactly 2 incoming transactions"); + }) + .await; } #[tokio::test(flavor = "multi_thread")] async fn test_balance_consistency_under_load() { - let TestParams { wallet, third_party, .. } = build_test_nodes().await; + test_utils::run_test(|params| async move { + let wallet = Arc::clone(¶ms.wallet); + let third_party = Arc::clone(¶ms.third_party); - // Add some initial balance - let initial_amount = Amount::from_sats(10000).unwrap(); - let uri = wallet.get_single_use_receive_uri(Some(initial_amount)).await.unwrap(); - third_party.bolt11_payment().send(&uri.invoice, None).unwrap(); + // Add some initial balance + let initial_amount = Amount::from_sats(10000).unwrap(); + let uri = wallet.get_single_use_receive_uri(Some(initial_amount)).await.unwrap(); + third_party.bolt11_payment().send(&uri.invoice, None).unwrap(); + + test_utils::wait_for_condition("initial balance", || async { + wallet.get_balance().await.unwrap().available_balance() >= initial_amount + }) + .await; - test_utils::wait_for_condition("initial balance", || async { - wallet.get_balance().await.unwrap().available_balance() >= initial_amount + // Test: Many concurrent balance queries + let mut balance_tasks = Vec::new(); + for _ in 0..20 { + balance_tasks.push(wallet.get_balance()); + } + + // Join all balance tasks + let mut all_balances = Vec::new(); + for task in balance_tasks { + all_balances.push(task.await); + } + let balances = all_balances; + + // All queries should succeed + assert_eq!(balances.len(), 20); + for b in &balances { + let balance = b.as_ref().unwrap(); + assert!(balance.available_balance() >= Amount::ZERO); + assert!(balance.pending_balance >= Amount::ZERO); + } + + // All balances should be consistent (same values) + let first_balance = &balances[0].as_ref().unwrap(); + for b in &balances[1..] { + let balance = b.as_ref().unwrap(); + assert_eq!( + balance.available_balance(), + first_balance.available_balance(), + "Concurrent balance queries should return consistent results" + ); + assert_eq!( + balance.pending_balance, first_balance.pending_balance, + "Concurrent balance queries should return consistent results" + ); + } }) .await; - - // Test: Many concurrent balance queries - let mut balance_tasks = Vec::new(); - for _ in 0..20 { - balance_tasks.push(wallet.get_balance()); - } - - // Join all balance tasks - let mut all_balances = Vec::new(); - for task in balance_tasks { - all_balances.push(task.await); - } - let balances = all_balances; - - // All queries should succeed - assert_eq!(balances.len(), 20); - for b in &balances { - let balance = b.as_ref().unwrap(); - assert!(balance.available_balance() >= Amount::ZERO); - assert!(balance.pending_balance >= Amount::ZERO); - } - - // All balances should be consistent (same values) - let first_balance = &balances[0].as_ref().unwrap(); - for b in &balances[1..] { - let balance = b.as_ref().unwrap(); - assert_eq!( - balance.available_balance(), - first_balance.available_balance(), - "Concurrent balance queries should return consistent results" - ); - assert_eq!( - balance.pending_balance, first_balance.pending_balance, - "Concurrent balance queries should return consistent results" - ); - } } #[tokio::test(flavor = "multi_thread")] async fn test_invalid_tunables_relationships() { - let TestParams { wallet, .. } = build_test_nodes().await; - - let current_tunables = wallet.get_tunables(); - - // Test 1: Verify default tunables are valid - assert!( - current_tunables.rebalance_min <= current_tunables.trusted_balance_limit, - "Default tunables should have valid relationship: rebalance_min <= trusted_balance_limit" - ); - - // Test 2: Test edge case amounts with current tunables - // Zero amount (should work for URI generation but not payments) - #[cfg(not(feature = "_cashu-tests"))] // Cashu does not support amountless invoices - { - let uri_result = wallet.get_single_use_receive_uri(None).await; - assert!(uri_result.is_ok(), "Should be able to generate amountless URI"); - } - - // Test 3: Very small amounts - let tiny_amount = Amount::from_sats(1).unwrap(); - let uri_result = wallet.get_single_use_receive_uri(Some(tiny_amount)).await; - assert!(uri_result.is_ok(), "Should handle tiny amounts"); - - let uri = uri_result.unwrap(); - assert!( - uri.invoice.amount_milli_satoshis().is_some(), - "Tiny amount should have Lightning invoice" - ); - - // Should not include on-chain address if below threshold - if tiny_amount < current_tunables.onchain_receive_threshold { + test_utils::run_test(|params| async move { + let wallet = Arc::clone(¶ms.wallet); + + let current_tunables = wallet.get_tunables(); + + // Test 1: Verify default tunables are valid assert!( - uri.address.is_none(), - "Tiny amount below threshold should not include on-chain address" + current_tunables.rebalance_min <= current_tunables.trusted_balance_limit, + "Default tunables should have valid relationship: rebalance_min <= trusted_balance_limit" ); - } - // Test 4: Amounts exactly at boundaries - let boundary_amounts = [ - current_tunables.rebalance_min, - current_tunables.trusted_balance_limit, - current_tunables.onchain_receive_threshold, - ]; + // Test 2: Test edge case amounts with current tunables + // Zero amount (should work for URI generation but not payments) + #[cfg(not(feature = "_cashu-tests"))] // Cashu does not support amountless invoices + { + let uri_result = wallet.get_single_use_receive_uri(None).await; + assert!(uri_result.is_ok(), "Should be able to generate amountless URI"); + } + + // Test 3: Very small amounts + let tiny_amount = Amount::from_sats(1).unwrap(); + let uri_result = wallet.get_single_use_receive_uri(Some(tiny_amount)).await; + assert!(uri_result.is_ok(), "Should handle tiny amounts"); + + let uri = uri_result.unwrap(); + assert!( + uri.invoice.amount_milli_satoshis().is_some(), + "Tiny amount should have Lightning invoice" + ); + + // Should not include on-chain address if below threshold + if tiny_amount < current_tunables.onchain_receive_threshold { + assert!( + uri.address.is_none(), + "Tiny amount below threshold should not include on-chain address" + ); + } + + // Test 4: Amounts exactly at boundaries + let boundary_amounts = [ + current_tunables.rebalance_min, + current_tunables.trusted_balance_limit, + current_tunables.onchain_receive_threshold, + ]; + + for amount in boundary_amounts { + let uri_result = wallet.get_single_use_receive_uri(Some(amount)).await; + assert!( + uri_result.is_ok(), + "Should handle boundary amounts: {} sats", + amount.sats().unwrap_or(0) + ); + } + + // Test 5: Verify tunables consistency with wallet behavior + let below_limit = + current_tunables.trusted_balance_limit.saturating_sub(Amount::from_sats(1).unwrap()); + let above_limit = + current_tunables.trusted_balance_limit.saturating_add(Amount::from_sats(1).unwrap()); + + let uri_below = wallet.get_single_use_receive_uri(Some(below_limit)).await.unwrap(); + let uri_above = wallet.get_single_use_receive_uri(Some(above_limit)).await.unwrap(); - for amount in boundary_amounts { - let uri_result = wallet.get_single_use_receive_uri(Some(amount)).await; + // Both should have invoices assert!( - uri_result.is_ok(), - "Should handle boundary amounts: {} sats", - amount.sats().unwrap_or(0) - ); - } - - // Test 5: Verify tunables consistency with wallet behavior - let below_limit = - current_tunables.trusted_balance_limit.saturating_sub(Amount::from_sats(1).unwrap()); - let above_limit = - current_tunables.trusted_balance_limit.saturating_add(Amount::from_sats(1).unwrap()); - - let uri_below = wallet.get_single_use_receive_uri(Some(below_limit)).await.unwrap(); - let uri_above = wallet.get_single_use_receive_uri(Some(above_limit)).await.unwrap(); - - // Both should have invoices - assert!(uri_below.invoice.amount_milli_satoshis().is_some(), "Below limit should have invoice"); - assert!(uri_above.invoice.amount_milli_satoshis().is_some(), "Above limit should have invoice"); - - // On-chain address inclusion should depend on onchain_receive_threshold, not trusted_balance_limit - if below_limit >= current_tunables.onchain_receive_threshold { - assert!(uri_below.address.is_some(), "Amount >= onchain_threshold should include address"); - } - if above_limit >= current_tunables.onchain_receive_threshold { - assert!(uri_above.address.is_some(), "Amount >= onchain_threshold should include address"); - } + uri_below.invoice.amount_milli_satoshis().is_some(), + "Below limit should have invoice" + ); + assert!( + uri_above.invoice.amount_milli_satoshis().is_some(), + "Above limit should have invoice" + ); + + // On-chain address inclusion should depend on onchain_receive_threshold, not trusted_balance_limit + if below_limit >= current_tunables.onchain_receive_threshold { + assert!( + uri_below.address.is_some(), + "Amount >= onchain_threshold should include address" + ); + } + if above_limit >= current_tunables.onchain_receive_threshold { + assert!( + uri_above.address.is_some(), + "Amount >= onchain_threshold should include address" + ); + } + }) + .await; } #[tokio::test(flavor = "multi_thread")] async fn test_extreme_amount_handling() { - let TestParams { wallet, .. } = build_test_nodes().await; - - // Test 1: Large but reasonable Bitcoin amount - let large_reasonable = Amount::from_sats(1_000_000).unwrap(); // 1M sats = 0.01 BTC - let uri_result = wallet.get_single_use_receive_uri(Some(large_reasonable)).await; - assert!(uri_result.is_ok(), "Should handle large reasonable Bitcoin amount"); - - // Test 2: Various large amounts (but still reasonable for testing) - let large_amounts = [ - Amount::from_sats(100_000).unwrap(), // 0.001 BTC - Amount::from_sats(500_000).unwrap(), // 0.005 BTC - Amount::from_sats(1_000_000).unwrap(), // 0.01 BTC - ]; - - for amount in large_amounts { - let uri_result = wallet.get_single_use_receive_uri(Some(amount)).await; + test_utils::run_test(|params| async move { + let wallet = Arc::clone(¶ms.wallet); + + // Test 1: Large but reasonable Bitcoin amount + let large_reasonable = Amount::from_sats(1_000_000).unwrap(); // 1M sats = 0.01 BTC + let uri_result = wallet.get_single_use_receive_uri(Some(large_reasonable)).await; + assert!(uri_result.is_ok(), "Should handle large reasonable Bitcoin amount"); + + // Test 2: Various large amounts (but still reasonable for testing) + let large_amounts = [ + Amount::from_sats(100_000).unwrap(), // 0.001 BTC + Amount::from_sats(500_000).unwrap(), // 0.005 BTC + Amount::from_sats(1_000_000).unwrap(), // 0.01 BTC + ]; + + for amount in large_amounts { + let uri_result = wallet.get_single_use_receive_uri(Some(amount)).await; + assert!( + uri_result.is_ok(), + "Should handle large amount: {} BTC", + amount.sats().unwrap() as f64 / 100_000_000.0 + ); + + let uri = uri_result.unwrap(); + assert!( + uri.invoice.amount_milli_satoshis().is_some(), + "Large amount should have invoice" + ); + // Large amounts should always include on-chain address + assert!(uri.address.is_some(), "Large amounts should include on-chain address"); + } + + // Test 3: Satoshi precision edge cases + let precision_amounts = [ + Amount::from_sats(1).unwrap(), // 1 sat + Amount::from_sats(10).unwrap(), // 10 sats + Amount::from_sats(100).unwrap(), // 100 sats + Amount::from_sats(1000).unwrap(), // 1000 sats (1 mBTC) + ]; + + for amount in precision_amounts { + let uri_result = wallet.get_single_use_receive_uri(Some(amount)).await; + assert!( + uri_result.is_ok(), + "Should handle precision amount: {} sats", + amount.sats().unwrap() + ); + } + + // Test 4: Milli-satoshi precision (if supported) + // Note: Bitcoin addresses can't handle milli-satoshi precision, only Lightning can + // Note: Cashu does not support msat precision + #[cfg(not(feature = "_cashu-tests"))] + { + let msat_amount = Amount::from_milli_sats(1500).unwrap(); // 1.5 sats + let uri_result = wallet.get_single_use_receive_uri(Some(msat_amount)).await; + assert!(uri_result.is_ok(), "Should handle milli-satoshi amounts"); + + let uri = uri_result.unwrap(); + assert!( + uri.invoice.amount_milli_satoshis().is_some(), + "Milli-satoshi amount should have Lightning invoice" + ); + } + // On-chain address depends on threshold, not msat precision + }) + .await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_wallet_configuration_validation() { + test_utils::run_test(|params| async move { + let wallet = Arc::clone(¶ms.wallet); + + // Test 1: Verify wallet is using expected network + // This is more of a sanity check since we can't easily test invalid networks + // without creating new wallets + + // Test 2: Verify rebalancing can be toggled + let initial_rebalance_state = wallet.get_rebalance_enabled(); + assert!(initial_rebalance_state, "Rebalancing should be enabled by default"); + + wallet.set_rebalance_enabled(false); + assert!(!wallet.get_rebalance_enabled(), "Should be able to disable rebalancing"); + + wallet.set_rebalance_enabled(true); + assert!(wallet.get_rebalance_enabled(), "Should be able to re-enable rebalancing"); + + // Test 3: Verify tunables are consistent and reasonable + let tunables = wallet.get_tunables(); + + // Check for reasonable default values assert!( - uri_result.is_ok(), - "Should handle large amount: {} BTC", - amount.sats().unwrap() as f64 / 100_000_000.0 + tunables.trusted_balance_limit > Amount::ZERO, + "Trusted balance limit should be positive" + ); + assert!(tunables.rebalance_min > Amount::ZERO, "Rebalance min should be positive"); + assert!( + tunables.onchain_receive_threshold > Amount::ZERO, + "Onchain threshold should be positive" ); - let uri = uri_result.unwrap(); - assert!(uri.invoice.amount_milli_satoshis().is_some(), "Large amount should have invoice"); - // Large amounts should always include on-chain address - assert!(uri.address.is_some(), "Large amounts should include on-chain address"); - } - - // Test 3: Satoshi precision edge cases - let precision_amounts = [ - Amount::from_sats(1).unwrap(), // 1 sat - Amount::from_sats(10).unwrap(), // 10 sats - Amount::from_sats(100).unwrap(), // 100 sats - Amount::from_sats(1000).unwrap(), // 1000 sats (1 mBTC) - ]; - - for amount in precision_amounts { - let uri_result = wallet.get_single_use_receive_uri(Some(amount)).await; + // Check relationships assert!( - uri_result.is_ok(), - "Should handle precision amount: {} sats", - amount.sats().unwrap() + tunables.rebalance_min <= tunables.trusted_balance_limit, + "Rebalance min should not exceed trusted balance limit" ); - } - // Test 4: Milli-satoshi precision (if supported) - // Note: Bitcoin addresses can't handle milli-satoshi precision, only Lightning can - // Note: Cashu does not support msat precision - #[cfg(not(feature = "_cashu-tests"))] - { - let msat_amount = Amount::from_milli_sats(1500).unwrap(); // 1.5 sats - let uri_result = wallet.get_single_use_receive_uri(Some(msat_amount)).await; - assert!(uri_result.is_ok(), "Should handle milli-satoshi amounts"); + // Test 4: Test URI generation consistency across multiple calls + let amount = Amount::from_sats(5000).unwrap(); + let uri1 = wallet.get_single_use_receive_uri(Some(amount)).await.unwrap(); + let uri2 = wallet.get_single_use_receive_uri(Some(amount)).await.unwrap(); - let uri = uri_result.unwrap(); - assert!( - uri.invoice.amount_milli_satoshis().is_some(), - "Milli-satoshi amount should have Lightning invoice" + // Should generate different invoices (single use) + assert_ne!( + uri1.invoice.to_string(), + uri2.invoice.to_string(), + "Single-use URIs should be unique" ); - } - // On-chain address depends on threshold, not msat precision -} -#[tokio::test(flavor = "multi_thread")] -async fn test_wallet_configuration_validation() { - let TestParams { wallet, .. } = build_test_nodes().await; - - // Test 1: Verify wallet is using expected network - // This is more of a sanity check since we can't easily test invalid networks - // without creating new wallets - - // Test 2: Verify rebalancing can be toggled - let initial_rebalance_state = wallet.get_rebalance_enabled(); - assert!(initial_rebalance_state, "Rebalancing should be enabled by default"); - - wallet.set_rebalance_enabled(false); - assert!(!wallet.get_rebalance_enabled(), "Should be able to disable rebalancing"); - - wallet.set_rebalance_enabled(true); - assert!(wallet.get_rebalance_enabled(), "Should be able to re-enable rebalancing"); - - // Test 3: Verify tunables are consistent and reasonable - let tunables = wallet.get_tunables(); - - // Check for reasonable default values - assert!( - tunables.trusted_balance_limit > Amount::ZERO, - "Trusted balance limit should be positive" - ); - assert!(tunables.rebalance_min > Amount::ZERO, "Rebalance min should be positive"); - assert!( - tunables.onchain_receive_threshold > Amount::ZERO, - "Onchain threshold should be positive" - ); - - // Check relationships - assert!( - tunables.rebalance_min <= tunables.trusted_balance_limit, - "Rebalance min should not exceed trusted balance limit" - ); - - // Test 4: Test URI generation consistency across multiple calls - let amount = Amount::from_sats(5000).unwrap(); - let uri1 = wallet.get_single_use_receive_uri(Some(amount)).await.unwrap(); - let uri2 = wallet.get_single_use_receive_uri(Some(amount)).await.unwrap(); - - // Should generate different invoices (single use) - assert_ne!( - uri1.invoice.to_string(), - uri2.invoice.to_string(), - "Single-use URIs should be unique" - ); - - // But same amount and policy decisions - assert_eq!( - uri1.invoice.amount_milli_satoshis(), - uri2.invoice.amount_milli_satoshis(), - "Same amount should be preserved" - ); - assert_eq!( - uri1.address.is_some(), - uri2.address.is_some(), - "Address inclusion should be consistent" - ); + // But same amount and policy decisions + assert_eq!( + uri1.invoice.amount_milli_satoshis(), + uri2.invoice.amount_milli_satoshis(), + "Same amount should be preserved" + ); + assert_eq!( + uri1.address.is_some(), + uri2.address.is_some(), + "Address inclusion should be consistent" + ); + }) + .await; } #[tokio::test(flavor = "multi_thread")] async fn test_edge_case_payment_instruction_parsing() { - let TestParams { wallet, third_party, .. } = build_test_nodes().await; - - // Test 1: Empty strings - let empty_result = wallet.parse_payment_instructions("").await; - assert!( - matches!(empty_result, Err(ParseError::UnknownPaymentInstructions)), - "Empty string should fail with UnknownPaymentInstructions error" - ); - - // Test 2: Whitespace-only strings - let whitespace_result = wallet.parse_payment_instructions(" \t\n ").await; - assert!( - matches!(whitespace_result, Err(ParseError::UnknownPaymentInstructions)), - "Whitespace-only string should fail with UnknownPaymentInstructions error" - ); - - // Test 3: Very long invalid strings - let long_invalid = "a".repeat(1000); - let long_result = wallet.parse_payment_instructions(&long_invalid).await; - assert!( - matches!(long_result, Err(ParseError::UnknownPaymentInstructions)), - "Very long invalid string should fail with UnknownPaymentInstructions error" - ); - - // Test 4: Mixed case handling - // Create a valid invoice first - let amount = Amount::from_sats(1000).unwrap(); - let desc = Bolt11InvoiceDescription::Direct(Description::empty()); - let invoice = third_party.bolt11_payment().receive(amount.milli_sats(), &desc, 300).unwrap(); - let invoice_str = invoice.to_string(); - - // Test uppercase - let _upper_result = wallet.parse_payment_instructions(&invoice_str.to_uppercase()).await; - let _lower_result = wallet.parse_payment_instructions(&invoice_str.to_lowercase()).await; - let original_result = wallet.parse_payment_instructions(&invoice_str).await; - - // At least the original should work - assert!(original_result.is_ok(), "Original invoice should parse successfully"); - - // Test 5: Invoices with special characters or encoding - let special_chars = ["lightning:", "bitcoin:?lightning=", "LIGHTNING:", "BITCOIN:?LIGHTNING="]; - for prefix in special_chars { - let prefixed = format!("{}{}", prefix, invoice_str); - let result = wallet.parse_payment_instructions(&prefixed).await; - assert!(result.is_ok(), "Failed to parse payment instructions"); - } + test_utils::run_test(|params| async move { + let wallet = Arc::clone(¶ms.wallet); + let third_party = Arc::clone(¶ms.third_party); + + // Test 1: Empty strings + let empty_result = wallet.parse_payment_instructions("").await; + assert!( + matches!(empty_result, Err(ParseError::UnknownPaymentInstructions)), + "Empty string should fail with UnknownPaymentInstructions error" + ); + + // Test 2: Whitespace-only strings + let whitespace_result = wallet.parse_payment_instructions(" \t\n ").await; + assert!( + matches!(whitespace_result, Err(ParseError::UnknownPaymentInstructions)), + "Whitespace-only string should fail with UnknownPaymentInstructions error" + ); + + // Test 3: Very long invalid strings + let long_invalid = "a".repeat(1000); + let long_result = wallet.parse_payment_instructions(&long_invalid).await; + assert!( + matches!(long_result, Err(ParseError::UnknownPaymentInstructions)), + "Very long invalid string should fail with UnknownPaymentInstructions error" + ); + + // Test 4: Mixed case handling + // Create a valid invoice first + let amount = Amount::from_sats(1000).unwrap(); + let desc = Bolt11InvoiceDescription::Direct(Description::empty()); + let invoice = + third_party.bolt11_payment().receive(amount.milli_sats(), &desc, 300).unwrap(); + let invoice_str = invoice.to_string(); + + // Test uppercase + let _upper_result = wallet.parse_payment_instructions(&invoice_str.to_uppercase()).await; + let _lower_result = wallet.parse_payment_instructions(&invoice_str.to_lowercase()).await; + let original_result = wallet.parse_payment_instructions(&invoice_str).await; + + // At least the original should work + assert!(original_result.is_ok(), "Original invoice should parse successfully"); + + // Test 5: Invoices with special characters or encoding + let special_chars = + ["lightning:", "bitcoin:?lightning=", "LIGHTNING:", "BITCOIN:?LIGHTNING="]; + for prefix in special_chars { + let prefixed = format!("{}{}", prefix, invoice_str); + let result = wallet.parse_payment_instructions(&prefixed).await; + assert!(result.is_ok(), "Failed to parse payment instructions"); + } + }) + .await; } #[tokio::test(flavor = "multi_thread")] async fn test_lsp_connectivity_fallback() { - let TestParams { wallet, lsp, bitcoind, third_party, .. } = build_test_nodes().await; + test_utils::run_test(|params| async move { + let wallet = Arc::clone(¶ms.wallet); + let lsp = Arc::clone(¶ms.lsp); + let bitcoind = Arc::clone(¶ms.bitcoind); + let third_party = Arc::clone(¶ms.third_party); + + // open a channel with the LSP + open_channel_from_lsp(&wallet, Arc::clone(&third_party)).await; + + // confirm channel + generate_blocks(&bitcoind, 6); + test_utils::wait_for_condition("wallet sync after channel open", || async { + wallet.channels().iter().any(|a| a.confirmations.is_some_and(|c| c > 0) && a.is_usable) + && third_party + .list_channels() + .iter() + .any(|a| a.confirmations.is_some_and(|c| c > 0) && a.is_usable) + }) + .await; + + // spend some of the balance so we have some inbound capacity + let amount = Amount::from_sats(10_000).unwrap(); + let inv = third_party + .bolt11_payment() + .receive( + amount.milli_sats(), + &Bolt11InvoiceDescription::Direct(Description::empty()), + 300, + ) + .unwrap(); + let instr = wallet.parse_payment_instructions(inv.to_string().as_str()).await.unwrap(); + let info = PaymentInfo::build(instr, Some(amount)).unwrap(); + let _ = wallet.pay(&info).await; - // open a channel with the LSP - open_channel_from_lsp(&wallet, Arc::clone(&third_party)).await; + // Wait for the payment to be processed + let event = wait_next_event(&wallet).await; + match event { + Event::PaymentSuccessful { .. } => {}, + e => panic!("Expected PaymentSuccessful event, got: {e:?}"), + } - // confirm channel - generate_blocks(&bitcoind, 6); - test_utils::wait_for_condition("wallet sync after channel open", || async { - wallet.channels().iter().any(|a| a.confirmations.is_some_and(|c| c > 0) && a.is_usable) - && third_party - .list_channels() - .iter() - .any(|a| a.confirmations.is_some_and(|c| c > 0) && a.is_usable) - }) - .await; + // Get the wallet's tunables to find the trusted balance limit + let tunables = wallet.get_tunables(); - // spend some of the balance so we have some inbound capacity - let amount = Amount::from_sats(10_000).unwrap(); - let inv = third_party - .bolt11_payment() - .receive(amount.milli_sats(), &Bolt11InvoiceDescription::Direct(Description::empty()), 300) + // Use an amount that would normally go to Lightning (above trusted balance limit) + let additional_amount = Amount::from_sats(1000).unwrap(); + let large_recv_amt = Amount::from_milli_sats( + tunables.trusted_balance_limit.milli_sats() + additional_amount.milli_sats(), + ) .unwrap(); - let instr = wallet.parse_payment_instructions(inv.to_string().as_str()).await.unwrap(); - let info = PaymentInfo::build(instr, Some(amount)).unwrap(); - let _ = wallet.pay(&info).await; - - // Wait for the payment to be processed - let event = wait_next_event(&wallet).await; - match event { - Event::PaymentSuccessful { .. } => {}, - e => panic!("Expected PaymentSuccessful event, got: {e:?}"), - } - - // Get the wallet's tunables to find the trusted balance limit - let tunables = wallet.get_tunables(); - - // Use an amount that would normally go to Lightning (above trusted balance limit) - let additional_amount = Amount::from_sats(1000).unwrap(); - let large_recv_amt = Amount::from_milli_sats( - tunables.trusted_balance_limit.milli_sats() + additional_amount.milli_sats(), - ) - .unwrap(); - - // First, verify that with LSP online, this large amount would normally use Lightning - let uri_with_lsp = wallet.get_single_use_receive_uri(Some(large_recv_amt)).await.unwrap(); - // This should work when LSP is online - assert!(!uri_with_lsp.from_trusted); - - // Now simulate LSP being offline by stopping it - let _ = lsp.stop(); - - // Wait a moment for the stop to take effect - tokio::time::sleep(Duration::from_secs(2)).await; - - // Now try to receive the same large amount that would normally trigger Lightning usage - // but should fall back to trusted wallet due to LSP being offline - let uri_result = wallet.get_single_use_receive_uri(Some(large_recv_amt)).await.unwrap(); - assert!(uri_result.from_trusted); - - // test with small amount that should succeed - let small_recv_amt = Amount::from_sats(100).unwrap(); - let uri_small = wallet.get_single_use_receive_uri(Some(small_recv_amt)).await.unwrap(); - assert_eq!( - uri_small.invoice.amount_milli_satoshis(), - Some(small_recv_amt.milli_sats()), - "Small amount should still generate a valid invoice even with LSP offline" - ); - assert!(uri_small.from_trusted); + + // First, verify that with LSP online, this large amount would normally use Lightning + let uri_with_lsp = wallet.get_single_use_receive_uri(Some(large_recv_amt)).await.unwrap(); + // This should work when LSP is online + assert!(!uri_with_lsp.from_trusted); + + // Now simulate LSP being offline by stopping it + let _ = lsp.stop(); + + // Wait a moment for the stop to take effect + tokio::time::sleep(Duration::from_secs(2)).await; + + // Now try to receive the same large amount that would normally trigger Lightning usage + // but should fall back to trusted wallet due to LSP being offline + let uri_result = wallet.get_single_use_receive_uri(Some(large_recv_amt)).await.unwrap(); + assert!(uri_result.from_trusted); + + // test with small amount that should succeed + let small_recv_amt = Amount::from_sats(100).unwrap(); + let uri_small = wallet.get_single_use_receive_uri(Some(small_recv_amt)).await.unwrap(); + assert_eq!( + uri_small.invoice.amount_milli_satoshis(), + Some(small_recv_amt.milli_sats()), + "Small amount should still generate a valid invoice even with LSP offline" + ); + assert!(uri_small.from_trusted); + }) + .await; } diff --git a/orange-sdk/tests/test_utils.rs b/orange-sdk/tests/test_utils.rs index 545ebdb..f09c114 100644 --- a/orange-sdk/tests/test_utils.rs +++ b/orange-sdk/tests/test_utils.rs @@ -218,8 +218,9 @@ fn fund_node(node: &Node, bitcoind: &Bitcoind) { generate_blocks(bitcoind, 6); } +#[derive(Clone)] pub struct TestParams { - pub wallet: orange_sdk::Wallet, + pub wallet: Arc, pub lsp: Arc, pub third_party: Arc, pub bitcoind: Arc, @@ -227,7 +228,36 @@ pub struct TestParams { pub _mint: Arc, } -pub async fn build_test_nodes() -> TestParams { +impl TestParams { + async fn stop(&self) { + self.wallet.stop().await; + + #[cfg(feature = "_cashu-tests")] + let _ = self._mint.stop().await; + + let _ = self.lsp.stop(); + let _ = self.third_party.stop(); + } +} + +/// Runs a test with automatically managed TestParams lifecycle. +/// The test closure receives TestParams and must return it when done. +/// Cleanup happens automatically after the test completes. +pub async fn run_test(test: F) +where + F: FnOnce(TestParams) -> Fut, + Fut: Future, +{ + let params = build_test_nodes().await; + + // Run the test and get params back + test(params.clone()).await; + + // Always clean up + params.stop().await; +} + +async fn build_test_nodes() -> TestParams { let test_id = Uuid::now_v7(); let bitcoind = Arc::new(create_bitcoind(test_id)); @@ -268,7 +298,7 @@ pub async fn build_test_nodes() -> TestParams { rand::thread_rng().fill_bytes(&mut seed); #[cfg(not(feature = "_cashu-tests"))] - let wallet: orange_sdk::Wallet = { + let wallet = { let dummy_wallet_config = DummyTrustedWalletExtraConfig { uuid: test_id, lsp: Arc::clone(&lsp), @@ -298,7 +328,7 @@ pub async fn build_test_nodes() -> TestParams { extra_config: ExtraConfig::Dummy(dummy_wallet_config), }; - Wallet::new(wallet_config).await.unwrap() + Arc::new(Wallet::new(wallet_config).await.unwrap()) }; #[cfg(feature = "_cashu-tests")] @@ -431,7 +461,7 @@ pub async fn build_test_nodes() -> TestParams { unit: orange_sdk::CurrencyUnit::Sat, }), }; - let wallet = Wallet::new(wallet_config).await.unwrap(); + let wallet = Arc::new(Wallet::new(wallet_config).await.unwrap()); return TestParams { wallet, lsp, third_party, bitcoind, _mint: mint }; }; From 8d6e593c086f679970b11d2c75077773db85dfd3 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Fri, 31 Oct 2025 14:13:06 -0500 Subject: [PATCH 07/20] ldk 0.2.0-rc1 --- Cargo.toml | 4 +-- orange-sdk/Cargo.toml | 10 +++---- .../src/trusted_wallet/cashu/cashu_store.rs | 26 ++++++++++++------- .../src/trusted_wallet/spark/spark_store.rs | 24 ++++++++++++----- 4 files changed, 41 insertions(+), 23 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 5222f00..fd77c38 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,8 +17,8 @@ panic = 'abort' # Abort on panic [workspace.dependencies] bitcoin-payment-instructions = { git = "https://github.com/benthecarman/bitcoin-payment-instructions.git", branch = "orange-fork2", features = ["http"] } -lightning = { version = "0.2.0-beta1" } -lightning-invoice = { version = "0.34.0-beta1" } +lightning = { version = "0.2.0-rc1" } +lightning-invoice = { version = "0.34.0-rc1" } [profile.release] panic = "abort" diff --git a/orange-sdk/Cargo.toml b/orange-sdk/Cargo.toml index 85c0c43..cd35ab1 100644 --- a/orange-sdk/Cargo.toml +++ b/orange-sdk/Cargo.toml @@ -23,7 +23,7 @@ _cashu-tests = ["_test-utils", "cdk-ldk-node", "cdk/mint", "cdk-sqlite", "cdk-ax [dependencies] graduated-rebalancer = { path = "../graduated-rebalancer", version = "0.1.0" } -ldk-node = { git = "https://github.com/lightningdevkit/ldk-node.git", rev = "6d91eabcb11bf3b32f0a2e5f43b55c98d84ba1f0" } +ldk-node = { git = "https://github.com/lightningdevkit/ldk-node.git", rev = "870521446700353ff6461da98bd6e2c2318a9930" } lightning-macros = "0.2.0-beta1" bitcoin-payment-instructions = { workspace = true } chrono = { version = "0.4", default-features = false } @@ -32,15 +32,15 @@ reqwest = { version = "0.12.23", default-features = false, features = ["rustls-t breez-sdk-spark = { git = "https://github.com/breez/spark-sdk.git", rev = "1f2e9995230cd582d6b4aa7d06d76b99defb635e", 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 = "62ac47d51d6c74c7b847ae0c19e7c84899efe872", default-features = false, features = ["wallet"], optional = true } +cdk = { git = "https://github.com/benthecarman/cdk.git", rev = "f95a3cfc3b48913e399ea6b080d99b5566437d0a", default-features = false, features = ["wallet"], optional = true } serde_json = { version = "1.0", optional = true } async-trait = "0.1" log = "0.4.28" corepc-node = { version = "0.8.0", features = ["29_0", "download"], optional = true } -cdk-ldk-node = { git = "https://github.com/benthecarman/cdk.git", rev = "62ac47d51d6c74c7b847ae0c19e7c84899efe872", optional = true } -cdk-sqlite = { git = "https://github.com/benthecarman/cdk.git", rev = "62ac47d51d6c74c7b847ae0c19e7c84899efe872", optional = true } -cdk-axum = { git = "https://github.com/benthecarman/cdk.git", rev = "62ac47d51d6c74c7b847ae0c19e7c84899efe872", optional = true } +cdk-ldk-node = { git = "https://github.com/benthecarman/cdk.git", rev = "f95a3cfc3b48913e399ea6b080d99b5566437d0a", optional = true } +cdk-sqlite = { git = "https://github.com/benthecarman/cdk.git", rev = "f95a3cfc3b48913e399ea6b080d99b5566437d0a", optional = true } +cdk-axum = { git = "https://github.com/benthecarman/cdk.git", rev = "f95a3cfc3b48913e399ea6b080d99b5566437d0a", optional = true } axum = { version = "0.8.1", optional = true } uniffi = { version = "0.29", features = ["cli", "tokio"], optional = true } diff --git a/orange-sdk/src/trusted_wallet/cashu/cashu_store.rs b/orange-sdk/src/trusted_wallet/cashu/cashu_store.rs index e127d45..9dbb38a 100644 --- a/orange-sdk/src/trusted_wallet/cashu/cashu_store.rs +++ b/orange-sdk/src/trusted_wallet/cashu/cashu_store.rs @@ -309,21 +309,27 @@ impl WalletDatabase for CashuKvDatabase { let mint_key = Self::generate_mint_key(&mint_url); // Remove mint URL by writing empty data - KVStore::remove(self.store.as_ref(), CASHU_PRIMARY_KEY, MINTS_KEY, &mint_key) + KVStore::remove(self.store.as_ref(), CASHU_PRIMARY_KEY, MINTS_KEY, &mint_key, false) .await .map_err(DatabaseError::Io)?; // Remove mint info let info_key = Self::generate_mint_info_key(&mint_url); - KVStore::remove(self.store.as_ref(), CASHU_PRIMARY_KEY, MINTS_KEY, &info_key) + KVStore::remove(self.store.as_ref(), CASHU_PRIMARY_KEY, MINTS_KEY, &info_key, false) .await .map_err(DatabaseError::Io)?; // Remove mint keysets let keysets_key = Self::generate_mint_keysets_key(&mint_url); - KVStore::remove(self.store.as_ref(), CASHU_PRIMARY_KEY, MINT_KEYSETS_KEY, &keysets_key) - .await - .map_err(DatabaseError::Io)?; + KVStore::remove( + self.store.as_ref(), + CASHU_PRIMARY_KEY, + MINT_KEYSETS_KEY, + &keysets_key, + false, + ) + .await + .map_err(DatabaseError::Io)?; // Update cache { @@ -580,7 +586,7 @@ impl WalletDatabase for CashuKvDatabase { async fn remove_mint_quote(&self, quote_id: &str) -> Result<(), Self::Err> { // Mark as removed by writing empty data - KVStore::remove(self.store.as_ref(), CASHU_PRIMARY_KEY, MINT_QUOTES_KEY, quote_id) + KVStore::remove(self.store.as_ref(), CASHU_PRIMARY_KEY, MINT_QUOTES_KEY, quote_id, false) .await .map_err(DatabaseError::Io)?; @@ -637,7 +643,7 @@ impl WalletDatabase for CashuKvDatabase { } async fn remove_melt_quote(&self, quote_id: &str) -> Result<(), Self::Err> { - KVStore::remove(self.store.as_ref(), CASHU_PRIMARY_KEY, MELT_QUOTES_KEY, quote_id) + KVStore::remove(self.store.as_ref(), CASHU_PRIMARY_KEY, MELT_QUOTES_KEY, quote_id, false) .await .map_err(DatabaseError::Io)?; @@ -682,7 +688,7 @@ impl WalletDatabase for CashuKvDatabase { async fn remove_keys(&self, id: &Id) -> Result<(), Self::Err> { let key = id.to_string(); - KVStore::remove(self.store.as_ref(), CASHU_PRIMARY_KEY, KEYS_KEY, &key) + KVStore::remove(self.store.as_ref(), CASHU_PRIMARY_KEY, KEYS_KEY, &key, false) .await .map_err(DatabaseError::Io)?; @@ -707,7 +713,7 @@ impl WalletDatabase for CashuKvDatabase { for y in &removed_ys { let key = format!("proof_{}", hex::encode(y.serialize())); - KVStore::remove(self.store.as_ref(), CASHU_PRIMARY_KEY, PROOFS_KEY, &key) + KVStore::remove(self.store.as_ref(), CASHU_PRIMARY_KEY, PROOFS_KEY, &key, false) .await .map_err(DatabaseError::Io)?; } @@ -913,7 +919,7 @@ impl WalletDatabase for CashuKvDatabase { async fn remove_transaction(&self, transaction_id: TransactionId) -> Result<(), Self::Err> { let key = transaction_id.to_string(); - KVStore::remove(self.store.as_ref(), CASHU_PRIMARY_KEY, TRANSACTIONS_KEY, &key) + KVStore::remove(self.store.as_ref(), CASHU_PRIMARY_KEY, TRANSACTIONS_KEY, &key, false) .await .map_err(DatabaseError::Io)?; diff --git a/orange-sdk/src/trusted_wallet/spark/spark_store.rs b/orange-sdk/src/trusted_wallet/spark/spark_store.rs index 44ff001..3f50946 100644 --- a/orange-sdk/src/trusted_wallet/spark/spark_store.rs +++ b/orange-sdk/src/trusted_wallet/spark/spark_store.rs @@ -33,9 +33,15 @@ fn sanitize_key(key: String) -> String { impl breez_sdk_spark::Storage for SparkStore { async fn delete_cached_item(&self, key: String) -> Result<(), StorageError> { let key = sanitize_key(key); - KVStore::remove(self.0.as_ref(), SPARK_PRIMARY_NAMESPACE, SPARK_CACHE_NAMESPACE, &key, false) - .await - .map_err(|e| StorageError::Implementation(format!("{e:?}")))?; + KVStore::remove( + self.0.as_ref(), + SPARK_PRIMARY_NAMESPACE, + SPARK_CACHE_NAMESPACE, + &key, + false, + ) + .await + .map_err(|e| StorageError::Implementation(format!("{e:?}")))?; Ok(()) } @@ -211,9 +217,15 @@ impl breez_sdk_spark::Storage for SparkStore { async fn delete_deposit(&self, txid: String, vout: u32) -> Result<(), StorageError> { let id = format!("{txid}:{vout}"); - KVStore::remove(self.0.as_ref(), SPARK_PRIMARY_NAMESPACE, SPARK_DEPOSITS_NAMESPACE, &id) - .await - .map_err(|e| StorageError::Implementation(format!("{e:?}")))?; + KVStore::remove( + self.0.as_ref(), + SPARK_PRIMARY_NAMESPACE, + SPARK_DEPOSITS_NAMESPACE, + &id, + false, + ) + .await + .map_err(|e| StorageError::Implementation(format!("{e:?}")))?; Ok(()) } From d2aca4cd840cb2f10ad67ac9156e3b053cce5de3 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Tue, 4 Nov 2025 15:19:05 -0600 Subject: [PATCH 08/20] update ldk-node --- orange-sdk/Cargo.toml | 2 +- orange-sdk/src/event.rs | 21 +++++++++++++-------- orange-sdk/src/trusted_wallet/dummy.rs | 2 ++ 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/orange-sdk/Cargo.toml b/orange-sdk/Cargo.toml index cd35ab1..0e1147c 100644 --- a/orange-sdk/Cargo.toml +++ b/orange-sdk/Cargo.toml @@ -23,7 +23,7 @@ _cashu-tests = ["_test-utils", "cdk-ldk-node", "cdk/mint", "cdk-sqlite", "cdk-ax [dependencies] graduated-rebalancer = { path = "../graduated-rebalancer", version = "0.1.0" } -ldk-node = { git = "https://github.com/lightningdevkit/ldk-node.git", rev = "870521446700353ff6461da98bd6e2c2318a9930" } +ldk-node = { git = "https://github.com/benthecarman/ldk-node.git", rev = "d0d91b809065dc3689d2eda75b9718bf1ec1da69" } lightning-macros = "0.2.0-beta1" bitcoin-payment-instructions = { workspace = true } chrono = { version = "0.4", default-features = false } diff --git a/orange-sdk/src/event.rs b/orange-sdk/src/event.rs index af068bd..931585a 100644 --- a/orange-sdk/src/event.rs +++ b/orange-sdk/src/event.rs @@ -376,14 +376,13 @@ impl LdkEventHandler { ldk_node::Event::ChannelPending { .. } => { log_debug!(self.logger, "Received ChannelPending event"); }, - ldk_node::Event::ChannelReady { channel_id, user_channel_id, counterparty_node_id } => { - let funding_txo = self - .ldk_node - .list_channels() - .iter() - .find(|c| c.user_channel_id == user_channel_id) - .and_then(|c| c.funding_txo) - .unwrap(); + ldk_node::Event::ChannelReady { + channel_id, + user_channel_id, + counterparty_node_id, + funding_txo, + } => { + let funding_txo = funding_txo.unwrap(); // safe if let Err(e) = self.event_queue.add_event(Event::ChannelOpened { channel_id, @@ -416,6 +415,12 @@ impl LdkEventHandler { return; } }, + ldk_node::Event::SplicePending { .. } => { + log_debug!(self.logger, "Received SplicePending event"); + }, + ldk_node::Event::SpliceFailed { .. } => { + log_debug!(self.logger, "Received SpliceFailed event"); + }, } if let Err(e) = self.ldk_node.event_handled() { diff --git a/orange-sdk/src/trusted_wallet/dummy.rs b/orange-sdk/src/trusted_wallet/dummy.rs index 8b73e7e..58aa2d1 100644 --- a/orange-sdk/src/trusted_wallet/dummy.rs +++ b/orange-sdk/src/trusted_wallet/dummy.rs @@ -203,6 +203,8 @@ impl DummyTrustedWallet { Event::ChannelPending { .. } => {}, Event::ChannelReady { .. } => {}, Event::ChannelClosed { .. } => {}, + Event::SplicePending { .. } => {}, + Event::SpliceFailed { .. } => {}, } println!("dummy: {event:?}"); if let Err(e) = events_ref.event_handled() { From 2b3507f482f38a8226491c54b4603f9e65b73a20 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Wed, 5 Nov 2025 20:54:10 -0600 Subject: [PATCH 09/20] WIP: splice in --- examples/cli/src/main.rs | 7 ++ graduated-rebalancer/src/lib.rs | 64 ++++++++++++---- orange-sdk/src/event.rs | 41 +++++++++- orange-sdk/src/ffi/orange/mod.rs | 22 ++++++ orange-sdk/src/lib.rs | 13 +++- orange-sdk/src/lightning_wallet.rs | 77 ++++++++++++++++++- orange-sdk/src/rebalancer.rs | 4 +- orange-sdk/tests/integration_tests.rs | 103 ++++++++++++++++++++++++++ orange-sdk/tests/test_utils.rs | 2 + 9 files changed, 311 insertions(+), 22 deletions(-) diff --git a/examples/cli/src/main.rs b/examples/cli/src/main.rs index f86c179..a3c13e7 100644 --- a/examples/cli/src/main.rs +++ b/examples/cli/src/main.rs @@ -192,6 +192,13 @@ impl WalletState { fee_msat ); }, + Event::SplicePending { new_funding_txo, .. } => { + println!( + "{} Splice pending: {}", + "🔄".bright_yellow(), + new_funding_txo + ); + }, } w.event_handled().unwrap(); diff --git a/graduated-rebalancer/src/lib.rs b/graduated-rebalancer/src/lib.rs index 6c68b41..282ffb3 100644 --- a/graduated-rebalancer/src/lib.rs +++ b/graduated-rebalancer/src/lib.rs @@ -108,6 +108,9 @@ pub trait LightningWallet: Send + Sync { &self, payment_hash: [u8; 32], ) -> Pin> + Send + '_>>; + /// Check if we already have a channel with the LSP + fn has_channel_with_lsp(&self) -> bool; + /// Open a channel with the LSP using on-chain funds fn open_channel_with_lsp( &self, amt: Amount, @@ -117,6 +120,16 @@ pub trait LightningWallet: Send + Sync { fn await_channel_pending( &self, channel_id: u128, ) -> Pin + Send + '_>>; + + /// Splice funds from on-chain to an existing channel with the LSP + fn splice_to_lsp_channel( + &self, amt: Amount, + ) -> Pin> + Send + '_>>; + + /// Wait for a splice pending notification, returns the splice outpoint + fn await_splice_pending( + &self, channel_id: u128, + ) -> Pin + Send + '_>>; } /// Represents a payment from the lightning wallet @@ -313,32 +326,51 @@ where } } - /// Perform on-chain to lightning rebalance by opening a channel + /// Perform on-chain to lightning rebalance by opening a channel or splicing into an existing one async fn do_onchain_rebalance(&self, params: TriggerParams) { - // This should open a channel with the LSP using available on-chain funds - let _ = self.balance_mutex.lock().await; - log_info!(self.logger, "Opening channel with LSP with on-chain funds"); + let (channel_outpoint, user_channel_id) = if self.ln_wallet.has_channel_with_lsp() { + log_info!(self.logger, "Splicing into channel with LSP with on-chain funds"); - // todo for now we can only open a channel, eventually move to splicing - let user_chan_id = match self.ln_wallet.open_channel_with_lsp(params.amount).await { - Ok(chan_id) => chan_id, - Err(e) => { - log_error!(self.logger, "Failed to open channel with LSP: {e:?}"); - return; - }, - }; + let user_chan_id = match self.ln_wallet.splice_to_lsp_channel(params.amount).await { + Ok(chan_id) => chan_id, + Err(e) => { + log_error!(self.logger, "Failed to open channel with LSP: {e:?}"); + return; + }, + }; + + log_info!(self.logger, "Initiated splice opened with LSP"); + + let channel_outpoint = self.ln_wallet.await_splice_pending(user_chan_id).await; + + log_info!(self.logger, "Splice initiated at: {channel_outpoint}"); + + (channel_outpoint, user_chan_id) + } else { + log_info!(self.logger, "Opening channel with LSP with on-chain funds"); - log_info!(self.logger, "Initiated channel opened with LSP"); + let user_chan_id = match self.ln_wallet.open_channel_with_lsp(params.amount).await { + Ok(chan_id) => chan_id, + Err(e) => { + log_error!(self.logger, "Failed to open channel with LSP: {e:?}"); + return; + }, + }; + + log_info!(self.logger, "Initiated channel opened with LSP"); - let channel_outpoint = self.ln_wallet.await_channel_pending(user_chan_id).await; + let channel_outpoint = self.ln_wallet.await_channel_pending(user_chan_id).await; - log_info!(self.logger, "Channel open succeeded at: {channel_outpoint}",); + log_info!(self.logger, "Channel open succeeded at: {channel_outpoint}"); + + (channel_outpoint, user_chan_id) + }; self.event_handler.handle_event(RebalancerEvent::OnChainRebalanceInitiated { trigger_id: params.id, - user_channel_id: user_chan_id, + user_channel_id, channel_outpoint, }); } diff --git a/orange-sdk/src/event.rs b/orange-sdk/src/event.rs index 931585a..b49a857 100644 --- a/orange-sdk/src/event.rs +++ b/orange-sdk/src/event.rs @@ -131,6 +131,17 @@ pub enum Event { /// The fee paid, in msats, for the rebalance payment. fee_msat: u64, }, + /// We have initiated a splice and are waiting for it to confirm. + SplicePending { + /// The `channel_id` of the channel. + channel_id: ChannelId, + /// The `user_channel_id` of the channel. + user_channel_id: UserChannelId, + /// The `node_id` of the channel counterparty. + counterparty_node_id: PublicKey, + /// The outpoint of the channel's splice funding transaction. + new_funding_txo: OutPoint, + }, } impl_writeable_tlv_based_enum!(Event, @@ -182,6 +193,12 @@ impl_writeable_tlv_based_enum!(Event, (6, amount_msat, required), (8, fee_msat, required), }, + (8, SplicePending) => { + (1, channel_id, required), + (3, counterparty_node_id, required), + (5, user_channel_id, required), + (7, new_funding_txo, required), + }, ); /// A queue for events emitted by the [`Wallet`]. @@ -301,6 +318,7 @@ pub(crate) struct LdkEventHandler { pub(crate) tx_metadata: store::TxMetadataStore, pub(crate) payment_receipt_sender: watch::Sender<()>, pub(crate) channel_pending_sender: watch::Sender, + pub(crate) splice_pending_sender: watch::Sender, pub(crate) logger: Arc, } @@ -415,11 +433,28 @@ impl LdkEventHandler { return; } }, - ldk_node::Event::SplicePending { .. } => { - log_debug!(self.logger, "Received SplicePending event"); + ldk_node::Event::SplicePending { + channel_id, + user_channel_id, + counterparty_node_id, + new_funding_txo, + } => { + log_debug!(self.logger, "Received SplicePending event {event:?}"); + let _ = self.splice_pending_sender.send(user_channel_id.0); + + if let Err(e) = self.event_queue.add_event(Event::SplicePending { + channel_id, + user_channel_id, + counterparty_node_id, + new_funding_txo, + }) { + log_error!(self.logger, "Failed to add SplicePending event: {e:?}"); + return; + } }, ldk_node::Event::SpliceFailed { .. } => { - log_debug!(self.logger, "Received SpliceFailed event"); + println!("===========splice failed============"); + log_warn!(self.logger, "Received SpliceFailed event: {event:?}"); }, } diff --git a/orange-sdk/src/ffi/orange/mod.rs b/orange-sdk/src/ffi/orange/mod.rs index a064012..af22e65 100644 --- a/orange-sdk/src/ffi/orange/mod.rs +++ b/orange-sdk/src/ffi/orange/mod.rs @@ -262,6 +262,17 @@ pub enum Event { /// The fee paid, in msats, for the rebalance payment. fee_msat: u64, }, + /// We have initiated a splice and are waiting for it to confirm. + SplicePending { + /// The `channel_id` of the channel. + channel_id: Vec, + /// The `user_channel_id` of the channel. + user_channel_id: Vec, + /// The `node_id` of the channel counterparty. + counterparty_node_id: Vec, + /// The outpoint of the channel's splice funding transaction. + new_funding_txo: String, + }, } impl From for Event { @@ -349,6 +360,17 @@ impl From for Event { amount_msat, fee_msat, }, + OrangeEvent::SplicePending { + channel_id, + user_channel_id, + counterparty_node_id, + new_funding_txo, + } => Event::SplicePending { + channel_id: channel_id.0.to_vec(), + user_channel_id: user_channel_id.0.to_be_bytes().to_vec(), + counterparty_node_id: counterparty_node_id.serialize().to_vec(), + new_funding_txo: new_funding_txo.to_string(), + }, } } } diff --git a/orange-sdk/src/lib.rs b/orange-sdk/src/lib.rs index 3a08da8..576ca96 100644 --- a/orange-sdk/src/lib.rs +++ b/orange-sdk/src/lib.rs @@ -662,11 +662,14 @@ impl Wallet { /// Lists the transactions which have been made. pub async fn list_transactions(&self) -> Result, WalletError> { let trusted_payments = self.inner.trusted.list_payments().await?; - let lightning_payments = self.inner.ln_wallet.list_payments(); + let mut lightning_payments = self.inner.ln_wallet.list_payments(); + lightning_payments.sort_by_key(|l| l.latest_update_timestamp); let mut res = Vec::with_capacity(trusted_payments.len() + lightning_payments.len()); let tx_metadata = self.inner.tx_metadata.read(); + println!("\n\n======================="); + let mut internal_transfers = HashMap::new(); #[derive(Debug, Default)] struct InternalTransfer { @@ -770,6 +773,7 @@ impl Wallet { }); } } + println!("ln payments: {:#?}", lightning_payments); for payment in lightning_payments { use ldk_node::payment::PaymentDirection; let lightning_receive_fee = match payment.kind { @@ -789,6 +793,10 @@ impl Wallet { ), }; if let Some(tx_metadata) = tx_metadata.get(&PaymentId::SelfCustodial(payment.id.0)) { + println!( + "Found metadata for lightning payment {} got {:?}", + payment.id, tx_metadata.ty + ); match &tx_metadata.ty { TxType::TrustedToLightning { trusted_payment: _, @@ -811,6 +819,7 @@ impl Wallet { .or_insert(InternalTransfer::default()); if &payment.id.0 == channel_txid.as_byte_array() { debug_assert!(entry.send_fee.is_none()); + println!("onchain to ln fee: {:?}", payment.fee_paid_msat); entry.send_fee = payment .fee_paid_msat .map(|fee| Amount::from_milli_sats(fee).expect("Must be valid")); @@ -828,6 +837,8 @@ impl Wallet { }); debug_assert!(entry.transaction.is_none()); + println!("trigger: {:?}", payment.fee_paid_msat); + entry.transaction = Some(Transaction { id: PaymentId::SelfCustodial(payment.id.0), status: payment.status.into(), diff --git a/orange-sdk/src/lightning_wallet.rs b/orange-sdk/src/lightning_wallet.rs index 56b03d3..c605b20 100644 --- a/orange-sdk/src/lightning_wallet.rs +++ b/orange-sdk/src/lightning_wallet.rs @@ -38,8 +38,10 @@ pub(crate) struct LightningWalletBalance { pub(crate) struct LightningWalletImpl { pub(crate) ldk_node: Arc, + logger: Arc, payment_receipt_flag: watch::Receiver<()>, channel_pending_receipt_flag: watch::Receiver, + splice_pending_receipt_flag: watch::Receiver, lsp_node_id: PublicKey, lsp_socket_addr: SocketAddress, } @@ -163,18 +165,22 @@ impl LightningWallet { let ldk_node = Arc::new(builder.build_with_store(Arc::clone(&store))?); let (payment_receipt_sender, payment_receipt_flag) = watch::channel(()); let (channel_pending_sender, channel_pending_receipt_flag) = watch::channel(0); + let (splice_pending_sender, splice_pending_receipt_flag) = watch::channel(0); let ev_handler = Arc::new(LdkEventHandler { event_queue, ldk_node: Arc::clone(&ldk_node), tx_metadata, payment_receipt_sender, channel_pending_sender, - logger, + splice_pending_sender, + logger: Arc::clone(&logger), }); let inner = Arc::new(LightningWalletImpl { ldk_node, + logger, payment_receipt_flag, channel_pending_receipt_flag, + splice_pending_receipt_flag, lsp_node_id, lsp_socket_addr, }); @@ -204,6 +210,12 @@ impl LightningWallet { flag.wait_for(|t| t == &channel_id).await.expect("channel pending not received"); } + pub(crate) async fn await_splice_pending(&self, channel_id: u128) { + let mut flag = self.inner.splice_pending_receipt_flag.clone(); + flag.mark_unchanged(); + flag.wait_for(|t| t == &channel_id).await.expect("splice pending not received"); + } + pub(crate) fn get_on_chain_address(&self) -> Result { self.inner.ldk_node.onchain_payment().new_address() } @@ -295,6 +307,32 @@ impl LightningWallet { } } + pub(crate) async fn splice_balance_into_channel( + &self, amount: Amount, + ) -> Result { + // find existing channel to splice into + let channels = self.inner.ldk_node.list_channels(); + let channel = channels.iter().find(|c| c.counterparty_node_id == self.inner.lsp_node_id); + + // todo fix this, for now leave some onchain balance for fees + let amt = amount.saturating_sub(Amount::from_sats(10_000).unwrap()); + + match channel { + Some(chan) => { + self.inner.ldk_node.splice_in( + &chan.user_channel_id, + chan.counterparty_node_id, + amt.sats_rounding_up(), + )?; + Ok(chan.user_channel_id) + }, + None => { + log_error!(self.inner.logger, "No existing channel to splice into"); + Err(NodeError::WalletOperationFailed) + }, + } + } + pub(crate) async fn open_channel_with_lsp(&self) -> Result { let bal = self.inner.ldk_node.list_balances().spendable_onchain_balance_sats; @@ -407,6 +445,11 @@ impl graduated_rebalancer::LightningWallet for LightningWallet { }) } + fn has_channel_with_lsp(&self) -> bool { + let channels = self.inner.ldk_node.list_channels(); + channels.iter().any(|c| c.counterparty_node_id == self.inner.lsp_node_id) + } + fn open_channel_with_lsp( &self, _amt: Amount, ) -> Pin> + Send + '_>> { @@ -435,6 +478,38 @@ impl graduated_rebalancer::LightningWallet for LightningWallet { } }) } + + fn splice_to_lsp_channel( + &self, amt: Amount, + ) -> Pin> + Send + '_>> { + Box::pin(async move { self.splice_balance_into_channel(amt).await.map(|c| c.0) }) + } + + fn await_splice_pending( + &self, channel_id: u128, + ) -> Pin + Send + '_>> { + Box::pin(async move { + // todo since we can't see if we have any active splices, we just await the next splice pending event + // this is kinda race-y hopefully we can fix + self.await_splice_pending(channel_id).await; + loop { + let channels = self.inner.ldk_node.list_channels(); + let chan = channels + .into_iter() + .find(|c| c.user_channel_id.0 == channel_id && c.funding_txo.is_some()); + match chan { + Some(c) => { + println!("\nRETURNING HERE\n"); + return c.funding_txo.expect("channel has no funding txo"); + }, + None => { + self.await_splice_pending(channel_id).await; + // Wait for the next channel pending event + }, + } + } + }) + } } impl From for TxStatus { diff --git a/orange-sdk/src/rebalancer.rs b/orange-sdk/src/rebalancer.rs index 6c1fb0b..2e804ed 100644 --- a/orange-sdk/src/rebalancer.rs +++ b/orange-sdk/src/rebalancer.rs @@ -10,7 +10,6 @@ use bitcoin_payment_instructions::amount::Amount; use graduated_rebalancer::{RebalanceTrigger, RebalancerEvent, TriggerParams}; use ldk_node::DynStore; use ldk_node::lightning::util::logger::Logger as _; -use ldk_node::lightning::util::persist::KVStore; use ldk_node::lightning::{log_error, log_info, log_trace, log_warn}; use ldk_node::payment::{ConfirmationStatus, PaymentDirection, PaymentKind, PaymentStatus}; use std::cmp; @@ -339,6 +338,9 @@ impl graduated_rebalancer::EventHandler for OrangeRebalanceEventHandler { } => { let chan_txid = channel_outpoint.txid; let triggering_txid = Txid::from_byte_array(trigger_id); + println!( + "Marking {chan_txid} as onchain rebalance initiated for triggering txid {triggering_txid}" + ); let trigger_id = PaymentId::SelfCustodial(triggering_txid.to_byte_array()); self.tx_metadata .set_tx_caused_rebalance(&trigger_id) diff --git a/orange-sdk/tests/integration_tests.rs b/orange-sdk/tests/integration_tests.rs index 2c652b1..02f43a7 100644 --- a/orange-sdk/tests/integration_tests.rs +++ b/orange-sdk/tests/integration_tests.rs @@ -481,6 +481,109 @@ async fn test_receive_to_onchain() { .await; } +#[tokio::test(flavor = "multi_thread")] +// #[test_log::test] +async fn test_receive_to_onchain_with_channel() { + test_utils::run_test(|params| async move { + let wallet = Arc::clone(¶ms.wallet); + let lsp = Arc::clone(¶ms.lsp); + let bitcoind = Arc::clone(¶ms.bitcoind); + let third_party = Arc::clone(¶ms.third_party); + + let start = open_channel_from_lsp(&wallet, Arc::clone(&third_party)).await; + + let starting_bal = wallet.get_balance().await.unwrap(); + // channel amt - opening fees + assert_eq!( + starting_bal.available_balance(), + start.saturating_sub(Amount::from_sats(2_000).unwrap()) + ); + assert_eq!(starting_bal.pending_balance, Amount::ZERO); + + let recv_amt = Amount::from_sats(300_000).unwrap(); + + let uri = wallet.get_single_use_receive_uri(Some(recv_amt)).await.unwrap(); + let sent_txid = third_party + .onchain_payment() + .send_to_address(&uri.address.unwrap(), recv_amt.sats().unwrap(), None) + .unwrap(); + + println!("Sent txid: {}", sent_txid); + + // confirm transaction + generate_blocks(&bitcoind, 6); + + // check we received on-chain, should be pending + // wait for payment success + test_utils::wait_for_condition("pending balance to update", || async { + // onchain balance is always listed as pending until we splice it into the channel. + wallet.get_balance().await.unwrap().pending_balance == recv_amt + }) + .await; + + let event = wait_next_event(&wallet).await; + match event { + Event::OnchainPaymentReceived { txid, amount_sat, status, .. } => { + assert_eq!(txid, sent_txid); + assert_eq!(amount_sat, recv_amt.sats().unwrap()); + assert!(matches!(status, ConfirmationStatus::Confirmed { .. })); + }, + ev => panic!("Expected OnchainPaymentReceived event, got {ev:?}"), + } + + let event = wait_next_event(&wallet).await; + match event { + Event::SplicePending { counterparty_node_id, .. } => { + assert_eq!(counterparty_node_id, lsp.node_id()); + }, + ev => panic!("Expected SplicePending event, got {ev:?}"), + } + + // confirm splice + generate_blocks(&bitcoind, 6); + tokio::time::sleep(Duration::from_secs(5)).await; + + let event = wait_next_event(&wallet).await; + match event { + Event::ChannelOpened { counterparty_node_id, .. } => { + assert_eq!(counterparty_node_id, lsp.node_id()); + }, + ev => panic!("Expected ChannelOpened event, got {ev:?}"), + } + + let txs = wallet.list_transactions().await.unwrap(); + assert_eq!(txs.len(), 2); + let tx = txs.into_iter().last().unwrap(); + + // Comprehensive validation for on-chain receive after rebalance + assert!(!tx.outbound, "Incoming payment should not be outbound"); + assert_eq!(tx.status, TxStatus::Completed, "Payment should be completed"); + assert_eq!( + tx.payment_type, + PaymentType::IncomingOnChain { txid: Some(sent_txid) }, + "Payment type should be IncomingOnChain with correct txid" + ); + assert_ne!(tx.time_since_epoch, Duration::ZERO, "Time should be set"); + assert_eq!(tx.amount, Some(recv_amt), "Amount should equal received amount"); + // fixme assert!( + // tx.fee.unwrap() > Amount::ZERO, + // "On-chain receive should have rebalance fees after channel opening" + // ); + + // Validate fee is reasonable (should be less than 5% of received amount for rebalance) + let fee_ratio = tx.fee.unwrap().milli_sats() as f64 / recv_amt.milli_sats() as f64; + assert!( + fee_ratio < 0.05, + "Rebalance fee should be less than 5% of received amount, got {:.2}%", + fee_ratio * 100.0 + ); + + let next = wallet.next_event(); + assert!(next.is_none(), "Expected no more events, got {next:?}"); + }) + .await; +} + async fn run_test_pay_lightning_from_self_custody(amountless: bool) { test_utils::run_test(|params| async move { let wallet = Arc::clone(¶ms.wallet); diff --git a/orange-sdk/tests/test_utils.rs b/orange-sdk/tests/test_utils.rs index f09c114..786c341 100644 --- a/orange-sdk/tests/test_utils.rs +++ b/orange-sdk/tests/test_utils.rs @@ -250,6 +250,8 @@ where { let params = build_test_nodes().await; + println!("=== test start ==="); + // Run the test and get params back test(params.clone()).await; From 0538c4e581ff0babaf257c1898c0c45e4b547f64 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Thu, 6 Nov 2025 12:36:55 -0600 Subject: [PATCH 10/20] WIP: splice out --- orange-sdk/src/lib.rs | 4 +- orange-sdk/src/lightning_wallet.rs | 66 +++++++++++++-- orange-sdk/tests/integration_tests.rs | 112 +++++++++++++++++++++++++- 3 files changed, 171 insertions(+), 11 deletions(-) diff --git a/orange-sdk/src/lib.rs b/orange-sdk/src/lib.rs index 576ca96..abda835 100644 --- a/orange-sdk/src/lib.rs +++ b/orange-sdk/src/lib.rs @@ -1110,7 +1110,9 @@ impl Wallet { let mut pay_lightning = async |method, ty: fn() -> PaymentType| { let typ = ty(); let balance = if matches!(typ, PaymentType::OutgoingOnChain { .. }) { - ln_balance.onchain + // if we are paying on-chain, we can either use the on-chain balance or the + // lightning balance with a splice. Use the larger of the two. + ln_balance.onchain.max(ln_balance.lightning) } else { ln_balance.lightning }; diff --git a/orange-sdk/src/lightning_wallet.rs b/orange-sdk/src/lightning_wallet.rs index c605b20..ad850c2 100644 --- a/orange-sdk/src/lightning_wallet.rs +++ b/orange-sdk/src/lightning_wallet.rs @@ -298,12 +298,66 @@ impl LightningWallet { .ldk_node .bolt12_payment() .send_using_amount(offer, amount.milli_sats(), None, None), - PaymentMethod::OnChain(address) => self - .inner - .ldk_node - .onchain_payment() - .send_to_address(address, amount.sats_rounding_up(), None) - .map(|txid| PaymentId(*txid.as_ref())), + PaymentMethod::OnChain(address) => { + let balance = self.inner.ldk_node.list_balances(); + + // if we have enough onchain balance, send onchain + if balance.spendable_onchain_balance_sats > amount.sats_rounding_up() { + self.inner + .ldk_node + .onchain_payment() + .send_to_address(address, amount.sats_rounding_up(), None) + .map(|txid| PaymentId(*txid.as_ref())) + } else { + // otherwise try to pay via splice out + + // find existing channel to splice out of + let channels = self.inner.ldk_node.list_channels(); + let channel = + channels.iter().find(|c| c.counterparty_node_id == self.inner.lsp_node_id); + + match channel { + None => { + log_error!(self.inner.logger, "No existing channel to splice out of"); + Err(NodeError::InsufficientFunds) + }, + Some(chan) => { + self.inner.ldk_node.splice_out( + &chan.user_channel_id, + chan.counterparty_node_id, + address.clone(), + amount.sats_rounding_up(), + )?; + + loop { + self.await_splice_pending(chan.user_channel_id.0).await; + let channels = self.inner.ldk_node.list_channels(); + let new_chan = channels + .iter() + .find(|c| c.user_channel_id == chan.user_channel_id); + match new_chan { + Some(c) => { + if c.funding_txo + .is_some_and(|f| f != chan.funding_txo.unwrap()) + { + return Ok(PaymentId( + *c.funding_txo.unwrap().txid.as_ref(), + )); + } + }, + None => { + log_error!( + self.inner.logger, + "Channel disappeared while awaiting splice out" + ); + return Err(NodeError::WalletOperationFailed); + }, + } + } + }, + } + } + }, } } diff --git a/orange-sdk/tests/integration_tests.rs b/orange-sdk/tests/integration_tests.rs index 02f43a7..1139cbe 100644 --- a/orange-sdk/tests/integration_tests.rs +++ b/orange-sdk/tests/integration_tests.rs @@ -565,10 +565,10 @@ async fn test_receive_to_onchain_with_channel() { ); assert_ne!(tx.time_since_epoch, Duration::ZERO, "Time should be set"); assert_eq!(tx.amount, Some(recv_amt), "Amount should equal received amount"); - // fixme assert!( - // tx.fee.unwrap() > Amount::ZERO, - // "On-chain receive should have rebalance fees after channel opening" - // ); + assert!( + tx.fee.unwrap() > Amount::ZERO, + "On-chain receive should have rebalance fees after channel opening" + ); // Validate fee is reasonable (should be less than 5% of received amount for rebalance) let fee_ratio = tx.fee.unwrap().milli_sats() as f64 / recv_amt.milli_sats() as f64; @@ -894,6 +894,110 @@ async fn test_pay_onchain_from_self_custody() { .await; } +#[tokio::test(flavor = "multi_thread")] +async fn test_pay_onchain_from_channel() { + test_utils::run_test(|params| async move { + let wallet = Arc::clone(¶ms.wallet); + let bitcoind = Arc::clone(¶ms.bitcoind); + let third_party = Arc::clone(¶ms.third_party); + + // get a channel so we can make a payment + let recv = open_channel_from_lsp(&wallet, Arc::clone(&third_party)).await; + + let starting_bal = wallet.get_balance().await.unwrap(); + assert_eq!( + starting_bal.available_balance(), + recv.saturating_sub(Amount::from_sats(2_000).unwrap()) + ); + assert_eq!(starting_bal.pending_balance, Amount::ZERO); + + // wait for sync + generate_blocks(&bitcoind, 6); + test_utils::wait_for_condition("wallet sync after channel open", || async { + wallet.channels().iter().any(|a| a.confirmations.is_some_and(|c| c > 0) && a.is_usable) + }) + .await; + + // get address from third party node + let addr = third_party.onchain_payment().new_address().unwrap(); + let send_amount = Amount::from_sats(10_000).unwrap(); + + let instr = wallet.parse_payment_instructions(addr.to_string().as_str()).await.unwrap(); + let info = PaymentInfo::build(instr, Some(send_amount)).unwrap(); + wallet.pay(&info).await.unwrap(); + + // sleep for a second to wait for proper broadcast + tokio::time::sleep(Duration::from_secs(1)).await; + + // confirm the tx + generate_blocks(&bitcoind, 6); + + // sleep for a second to wait for sync + tokio::time::sleep(Duration::from_secs(1)).await; + + // wait for payment to complete + test_utils::wait_for_condition("on-chain payment completion", || async { + let payments = wallet.list_transactions().await.unwrap(); + let payment = payments.into_iter().find(|p| p.outbound); + if payment.as_ref().is_some_and(|p| p.status == TxStatus::Failed) { + panic!("Payment failed"); + } + payment.is_some_and(|p| p.status == TxStatus::Completed) + }) + .await; + + // check the payment is correct + let payments = wallet.list_transactions().await.unwrap(); + let payment = payments.into_iter().find(|p| p.outbound).unwrap(); + + // Comprehensive validation for outgoing on-chain payment + assert_eq!(payment.amount, Some(send_amount), "Amount should equal sent amount"); + assert!( + payment.fee.is_some_and(|f| f > Amount::ZERO), + "On-chain payment should have non-zero fees" + ); + assert!(payment.outbound, "Outgoing payment should be outbound"); + assert!( + matches!(payment.payment_type, PaymentType::OutgoingOnChain { .. }), + "Payment type should be OutgoingOnChain" + ); + assert_eq!(payment.status, TxStatus::Completed, "Payment should be completed"); + assert_ne!(payment.time_since_epoch, Duration::ZERO, "Time should be set"); + + // Validate fee is reasonable for on-chain (should be less than 1% of sent amount) + let fee_ratio = payment.fee.unwrap().milli_sats() as f64 / send_amount.milli_sats() as f64; + assert!( + fee_ratio < 0.01, + "On-chain fee should be less than 1% of sent amount, got {:.2}%", + fee_ratio * 100.0 + ); + + // Check that payment_type contains txid for completed payments + if let PaymentType::OutgoingOnChain { txid } = &payment.payment_type { + assert!(txid.is_some(), "Completed on-chain payment should have txid"); + } + + // check balance left our wallet + let bal = wallet.get_balance().await.unwrap(); + assert_eq!( + bal.pending_balance, + recv.saturating_sub(send_amount).saturating_sub(payment.fee.unwrap()) + ); + + // Wait for third party node to receive it + test_utils::wait_for_condition("on-chain payment received", || async { + let payments = third_party.list_payments(); + payments.iter().any(|p| { + p.status == PaymentStatus::Succeeded + && p.direction == PaymentDirection::Inbound + && p.amount_msat == Some(send_amount.milli_sats()) + }) + }) + .await; + }) + .await; +} + #[tokio::test(flavor = "multi_thread")] async fn test_force_close_handling() { test_utils::run_test(|params| async move { From b8699895ee885f1a8389335f0939fcf1a299fdb8 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Fri, 7 Nov 2025 16:55:37 -0600 Subject: [PATCH 11/20] Persist our splice outs Since splice outs will never appear in our wallet we need to persist them ourselves so we can actually have them in the payment history. --- orange-sdk/Cargo.toml | 2 +- orange-sdk/src/lib.rs | 20 ++++++++++-- orange-sdk/src/lightning_wallet.rs | 46 +++++++++++++++++++++------ orange-sdk/src/store.rs | 30 ++++++++++++++++- orange-sdk/tests/integration_tests.rs | 8 +++-- 5 files changed, 90 insertions(+), 16 deletions(-) diff --git a/orange-sdk/Cargo.toml b/orange-sdk/Cargo.toml index 0e1147c..020f2c1 100644 --- a/orange-sdk/Cargo.toml +++ b/orange-sdk/Cargo.toml @@ -23,7 +23,7 @@ _cashu-tests = ["_test-utils", "cdk-ldk-node", "cdk/mint", "cdk-sqlite", "cdk-ax [dependencies] graduated-rebalancer = { path = "../graduated-rebalancer", version = "0.1.0" } -ldk-node = { git = "https://github.com/benthecarman/ldk-node.git", rev = "d0d91b809065dc3689d2eda75b9718bf1ec1da69" } +ldk-node = { git = "https://github.com/benthecarman/ldk-node.git", rev = "492e6eafcbe0e84f1e1b38268a4d9ad20337929d" } lightning-macros = "0.2.0-beta1" bitcoin-payment-instructions = { workspace = true } chrono = { version = "0.4", default-features = false } diff --git a/orange-sdk/src/lib.rs b/orange-sdk/src/lib.rs index abda835..3a244a1 100644 --- a/orange-sdk/src/lib.rs +++ b/orange-sdk/src/lib.rs @@ -26,7 +26,7 @@ use ldk_node::lightning::ln::msgs::SocketAddress; use ldk_node::lightning::util::logger::Logger as _; use ldk_node::lightning::{log_debug, log_error, log_info, log_trace, log_warn}; use ldk_node::lightning_invoice::Bolt11Invoice; -use ldk_node::payment::PaymentKind; +use ldk_node::payment::{PaymentDirection, PaymentKind}; use ldk_node::{BuildError, ChannelDetails, DynStore, NodeError}; use std::collections::HashMap; @@ -665,7 +665,11 @@ impl Wallet { let mut lightning_payments = self.inner.ln_wallet.list_payments(); lightning_payments.sort_by_key(|l| l.latest_update_timestamp); - let mut res = Vec::with_capacity(trusted_payments.len() + lightning_payments.len()); + let splice_outs = store::read_splice_outs(self.inner.store.as_ref()); + + let mut res = Vec::with_capacity( + trusted_payments.len() + lightning_payments.len() + splice_outs.len(), + ); let tx_metadata = self.inner.tx_metadata.read(); println!("\n\n======================="); @@ -894,6 +898,18 @@ impl Wallet { } } + for details in splice_outs { + res.push(Transaction { + id: PaymentId::SelfCustodial(details.id.0), + status: details.status.into(), + outbound: details.direction == PaymentDirection::Outbound, + amount: details.amount_msat.map(|a| Amount::from_milli_sats(a).unwrap()), + fee: details.fee_paid_msat.map(|fee| Amount::from_milli_sats(fee).unwrap()), + payment_type: (&details).into(), + time_since_epoch: Duration::from_secs(details.latest_update_timestamp), + }); + } + for (id, tx_info) in internal_transfers { debug_assert!( tx_info.send_fee.is_some(), diff --git a/orange-sdk/src/lightning_wallet.rs b/orange-sdk/src/lightning_wallet.rs index ad850c2..f28c99f 100644 --- a/orange-sdk/src/lightning_wallet.rs +++ b/orange-sdk/src/lightning_wallet.rs @@ -1,9 +1,10 @@ use crate::bitcoin::OutPoint; +use crate::bitcoin::hashes::Hash; use crate::event::{EventQueue, LdkEventHandler}; use crate::logging::Logger; use crate::runtime::Runtime; use crate::store::{TxMetadataStore, TxStatus}; -use crate::{ChainSource, InitFailure, PaymentType, Seed, WalletConfig}; +use crate::{ChainSource, InitFailure, PaymentType, Seed, WalletConfig, store}; use bitcoin_payment_instructions::PaymentMethod; use bitcoin_payment_instructions::amount::Amount; @@ -18,7 +19,9 @@ 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}; use ldk_node::lightning_invoice::{Bolt11Invoice, Bolt11InvoiceDescription, Description}; -use ldk_node::payment::{PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus}; +use ldk_node::payment::{ + ConfirmationStatus, PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, +}; use ldk_node::{DynStore, NodeError, UserChannelId, lightning}; use graduated_rebalancer::{LightningBalance, ReceivedLightningPayment}; @@ -27,7 +30,7 @@ use std::collections::HashMap; use std::fmt::Debug; use std::pin::Pin; use std::sync::Arc; - +use std::time::SystemTime; use tokio::sync::watch; #[derive(Debug, Clone, Copy)] @@ -39,6 +42,7 @@ pub(crate) struct LightningWalletBalance { pub(crate) struct LightningWalletImpl { pub(crate) ldk_node: Arc, logger: Arc, + store: Arc, payment_receipt_flag: watch::Receiver<()>, channel_pending_receipt_flag: watch::Receiver, splice_pending_receipt_flag: watch::Receiver, @@ -178,6 +182,7 @@ impl LightningWallet { let inner = Arc::new(LightningWalletImpl { ldk_node, logger, + store, payment_receipt_flag, channel_pending_receipt_flag, splice_pending_receipt_flag, @@ -299,14 +304,16 @@ impl LightningWallet { .bolt12_payment() .send_using_amount(offer, amount.milli_sats(), None, None), PaymentMethod::OnChain(address) => { + let amount_sats = amount.sats().map_err(|_| NodeError::InvalidAmount)?; + let balance = self.inner.ldk_node.list_balances(); // if we have enough onchain balance, send onchain - if balance.spendable_onchain_balance_sats > amount.sats_rounding_up() { + if balance.spendable_onchain_balance_sats > amount_sats { self.inner .ldk_node .onchain_payment() - .send_to_address(address, amount.sats_rounding_up(), None) + .send_to_address(address, amount_sats, None) .map(|txid| PaymentId(*txid.as_ref())) } else { // otherwise try to pay via splice out @@ -326,7 +333,7 @@ impl LightningWallet { &chan.user_channel_id, chan.counterparty_node_id, address.clone(), - amount.sats_rounding_up(), + amount_sats, )?; loop { @@ -340,9 +347,30 @@ impl LightningWallet { if c.funding_txo .is_some_and(|f| f != chan.funding_txo.unwrap()) { - return Ok(PaymentId( - *c.funding_txo.unwrap().txid.as_ref(), - )); + let funding_txo = c.funding_txo.unwrap(); + + let id = PaymentId(funding_txo.txid.to_byte_array()); + let details = PaymentDetails { + id, + kind: PaymentKind::Onchain { + txid: funding_txo.txid, + status: ConfirmationStatus::Unconfirmed, // todo how do we update this? + }, + amount_msat: Some(amount_sats * 1_000), + fee_paid_msat: Some(69), // todo get real fee + direction: PaymentDirection::Outbound, + status: PaymentStatus::Succeeded, + latest_update_timestamp: SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_secs(), + }; + + store::write_splice_out( + self.inner.store.as_ref(), + &details, + ); + return Ok(id); } }, None => { diff --git a/orange-sdk/src/store.rs b/orange-sdk/src/store.rs index 39a9590..2908e25 100644 --- a/orange-sdk/src/store.rs +++ b/orange-sdk/src/store.rs @@ -13,6 +13,7 @@ use bitcoin_payment_instructions::amount::Amount; +use ldk_node::DynStore; use ldk_node::bitcoin::Txid; use ldk_node::bitcoin::hex::{DisplayHex, FromHex}; use ldk_node::lightning::io; @@ -21,8 +22,8 @@ use ldk_node::lightning::types::payment::PaymentPreimage; use ldk_node::lightning::util::persist::{KVStore, KVStoreSync}; use ldk_node::lightning::util::ser::{Readable, Writeable, Writer}; use ldk_node::lightning::{impl_writeable_tlv_based, impl_writeable_tlv_based_enum}; +use ldk_node::payment::PaymentDetails; -use ldk_node::DynStore; use std::collections::HashMap; use std::fmt; use std::str::FromStr; @@ -31,6 +32,7 @@ use std::time::Duration; const STORE_PRIMARY_KEY: &str = "orange_sdk"; const STORE_SECONDARY_KEY: &str = "payment_store"; +const SPLICE_OUT_SECONDARY_KEY: &str = "splice_out"; /// The status of a transaction. This is used to track the state of a transaction #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -465,6 +467,32 @@ pub(crate) fn set_rebalance_enabled(store: &DynStore, enabled: bool) { .expect("Failed to write rebalance_enabled"); } +pub(crate) fn write_splice_out(store: &DynStore, details: &PaymentDetails) { + KVStoreSync::write( + store, + STORE_PRIMARY_KEY, + SPLICE_OUT_SECONDARY_KEY, + &details.id.0.to_lower_hex_string(), + details.encode(), + ) + .expect("Failed to write splice out txid"); +} + +pub(crate) fn read_splice_outs(store: &DynStore) -> Vec { + let keys = KVStoreSync::list(store, STORE_PRIMARY_KEY, SPLICE_OUT_SECONDARY_KEY) + .expect("We do not allow reads to fail"); + let mut splice_outs = Vec::with_capacity(keys.len()); + for key in keys { + let data_bytes = + KVStoreSync::read(store, STORE_PRIMARY_KEY, SPLICE_OUT_SECONDARY_KEY, &key) + .expect("We do not allow reads to fail"); + let data = + Readable::read(&mut &data_bytes[..]).expect("Invalid data in splice out storage"); + splice_outs.push(data); + } + splice_outs +} + #[cfg(test)] mod tests { use super::*; diff --git a/orange-sdk/tests/integration_tests.rs b/orange-sdk/tests/integration_tests.rs index 1139cbe..56c05ab 100644 --- a/orange-sdk/tests/integration_tests.rs +++ b/orange-sdk/tests/integration_tests.rs @@ -895,6 +895,7 @@ async fn test_pay_onchain_from_self_custody() { } #[tokio::test(flavor = "multi_thread")] +#[test_log::test] async fn test_pay_onchain_from_channel() { test_utils::run_test(|params| async move { let wallet = Arc::clone(¶ms.wallet); @@ -979,9 +980,10 @@ async fn test_pay_onchain_from_channel() { // check balance left our wallet let bal = wallet.get_balance().await.unwrap(); - assert_eq!( - bal.pending_balance, - recv.saturating_sub(send_amount).saturating_sub(payment.fee.unwrap()) + // fixme change to exact match once we have the real feee + assert!( + bal.available_balance() < + starting_bal.available_balance().saturating_sub(send_amount).saturating_sub(payment.fee.unwrap()) ); // Wait for third party node to receive it From 6fa3488f82d577be88c6105298e643fc158f3969 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Wed, 12 Nov 2025 22:04:51 -0600 Subject: [PATCH 12/20] fmt --- orange-sdk/tests/integration_tests.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/orange-sdk/tests/integration_tests.rs b/orange-sdk/tests/integration_tests.rs index 56c05ab..40a3305 100644 --- a/orange-sdk/tests/integration_tests.rs +++ b/orange-sdk/tests/integration_tests.rs @@ -982,8 +982,11 @@ async fn test_pay_onchain_from_channel() { let bal = wallet.get_balance().await.unwrap(); // fixme change to exact match once we have the real feee assert!( - bal.available_balance() < - starting_bal.available_balance().saturating_sub(send_amount).saturating_sub(payment.fee.unwrap()) + bal.available_balance() + < starting_bal + .available_balance() + .saturating_sub(send_amount) + .saturating_sub(payment.fee.unwrap()) ); // Wait for third party node to receive it From 17d0a292eca8aa2b080d1775baedda952805d28f Mon Sep 17 00:00:00 2001 From: benthecarman Date: Tue, 18 Nov 2025 05:02:55 -0600 Subject: [PATCH 13/20] Async event persistance --- graduated-rebalancer/src/lib.rs | 50 ++++--- orange-sdk/src/event.rs | 137 +++++++++++------- orange-sdk/src/lib.rs | 4 +- orange-sdk/src/rebalancer.rs | 155 ++++++++++++--------- orange-sdk/src/trusted_wallet/cashu/mod.rs | 39 +++--- orange-sdk/src/trusted_wallet/dummy.rs | 17 ++- orange-sdk/src/trusted_wallet/spark/mod.rs | 52 ++++--- 7 files changed, 264 insertions(+), 190 deletions(-) diff --git a/graduated-rebalancer/src/lib.rs b/graduated-rebalancer/src/lib.rs index 282ffb3..827d0ba 100644 --- a/graduated-rebalancer/src/lib.rs +++ b/graduated-rebalancer/src/lib.rs @@ -189,7 +189,8 @@ pub enum RebalancerEvent { /// Trait for handling rebalancer events pub trait EventHandler: Send + Sync { /// Handle a rebalancer event - fn handle_event(&self, event: RebalancerEvent); + fn handle_event(&self, event: RebalancerEvent) + -> Pin + Send + '_>>; } /// A no-op event handler that discards all events @@ -197,7 +198,10 @@ pub trait EventHandler: Send + Sync { pub struct IgnoringEventHandler; impl EventHandler for IgnoringEventHandler { - fn handle_event(&self, _event: RebalancerEvent) { + fn handle_event( + &self, _event: RebalancerEvent, + ) -> Pin + Send + '_>> { + Box::pin(async move {}) // Do nothing } } @@ -273,11 +277,13 @@ where rebalance_id.as_hex() ); - self.event_handler.handle_event(RebalancerEvent::RebalanceInitiated { - trigger_id: params.id, - trusted_rebalance_payment_id: rebalance_id, - amount_msat: transfer_amt.milli_sats(), - }); + self.event_handler + .handle_event(RebalancerEvent::RebalanceInitiated { + trigger_id: params.id, + trusted_rebalance_payment_id: rebalance_id, + amount_msat: transfer_amt.milli_sats(), + }) + .await; let ln_payment = match self .ln_wallet @@ -310,14 +316,16 @@ where ln_payment.id.as_hex(), ); - self.event_handler.handle_event(RebalancerEvent::RebalanceSuccessful { - trigger_id: params.id, - trusted_rebalance_payment_id: rebalance_id, - ln_rebalance_payment_id: ln_payment.id, - amount_msat: transfer_amt.milli_sats(), - fee_msat: ln_payment.fee_paid_msat.unwrap_or_default() - + trusted_payment.fee_paid_msat.unwrap_or_default(), - }); + self.event_handler + .handle_event(RebalancerEvent::RebalanceSuccessful { + trigger_id: params.id, + trusted_rebalance_payment_id: rebalance_id, + ln_rebalance_payment_id: ln_payment.id, + amount_msat: transfer_amt.milli_sats(), + fee_msat: ln_payment.fee_paid_msat.unwrap_or_default() + + trusted_payment.fee_paid_msat.unwrap_or_default(), + }) + .await; }, Err(e) => { log_info!(self.logger, "Rebalance trusted transaction failed with {e:?}",); @@ -368,11 +376,13 @@ where (channel_outpoint, user_chan_id) }; - self.event_handler.handle_event(RebalancerEvent::OnChainRebalanceInitiated { - trigger_id: params.id, - user_channel_id, - channel_outpoint, - }); + self.event_handler + .handle_event(RebalancerEvent::OnChainRebalanceInitiated { + trigger_id: params.id, + user_channel_id, + channel_outpoint, + }) + .await; } /// Stops the rebalancer, waits for any active rebalances to complete diff --git a/orange-sdk/src/event.rs b/orange-sdk/src/event.rs index b49a857..da7637f 100644 --- a/orange-sdk/src/event.rs +++ b/orange-sdk/src/event.rs @@ -6,7 +6,7 @@ use ldk_node::bitcoin::{OutPoint, Txid}; use ldk_node::lightning::events::{ClosureReason, PaymentFailureReason}; use ldk_node::lightning::ln::types::ChannelId; use ldk_node::lightning::util::logger::Logger as _; -use ldk_node::lightning::util::persist::KVStoreSync; +use ldk_node::lightning::util::persist::KVStore; use ldk_node::lightning::util::ser::{Writeable, Writer}; use ldk_node::lightning::{impl_writeable_tlv_based_enum, log_debug, log_error, log_warn}; use ldk_node::lightning_types::payment::{PaymentHash, PaymentPreimage}; @@ -14,9 +14,9 @@ use ldk_node::payment::{ConfirmationStatus, PaymentKind}; use ldk_node::{CustomTlvRecord, DynStore, UserChannelId}; use std::collections::VecDeque; -use std::sync::{Arc, Mutex}; +use std::sync::Arc; use std::task::{Poll, Waker}; -use tokio::sync::watch; +use tokio::sync::{Mutex, watch}; /// The event queue will be persisted under this key. pub(crate) const EVENT_QUEUE_PERSISTENCE_PRIMARY_NAMESPACE: &str = ""; @@ -218,21 +218,23 @@ impl EventQueue { Self { queue, waker, kv_store, logger } } - pub(crate) fn add_event(&self, event: Event) -> Result<(), ldk_node::lightning::io::Error> { + pub(crate) async fn add_event( + &self, event: Event, + ) -> Result<(), ldk_node::lightning::io::Error> { { - let mut locked_queue = self.queue.lock().unwrap(); + let mut locked_queue = self.queue.lock().await; locked_queue.push_back(event); - self.persist_queue(&locked_queue)?; + self.persist_queue(&locked_queue).await?; } - if let Some(waker) = self.waker.lock().unwrap().take() { + if let Some(waker) = self.waker.lock().await.take() { waker.wake(); } Ok(()) } - pub(crate) fn next_event(&self) -> Option { - let locked_queue = self.queue.lock().unwrap(); + pub(crate) async fn next_event(&self) -> Option { + let locked_queue = self.queue.lock().await; locked_queue.front().cloned() } @@ -240,30 +242,31 @@ impl EventQueue { EventFuture { event_queue: Arc::clone(&self.queue), waker: Arc::clone(&self.waker) }.await } - pub(crate) fn event_handled(&self) -> Result<(), ldk_node::lightning::io::Error> { + pub(crate) async fn event_handled(&self) -> Result<(), ldk_node::lightning::io::Error> { { - let mut locked_queue = self.queue.lock().unwrap(); + let mut locked_queue = self.queue.lock().await; locked_queue.pop_front(); - self.persist_queue(&locked_queue)?; + self.persist_queue(&locked_queue).await?; } - if let Some(waker) = self.waker.lock().unwrap().take() { + if let Some(waker) = self.waker.lock().await.take() { waker.wake(); } Ok(()) } - fn persist_queue( + async fn persist_queue( &self, locked_queue: &VecDeque, ) -> Result<(), ldk_node::lightning::io::Error> { let data = EventQueueSerWrapper(locked_queue).encode(); - KVStoreSync::write( + KVStore::write( self.kv_store.as_ref(), EVENT_QUEUE_PERSISTENCE_PRIMARY_NAMESPACE, EVENT_QUEUE_PERSISTENCE_SECONDARY_NAMESPACE, EVENT_QUEUE_PERSISTENCE_KEY, data, ) + .await .map_err(|e| { log_error!( self.logger.as_ref(), @@ -302,10 +305,12 @@ impl Future for EventFuture { fn poll( self: core::pin::Pin<&mut Self>, cx: &mut core::task::Context<'_>, ) -> Poll { - if let Some(event) = self.event_queue.lock().unwrap().front() { - Poll::Ready(event.clone()) + if let Some(event) = self.event_queue.try_lock().ok().and_then(|q| q.front().cloned()) { + Poll::Ready(event) } else { - *self.waker.lock().unwrap() = Some(cx.waker().clone()); + if let Ok(mut waker) = self.waker.try_lock() { + *waker = Some(cx.waker().clone()); + } Poll::Pending } } @@ -338,22 +343,30 @@ impl LdkEventHandler { log_error!(self.logger, "Failed to set preimage for payment {payment_id:?}"); } - if let Err(e) = self.event_queue.add_event(Event::PaymentSuccessful { - payment_id, - payment_hash, - payment_preimage: preimage, - fee_paid_msat, - }) { + if let Err(e) = self + .event_queue + .add_event(Event::PaymentSuccessful { + payment_id, + payment_hash, + payment_preimage: preimage, + fee_paid_msat, + }) + .await + { log_error!(self.logger, "Failed to add PaymentSuccessful event: {e:?}"); return; } }, ldk_node::Event::PaymentFailed { payment_id, payment_hash, reason } => { - if let Err(e) = self.event_queue.add_event(Event::PaymentFailed { - payment_id: PaymentId::SelfCustodial(payment_id.unwrap().0), // safe - payment_hash, - reason, - }) { + if let Err(e) = self + .event_queue + .add_event(Event::PaymentFailed { + payment_id: PaymentId::SelfCustodial(payment_id.unwrap().0), // safe + payment_hash, + reason, + }) + .await + { log_error!(self.logger, "Failed to add PaymentFailed event: {e:?}"); return; } @@ -373,13 +386,17 @@ impl LdkEventHandler { } }); - if let Err(e) = self.event_queue.add_event(Event::PaymentReceived { - payment_id: PaymentId::SelfCustodial(payment_id.0), - payment_hash, - amount_msat, - custom_records, - lsp_fee_msats, - }) { + if let Err(e) = self + .event_queue + .add_event(Event::PaymentReceived { + payment_id: PaymentId::SelfCustodial(payment_id.0), + payment_hash, + amount_msat, + custom_records, + lsp_fee_msats, + }) + .await + { log_error!(self.logger, "Failed to add PaymentReceived event: {e:?}"); } let _ = self.payment_receipt_sender.send(()); @@ -402,12 +419,16 @@ impl LdkEventHandler { } => { let funding_txo = funding_txo.unwrap(); // safe - if let Err(e) = self.event_queue.add_event(Event::ChannelOpened { - channel_id, - user_channel_id, - counterparty_node_id: counterparty_node_id.unwrap(), // safe - funding_txo, - }) { + if let Err(e) = self + .event_queue + .add_event(Event::ChannelOpened { + channel_id, + user_channel_id, + counterparty_node_id: counterparty_node_id.unwrap(), // safe + funding_txo, + }) + .await + { log_error!(self.logger, "Failed to add ChannelOpened event: {e:?}"); return; } @@ -423,12 +444,16 @@ impl LdkEventHandler { // try to reopen the channel. store::set_rebalance_enabled(self.event_queue.kv_store.as_ref(), false); - if let Err(e) = self.event_queue.add_event(Event::ChannelClosed { - channel_id, - user_channel_id, - counterparty_node_id: counterparty_node_id.unwrap(), // safe - reason, - }) { + if let Err(e) = self + .event_queue + .add_event(Event::ChannelClosed { + channel_id, + user_channel_id, + counterparty_node_id: counterparty_node_id.unwrap(), // safe + reason, + }) + .await + { log_error!(self.logger, "Failed to add ChannelClosed event: {e:?}"); return; } @@ -442,12 +467,16 @@ impl LdkEventHandler { log_debug!(self.logger, "Received SplicePending event {event:?}"); let _ = self.splice_pending_sender.send(user_channel_id.0); - if let Err(e) = self.event_queue.add_event(Event::SplicePending { - channel_id, - user_channel_id, - counterparty_node_id, - new_funding_txo, - }) { + if let Err(e) = self + .event_queue + .add_event(Event::SplicePending { + channel_id, + user_channel_id, + counterparty_node_id, + new_funding_txo, + }) + .await + { log_error!(self.logger, "Failed to add SplicePending event: {e:?}"); return; } diff --git a/orange-sdk/src/lib.rs b/orange-sdk/src/lib.rs index 3a244a1..22fa56c 100644 --- a/orange-sdk/src/lib.rs +++ b/orange-sdk/src/lib.rs @@ -1305,7 +1305,7 @@ impl Wallet { /// **Caution:** Users must handle events as quickly as possible to prevent a large event backlog, /// which can increase the memory footprint of [`Wallet`]. pub fn next_event(&self) -> Option { - self.inner.event_queue.next_event() + self.inner.runtime.block_on(self.inner.event_queue.next_event()) } /// Returns the next event in the event queue. @@ -1339,7 +1339,7 @@ impl Wallet { /// /// **Note:** This **MUST** be called after each event has been handled. pub fn event_handled(&self) -> Result<(), ()> { - self.inner.event_queue.event_handled().map_err(|e| { + self.inner.runtime.block_on(self.inner.event_queue.event_handled()).map_err(|e| { log_error!( self.inner.logger, "Couldn't mark event handled due to persistence failure: {e}" diff --git a/orange-sdk/src/rebalancer.rs b/orange-sdk/src/rebalancer.rs index 2e804ed..4a6d2b7 100644 --- a/orange-sdk/src/rebalancer.rs +++ b/orange-sdk/src/rebalancer.rs @@ -13,6 +13,7 @@ use ldk_node::lightning::util::logger::Logger as _; use ldk_node::lightning::{log_error, log_info, log_trace, log_warn}; use ldk_node::payment::{ConfirmationStatus, PaymentDirection, PaymentKind, PaymentStatus}; use std::cmp; +use std::pin::Pin; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{Duration, SystemTime}; @@ -183,7 +184,7 @@ impl RebalanceTrigger for OrangeTrigger { }; log_trace!(self.logger, "Generated OnchainPaymentReceived event: {event:?}"); - if let Err(e) = self.event_queue.add_event(event) { + if let Err(e) = self.event_queue.add_event(event).await { log_error!( self.logger, "Failed to add OnchainPaymentReceived event: {e:?}" @@ -279,79 +280,95 @@ impl OrangeRebalanceEventHandler { } impl graduated_rebalancer::EventHandler for OrangeRebalanceEventHandler { - fn handle_event(&self, event: RebalancerEvent) { - match event { - RebalancerEvent::RebalanceInitiated { - trigger_id, - trusted_rebalance_payment_id, - amount_msat, - } => { - let metadata = TxMetadata { - ty: TxType::PendingRebalance {}, - time: SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap(), - }; - self.tx_metadata.insert(PaymentId::Trusted(trusted_rebalance_payment_id), metadata); - if let Err(e) = self.event_queue.add_event(Event::RebalanceInitiated { - trigger_payment_id: PaymentId::Trusted(trigger_id), + fn handle_event( + &self, event: RebalancerEvent, + ) -> Pin + Send + '_>> { + Box::pin(async move { + match event { + RebalancerEvent::RebalanceInitiated { + trigger_id, trusted_rebalance_payment_id, amount_msat, - }) { - log_error!(self.logger, "Failed to add RebalanceSuccessful event: {e:?}"); - } - }, - RebalancerEvent::RebalanceSuccessful { - trigger_id, - trusted_rebalance_payment_id: rebalance_id, - ln_rebalance_payment_id: lightning_id, - amount_msat, - fee_msat, - } => { - let triggering_transaction_id = PaymentId::Trusted(trigger_id); - self.tx_metadata - .set_tx_caused_rebalance(&triggering_transaction_id) - .expect("Failed to write metadata for rebalance transaction"); - let metadata = TxMetadata { - ty: TxType::TrustedToLightning { - trusted_payment: rebalance_id, - lightning_payment: lightning_id, - payment_triggering_transfer: triggering_transaction_id, - }, - time: SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap(), - }; - self.tx_metadata.upsert(PaymentId::Trusted(rebalance_id), metadata); - self.tx_metadata.insert(PaymentId::SelfCustodial(lightning_id), metadata); - - if let Err(e) = self.event_queue.add_event(Event::RebalanceSuccessful { - trigger_payment_id: triggering_transaction_id, + } => { + let metadata = TxMetadata { + ty: TxType::PendingRebalance {}, + time: SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap(), + }; + self.tx_metadata + .insert(PaymentId::Trusted(trusted_rebalance_payment_id), metadata); + if let Err(e) = self + .event_queue + .add_event(Event::RebalanceInitiated { + trigger_payment_id: PaymentId::Trusted(trigger_id), + trusted_rebalance_payment_id, + amount_msat, + }) + .await + { + log_error!(self.logger, "Failed to add RebalanceSuccessful event: {e:?}"); + } + }, + RebalancerEvent::RebalanceSuccessful { + trigger_id, trusted_rebalance_payment_id: rebalance_id, ln_rebalance_payment_id: lightning_id, amount_msat, fee_msat, - }) { - log_error!(self.logger, "Failed to add RebalanceSuccessful event: {e:?}"); - } - }, - RebalancerEvent::OnChainRebalanceInitiated { - trigger_id, - channel_outpoint, - user_channel_id: _, - } => { - let chan_txid = channel_outpoint.txid; - let triggering_txid = Txid::from_byte_array(trigger_id); - println!( - "Marking {chan_txid} as onchain rebalance initiated for triggering txid {triggering_txid}" - ); - let trigger_id = PaymentId::SelfCustodial(triggering_txid.to_byte_array()); - self.tx_metadata - .set_tx_caused_rebalance(&trigger_id) - .expect("Failed to write metadata for onchain rebalance transaction"); - let metadata = TxMetadata { - ty: TxType::OnchainToLightning { channel_txid: chan_txid, triggering_txid }, - time: SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap(), - }; - self.tx_metadata - .insert(PaymentId::SelfCustodial(chan_txid.to_byte_array()), metadata); - }, - } + } => { + let triggering_transaction_id = PaymentId::Trusted(trigger_id); + self.tx_metadata + .set_tx_caused_rebalance(&triggering_transaction_id) + .expect("Failed to write metadata for rebalance transaction"); + let metadata = TxMetadata { + ty: TxType::TrustedToLightning { + trusted_payment: rebalance_id, + lightning_payment: lightning_id, + payment_triggering_transfer: triggering_transaction_id, + }, + time: SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap(), + }; + self.tx_metadata.upsert(PaymentId::Trusted(rebalance_id), metadata); + self.tx_metadata.insert(PaymentId::SelfCustodial(lightning_id), metadata); + + let event_queue = Arc::clone(&self.event_queue); + let logger = Arc::clone(&self.logger); + tokio::spawn(async move { + if let Err(e) = event_queue + .add_event(Event::RebalanceSuccessful { + trigger_payment_id: triggering_transaction_id, + trusted_rebalance_payment_id: rebalance_id, + ln_rebalance_payment_id: lightning_id, + amount_msat, + fee_msat, + }) + .await + { + log_error!(logger, "Failed to add RebalanceSuccessful event: {e:?}"); + } + }); + }, + RebalancerEvent::OnChainRebalanceInitiated { + trigger_id, + channel_outpoint, + user_channel_id: _, + } => { + let chan_txid = channel_outpoint.txid; + let triggering_txid = Txid::from_byte_array(trigger_id); + println!( + "Marking {chan_txid} as onchain rebalance initiated for triggering txid {triggering_txid}" + ); + let trigger_id = PaymentId::SelfCustodial(triggering_txid.to_byte_array()); + self.tx_metadata + .set_tx_caused_rebalance(&trigger_id) + .expect("Failed to write metadata for onchain rebalance transaction"); + let metadata = TxMetadata { + ty: TxType::OnchainToLightning { channel_txid: chan_txid, triggering_txid }, + time: SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap(), + }; + self.tx_metadata + .insert(PaymentId::SelfCustodial(chan_txid.to_byte_array()), metadata); + }, + } + }) } } diff --git a/orange-sdk/src/trusted_wallet/cashu/mod.rs b/orange-sdk/src/trusted_wallet/cashu/mod.rs index d301a48..091e766 100644 --- a/orange-sdk/src/trusted_wallet/cashu/mod.rs +++ b/orange-sdk/src/trusted_wallet/cashu/mod.rs @@ -377,12 +377,14 @@ impl TrustedWalletInterface for Cashu { } let fee_paid_sat: u64 = res.fee_paid.into(); - let _ = event_queue.add_event(Event::PaymentSuccessful { - payment_id, - payment_hash: hash, - payment_preimage, - fee_paid_msat: Some(fee_paid_sat * 1_000), // convert to msats - }); + let _ = event_queue + .add_event(Event::PaymentSuccessful { + payment_id, + payment_hash: hash, + payment_preimage, + fee_paid_msat: Some(fee_paid_sat * 1_000), // convert to msats + }) + .await; payment_success_sender.send(()).unwrap(); }, @@ -395,11 +397,13 @@ impl TrustedWalletInterface for Cashu { }; if !is_rebalance { - let _ = event_queue.add_event(Event::PaymentFailed { - payment_id, - payment_hash, - reason: None, - }); + let _ = event_queue + .add_event(Event::PaymentFailed { + payment_id, + payment_hash, + reason: None, + }) + .await; } }, state => { @@ -420,11 +424,13 @@ impl TrustedWalletInterface for Cashu { }; if !is_rebalance { - let _ = event_queue.add_event(Event::PaymentFailed { - payment_id, - payment_hash, - reason: None, - }); + let _ = event_queue + .add_event(Event::PaymentFailed { + payment_id, + payment_hash, + reason: None, + }) + .await; } }, } @@ -688,6 +694,7 @@ impl Cashu { custom_records: vec![], lsp_fee_msats: None, }) + .await .map_err(|e| TrustedError::Other(format!("Failed to add event: {e}")))?; log_info!(logger, "Sent PaymentReceived event for mint quote: {}", mint_quote.id); diff --git a/orange-sdk/src/trusted_wallet/dummy.rs b/orange-sdk/src/trusted_wallet/dummy.rs index 58aa2d1..6cce26a 100644 --- a/orange-sdk/src/trusted_wallet/dummy.rs +++ b/orange-sdk/src/trusted_wallet/dummy.rs @@ -18,10 +18,10 @@ use ldk_node::{Event, Node}; use rand::RngCore; use std::env::temp_dir; use std::pin::Pin; +use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, RwLock}; use std::time::Duration; -use tokio::sync::watch; +use tokio::sync::{RwLock, watch}; use uuid::Uuid; /// A dummy implementation of `TrustedWalletInterface` for testing purposes. @@ -99,7 +99,7 @@ impl DummyTrustedWallet { // convert id let id = mangle_payment_id(payment_id.unwrap().0); - let mut payments = pays.write().unwrap(); + let mut payments = pays.write().await; let item = payments.iter_mut().find(|p| p.id == id); if let Some(payment) = item { payment.status = TxStatus::Completed; @@ -129,6 +129,7 @@ impl DummyTrustedWallet { payment_preimage: payment_preimage.unwrap(), // safe fee_paid_msat, }) + .await .unwrap(); } @@ -138,7 +139,7 @@ impl DummyTrustedWallet { // convert id let id = mangle_payment_id(payment_id.unwrap().0); - let mut payments = pays.write().unwrap(); + let mut payments = pays.write().await; let item = payments.iter().cloned().enumerate().find(|(_, p)| p.id == id); if let Some((idx, payment)) = item { // remove from list and refund balance @@ -160,6 +161,7 @@ impl DummyTrustedWallet { payment_hash, reason, }) + .await .unwrap(); } }, @@ -167,7 +169,7 @@ impl DummyTrustedWallet { // convert id let id = mangle_payment_id(payment_id.unwrap().0); - let mut payments = pays.write().unwrap(); + let mut payments = pays.write().await; // We create invoices on the fly without adding the payment to our list // We need to insert it into our payments list @@ -196,6 +198,7 @@ impl DummyTrustedWallet { custom_records: vec![], lsp_fee_msats: None, }) + .await .unwrap(); }, Event::PaymentForwarded { .. } => {}, @@ -307,7 +310,7 @@ impl TrustedWalletInterface for DummyTrustedWallet { fn list_payments( &self, ) -> Pin, TrustedError>> + Send + '_>> { - Box::pin(async move { Ok(self.payments.read().unwrap().clone()) }) + Box::pin(async move { Ok(self.payments.read().await.clone()) }) } fn estimate_fee( @@ -360,7 +363,7 @@ impl TrustedWalletInterface for DummyTrustedWallet { .as_secs(); // add to payments - let mut list = self.payments.write().unwrap(); + let mut list = self.payments.write().await; list.push(Payment { id, amount, diff --git a/orange-sdk/src/trusted_wallet/spark/mod.rs b/orange-sdk/src/trusted_wallet/spark/mod.rs index 0047fa7..af6f4a3 100644 --- a/orange-sdk/src/trusted_wallet/spark/mod.rs +++ b/orange-sdk/src/trusted_wallet/spark/mod.rs @@ -341,12 +341,12 @@ impl EventListener for SparkEventHandler { log_info!(self.logger, "Spark wallet claimed deposits! {claimed_deposits:?}"); }, SdkEvent::PaymentSucceeded { payment } => { - if let Err(e) = self.handle_payment_succeeded(payment) { + if let Err(e) = self.handle_payment_succeeded(payment).await { log_error!(self.logger, "Failed to handle payment succeeded: {e:?}"); } }, SdkEvent::PaymentFailed { payment } => { - if let Err(e) = self.handle_payment_failed(payment) { + if let Err(e) = self.handle_payment_failed(payment).await { log_error!(self.logger, "Failed to handle payment succeeded: {e:?}"); } }, @@ -361,7 +361,7 @@ impl EventListener for SparkEventHandler { } impl SparkEventHandler { - fn handle_payment_succeeded( + async fn handle_payment_succeeded( &self, payment: breez_sdk_spark::Payment, ) -> Result<(), TrustedError> { log_info!(self.logger, "Spark payment succeeded: {payment:?}"); @@ -409,12 +409,14 @@ impl SparkEventHandler { ); } - 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) as u64), // convert to msats - })?; + 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) as u64), // convert to msats + }) + .await?; self.payment_success_sender.send(()).unwrap(); }, @@ -437,13 +439,15 @@ impl SparkEventHandler { Some((payment.fees * 1_000) as u64) // 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) as u64, // convert to msats - custom_records: vec![], - lsp_fee_msats, - })?; + self.event_queue + .add_event(Event::PaymentReceived { + payment_id: PaymentId::Trusted(id), + payment_hash: PaymentHash(payment_hash), + amount_msat: (payment.amount * 1_000) as u64, // convert to msats + custom_records: vec![], + lsp_fee_msats, + }) + .await?; }, _ => { log_debug!( @@ -458,7 +462,9 @@ impl SparkEventHandler { Ok(()) } - fn handle_payment_failed(&self, payment: breez_sdk_spark::Payment) -> Result<(), TrustedError> { + async 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)?; @@ -484,11 +490,13 @@ impl SparkEventHandler { 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, - })?; + self.event_queue + .add_event(Event::PaymentFailed { + payment_id, + payment_hash: Some(PaymentHash(payment_hash)), + reason: None, + }) + .await?; }, _ => { log_debug!(self.logger, "Unsupported payment details for Send: {payment:?}") From 63957be57711f790d2ecda9a4c287f10d834f692 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Wed, 19 Nov 2025 08:38:25 -0600 Subject: [PATCH 14/20] Update to latest ldk-node --- orange-sdk/Cargo.toml | 2 +- orange-sdk/src/lightning_wallet.rs | 4 ++-- orange-sdk/src/trusted_wallet/dummy.rs | 2 +- orange-sdk/tests/test_utils.rs | 1 + 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/orange-sdk/Cargo.toml b/orange-sdk/Cargo.toml index 020f2c1..3089707 100644 --- a/orange-sdk/Cargo.toml +++ b/orange-sdk/Cargo.toml @@ -23,7 +23,7 @@ _cashu-tests = ["_test-utils", "cdk-ldk-node", "cdk/mint", "cdk-sqlite", "cdk-ax [dependencies] graduated-rebalancer = { path = "../graduated-rebalancer", version = "0.1.0" } -ldk-node = { git = "https://github.com/benthecarman/ldk-node.git", rev = "492e6eafcbe0e84f1e1b38268a4d9ad20337929d" } +ldk-node = { git = "https://github.com/benthecarman/ldk-node.git", rev = "3ed1d13451239c54616e9f3fe75fa1f748807b15" } lightning-macros = "0.2.0-beta1" bitcoin-payment-instructions = { workspace = true } chrono = { version = "0.4", default-features = false } diff --git a/orange-sdk/src/lightning_wallet.rs b/orange-sdk/src/lightning_wallet.rs index f28c99f..5208708 100644 --- a/orange-sdk/src/lightning_wallet.rs +++ b/orange-sdk/src/lightning_wallet.rs @@ -302,7 +302,7 @@ impl LightningWallet { .inner .ldk_node .bolt12_payment() - .send_using_amount(offer, amount.milli_sats(), None, None), + .send_using_amount(offer, amount.milli_sats(), None, None, None), PaymentMethod::OnChain(address) => { let amount_sats = amount.sats().map_err(|_| NodeError::InvalidAmount)?; @@ -332,7 +332,7 @@ impl LightningWallet { self.inner.ldk_node.splice_out( &chan.user_channel_id, chan.counterparty_node_id, - address.clone(), + address, amount_sats, )?; diff --git a/orange-sdk/src/trusted_wallet/dummy.rs b/orange-sdk/src/trusted_wallet/dummy.rs index 6cce26a..0dcd527 100644 --- a/orange-sdk/src/trusted_wallet/dummy.rs +++ b/orange-sdk/src/trusted_wallet/dummy.rs @@ -338,7 +338,7 @@ impl TrustedWalletInterface for DummyTrustedWallet { let id = self .ldk_node .bolt12_payment() - .send_using_amount(&offer, amount.milli_sats(), None, None) + .send_using_amount(&offer, amount.milli_sats(), None, None, None) .unwrap() .0; diff --git a/orange-sdk/tests/test_utils.rs b/orange-sdk/tests/test_utils.rs index 786c341..5f03b3d 100644 --- a/orange-sdk/tests/test_utils.rs +++ b/orange-sdk/tests/test_utils.rs @@ -142,6 +142,7 @@ fn create_lsp(uuid: Uuid, bitcoind: &Bitcoind) -> Arc { min_channel_lifetime: 10_000, min_channel_opening_fee_msat: 0, max_client_to_self_delay: 1024, + client_trusts_lsp: true, }; builder.set_liquidity_provider_lsps2(lsps2_service_config); From 289251ca3489a0cdc014fde46d392aab3c209962 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Mon, 1 Dec 2025 12:11:37 -0600 Subject: [PATCH 15/20] Remove debug printlns --- orange-sdk/src/event.rs | 1 - orange-sdk/src/lib.rs | 10 ---------- orange-sdk/src/lightning_wallet.rs | 1 - orange-sdk/src/rebalancer.rs | 3 --- 4 files changed, 15 deletions(-) diff --git a/orange-sdk/src/event.rs b/orange-sdk/src/event.rs index da7637f..a815fd1 100644 --- a/orange-sdk/src/event.rs +++ b/orange-sdk/src/event.rs @@ -482,7 +482,6 @@ impl LdkEventHandler { } }, ldk_node::Event::SpliceFailed { .. } => { - println!("===========splice failed============"); log_warn!(self.logger, "Received SpliceFailed event: {event:?}"); }, } diff --git a/orange-sdk/src/lib.rs b/orange-sdk/src/lib.rs index 22fa56c..66cb696 100644 --- a/orange-sdk/src/lib.rs +++ b/orange-sdk/src/lib.rs @@ -672,8 +672,6 @@ impl Wallet { ); let tx_metadata = self.inner.tx_metadata.read(); - println!("\n\n======================="); - let mut internal_transfers = HashMap::new(); #[derive(Debug, Default)] struct InternalTransfer { @@ -777,7 +775,6 @@ impl Wallet { }); } } - println!("ln payments: {:#?}", lightning_payments); for payment in lightning_payments { use ldk_node::payment::PaymentDirection; let lightning_receive_fee = match payment.kind { @@ -797,10 +794,6 @@ impl Wallet { ), }; if let Some(tx_metadata) = tx_metadata.get(&PaymentId::SelfCustodial(payment.id.0)) { - println!( - "Found metadata for lightning payment {} got {:?}", - payment.id, tx_metadata.ty - ); match &tx_metadata.ty { TxType::TrustedToLightning { trusted_payment: _, @@ -823,7 +816,6 @@ impl Wallet { .or_insert(InternalTransfer::default()); if &payment.id.0 == channel_txid.as_byte_array() { debug_assert!(entry.send_fee.is_none()); - println!("onchain to ln fee: {:?}", payment.fee_paid_msat); entry.send_fee = payment .fee_paid_msat .map(|fee| Amount::from_milli_sats(fee).expect("Must be valid")); @@ -841,8 +833,6 @@ impl Wallet { }); debug_assert!(entry.transaction.is_none()); - println!("trigger: {:?}", payment.fee_paid_msat); - entry.transaction = Some(Transaction { id: PaymentId::SelfCustodial(payment.id.0), status: payment.status.into(), diff --git a/orange-sdk/src/lightning_wallet.rs b/orange-sdk/src/lightning_wallet.rs index 5208708..1de45e4 100644 --- a/orange-sdk/src/lightning_wallet.rs +++ b/orange-sdk/src/lightning_wallet.rs @@ -581,7 +581,6 @@ impl graduated_rebalancer::LightningWallet for LightningWallet { .find(|c| c.user_channel_id.0 == channel_id && c.funding_txo.is_some()); match chan { Some(c) => { - println!("\nRETURNING HERE\n"); return c.funding_txo.expect("channel has no funding txo"); }, None => { diff --git a/orange-sdk/src/rebalancer.rs b/orange-sdk/src/rebalancer.rs index 4a6d2b7..84771d5 100644 --- a/orange-sdk/src/rebalancer.rs +++ b/orange-sdk/src/rebalancer.rs @@ -354,9 +354,6 @@ impl graduated_rebalancer::EventHandler for OrangeRebalanceEventHandler { } => { let chan_txid = channel_outpoint.txid; let triggering_txid = Txid::from_byte_array(trigger_id); - println!( - "Marking {chan_txid} as onchain rebalance initiated for triggering txid {triggering_txid}" - ); let trigger_id = PaymentId::SelfCustodial(triggering_txid.to_byte_array()); self.tx_metadata .set_tx_caused_rebalance(&trigger_id) From cd130ab01436b75d85c0497df4628581430fe037 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Thu, 18 Dec 2025 12:22:33 -0600 Subject: [PATCH 16/20] Use official releases --- orange-sdk/Cargo.toml | 13 ++++---- orange-sdk/src/lightning_wallet.rs | 33 +++---------------- .../src/trusted_wallet/cashu/cashu_store.rs | 7 ++++ orange-sdk/tests/test_utils.rs | 9 ++++- 4 files changed, 26 insertions(+), 36 deletions(-) diff --git a/orange-sdk/Cargo.toml b/orange-sdk/Cargo.toml index 3089707..da7743d 100644 --- a/orange-sdk/Cargo.toml +++ b/orange-sdk/Cargo.toml @@ -23,24 +23,23 @@ _cashu-tests = ["_test-utils", "cdk-ldk-node", "cdk/mint", "cdk-sqlite", "cdk-ax [dependencies] graduated-rebalancer = { path = "../graduated-rebalancer", version = "0.1.0" } -ldk-node = { git = "https://github.com/benthecarman/ldk-node.git", rev = "3ed1d13451239c54616e9f3fe75fa1f748807b15" } -lightning-macros = "0.2.0-beta1" +ldk-node = { version = "0.7.0" } +lightning-macros = "0.2.0" 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 = "1f2e9995230cd582d6b4aa7d06d76b99defb635e", 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 = "f95a3cfc3b48913e399ea6b080d99b5566437d0a", default-features = false, features = ["wallet"], optional = true } +cdk = { version = "0.14.2", default-features = false, features = ["wallet"], optional = true } serde_json = { version = "1.0", optional = true } async-trait = "0.1" log = "0.4.28" corepc-node = { version = "0.8.0", features = ["29_0", "download"], optional = true } -cdk-ldk-node = { git = "https://github.com/benthecarman/cdk.git", rev = "f95a3cfc3b48913e399ea6b080d99b5566437d0a", optional = true } -cdk-sqlite = { git = "https://github.com/benthecarman/cdk.git", rev = "f95a3cfc3b48913e399ea6b080d99b5566437d0a", optional = true } -cdk-axum = { git = "https://github.com/benthecarman/cdk.git", rev = "f95a3cfc3b48913e399ea6b080d99b5566437d0a", optional = true } +cdk-ldk-node = { version = "0.14.2", optional = true } +cdk-sqlite = { version = "0.14.2", optional = true } +cdk-axum = { version = "0.14.2", optional = true } axum = { version = "0.8.1", optional = true } uniffi = { version = "0.29", features = ["cli", "tokio"], optional = true } diff --git a/orange-sdk/src/lightning_wallet.rs b/orange-sdk/src/lightning_wallet.rs index 1de45e4..b37b691 100644 --- a/orange-sdk/src/lightning_wallet.rs +++ b/orange-sdk/src/lightning_wallet.rs @@ -13,16 +13,16 @@ use ldk_node::bitcoin::base64::Engine; use ldk_node::bitcoin::base64::prelude::BASE64_STANDARD; use ldk_node::bitcoin::secp256k1::PublicKey; use ldk_node::bitcoin::{Address, Network}; +use ldk_node::config::{AsyncPaymentsRole, BackgroundSyncConfig}; use ldk_node::lightning::ln::channelmanager::PaymentId; use ldk_node::lightning::ln::msgs::SocketAddress; 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}; use ldk_node::lightning_invoice::{Bolt11Invoice, Bolt11InvoiceDescription, Description}; use ldk_node::payment::{ ConfirmationStatus, PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, }; -use ldk_node::{DynStore, NodeError, UserChannelId, lightning}; +use ldk_node::{DynStore, NodeError, UserChannelId}; use graduated_rebalancer::{LightningBalance, ReceivedLightningPayment}; @@ -133,37 +133,14 @@ impl LightningWallet { }, }; + builder.set_async_payments_role(Some(AsyncPaymentsRole::Client))?; + builder.set_custom_logger(Arc::clone(&logger) as Arc); builder.set_runtime(runtime.get_handle()); - // download scorer and write to storage - // todo switch to https://github.com/lightningdevkit/ldk-node/pull/449 once available if let Some(url) = config.scorer_url { - let fetch = tokio::time::timeout(std::time::Duration::from_secs(10), reqwest::get(url)); - let res = fetch.await.map_err(|e| { - log_error!(logger, "Timed out downloading scorer: {e}"); - InitFailure::LdkNodeStartFailure(NodeError::InvalidUri) - })?; - - let req = res.map_err(|e| { - log_error!(logger, "Failed to download scorer: {e}"); - InitFailure::LdkNodeStartFailure(NodeError::InvalidUri) - })?; - - let bytes = req.bytes().await.map_err(|e| { - log_debug!(logger, "Failed to read scorer bytes: {e}"); - InitFailure::LdkNodeStartFailure(NodeError::InvalidUri) - })?; - - KVStore::write( - store.as_ref(), - lightning::util::persist::SCORER_PERSISTENCE_PRIMARY_NAMESPACE, - lightning::util::persist::SCORER_PERSISTENCE_SECONDARY_NAMESPACE, - lightning::util::persist::SCORER_PERSISTENCE_KEY, - bytes.to_vec(), - ) - .await?; + builder.set_pathfinding_scores_source(url); } let ldk_node = Arc::new(builder.build_with_store(Arc::clone(&store))?); diff --git a/orange-sdk/src/trusted_wallet/cashu/cashu_store.rs b/orange-sdk/src/trusted_wallet/cashu/cashu_store.rs index 9dbb38a..50f3407 100644 --- a/orange-sdk/src/trusted_wallet/cashu/cashu_store.rs +++ b/orange-sdk/src/trusted_wallet/cashu/cashu_store.rs @@ -765,6 +765,13 @@ impl WalletDatabase for CashuKvDatabase { Ok(filtered_proofs) } + async fn get_balance( + &self, mint_url: Option, unit: Option, state: Option>, + ) -> Result { + let proofs = self.get_proofs(mint_url, unit, state, None).await?; + Ok(proofs.iter().map(|p| u64::from(p.proof.amount)).sum()) + } + async fn update_proofs_state(&self, ys: Vec, state: State) -> Result<(), Self::Err> { // Update proofs in storage and cache for y in &ys { diff --git a/orange-sdk/tests/test_utils.rs b/orange-sdk/tests/test_utils.rs index 5f03b3d..7253c85 100644 --- a/orange-sdk/tests/test_utils.rs +++ b/orange-sdk/tests/test_utils.rs @@ -427,8 +427,15 @@ async fn build_test_nodes() -> TestParams { }) .await; + let lsp_listen = lsp_listen_clone.to_string(); cdk.node() - .open_channel(lsp_node_id, lsp_listen_clone, 10_000_000, Some(5_000_000_000), None) + .open_channel( + lsp_node_id, + lsp_listen.parse().unwrap(), + 10_000_000, + Some(5_000_000_000), + None, + ) .unwrap(); wait_for_tx_broadcast(&bitcoind_clone); generate_blocks(&bitcoind_clone, 6); From cf2c6576f0999d24729712bbd87772cc0570edc8 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Thu, 18 Dec 2025 16:18:10 -0600 Subject: [PATCH 17/20] Have tests use esplora syncing --- Cargo.toml | 6 +- orange-sdk/Cargo.toml | 5 +- orange-sdk/src/lib.rs | 27 ++++-- orange-sdk/src/lightning_wallet.rs | 52 +++++++---- orange-sdk/tests/integration_tests.rs | 59 ++++++++---- orange-sdk/tests/test_utils.rs | 128 ++++++++++++++++++++------ 6 files changed, 200 insertions(+), 77 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index fd77c38..493717e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,9 +16,9 @@ codegen-units = 1 # Reduce number of codegen units to increase optimizations. panic = 'abort' # Abort on panic [workspace.dependencies] -bitcoin-payment-instructions = { git = "https://github.com/benthecarman/bitcoin-payment-instructions.git", branch = "orange-fork2", features = ["http"] } -lightning = { version = "0.2.0-rc1" } -lightning-invoice = { version = "0.34.0-rc1" } +bitcoin-payment-instructions = { version = "0.6.0" } +lightning = { version = "0.2.0" } +lightning-invoice = { version = "0.34.0" } [profile.release] panic = "abort" diff --git a/orange-sdk/Cargo.toml b/orange-sdk/Cargo.toml index da7743d..0dd33c6 100644 --- a/orange-sdk/Cargo.toml +++ b/orange-sdk/Cargo.toml @@ -17,7 +17,7 @@ default = ["spark"] uniffi = ["dep:uniffi", "spark", "cashu"] spark = ["breez-sdk-spark", "uuid", "serde_json"] cashu = ["cdk", "serde_json"] -_test-utils = ["corepc-node", "cashu", "uuid/v7", "rand"] +_test-utils = ["corepc-node", 'electrsd', "cashu", "uuid/v7", "rand"] _cashu-tests = ["_test-utils", "cdk-ldk-node", "cdk/mint", "cdk-sqlite", "cdk-axum", "axum"] [dependencies] @@ -36,7 +36,8 @@ serde_json = { version = "1.0", optional = true } async-trait = "0.1" log = "0.4.28" -corepc-node = { version = "0.8.0", features = ["29_0", "download"], optional = true } +corepc-node = { version = "0.10.1", features = ["27_2", "download"], optional = true } +electrsd = { version = "0.36.1", default-features = false, features = ["esplora_a33e97e1", "corepc-node_27_2"], optional = true } cdk-ldk-node = { version = "0.14.2", optional = true } cdk-sqlite = { version = "0.14.2", optional = true } cdk-axum = { version = "0.14.2", optional = true } diff --git a/orange-sdk/src/lib.rs b/orange-sdk/src/lib.rs index 66cb696..33acf24 100644 --- a/orange-sdk/src/lib.rs +++ b/orange-sdk/src/lib.rs @@ -785,13 +785,17 @@ impl Wallet { }, _ => None, }; - let fee = match payment.fee_paid_msat { - None => lightning_receive_fee, - Some(fee) => Some( - Amount::from_milli_sats(fee) - .unwrap() - .saturating_add(lightning_receive_fee.unwrap_or(Amount::ZERO)), - ), + let fee = if payment.direction == PaymentDirection::Outbound { + match payment.fee_paid_msat { + None => Some(lightning_receive_fee.unwrap_or(Amount::ZERO)), + Some(fee) => Some( + Amount::from_milli_sats(fee) + .unwrap() + .saturating_add(lightning_receive_fee.unwrap_or(Amount::ZERO)), + ), + } + } else { + Some(lightning_receive_fee.unwrap_or(Amount::ZERO)) }; if let Some(tx_metadata) = tx_metadata.get(&PaymentId::SelfCustodial(payment.id.0)) { match &tx_metadata.ty { @@ -1357,4 +1361,13 @@ impl Wallet { // Wait until non-cancellable background tasks (mod LDK's background processor) are done. self.inner.runtime.wait_on_background_tasks(); } + + /// Manually sync the LDK and BDK wallets with the current chain state and update the fee rate cache. + /// + /// This is done automatically in the background, but can be triggered manually if needed. Often useful for + /// testing purposes. + pub fn sync_ln_wallet(&self) -> Result<(), WalletError> { + self.inner.ln_wallet.inner.ldk_node.sync_wallets()?; + Ok(()) + } } diff --git a/orange-sdk/src/lightning_wallet.rs b/orange-sdk/src/lightning_wallet.rs index b37b691..cd261e7 100644 --- a/orange-sdk/src/lightning_wallet.rs +++ b/orange-sdk/src/lightning_wallet.rs @@ -110,22 +110,42 @@ impl LightningWallet { let (lsp_socket_addr, lsp_node_id, lsp_token) = config.lsp; builder.set_liquidity_source_lsps2(lsp_node_id, lsp_socket_addr.clone(), lsp_token); match config.chain_source { - ChainSource::Esplora { url, username, password } => match (&username, &password) { - (Some(username), Some(password)) => { - let mut headers = HashMap::with_capacity(1); - headers.insert( - "Authorization".to_string(), - format!( - "Basic {}", - BASE64_STANDARD.encode(format!("{}:{}", username, password)) - ), - ); - builder.set_chain_source_esplora_with_headers(url, headers, None) - }, - (None, None) => builder.set_chain_source_esplora(url, None), - _ => { - return Err(InitFailure::LdkNodeStartFailure(NodeError::WalletOperationFailed)); - }, + ChainSource::Esplora { url, username, password } => { + let sync_config = if config.network == Network::Regtest { + ldk_node::config::EsploraSyncConfig { + background_sync_config: Some(BackgroundSyncConfig { + onchain_wallet_sync_interval_secs: 2, + lightning_wallet_sync_interval_secs: 2, + fee_rate_cache_update_interval_secs: 30, + }), + } + } else { + ldk_node::config::EsploraSyncConfig::default() + }; + + match (&username, &password) { + (Some(username), Some(password)) => { + let mut headers = HashMap::with_capacity(1); + headers.insert( + "Authorization".to_string(), + format!( + "Basic {}", + BASE64_STANDARD.encode(format!("{username}:{password}")) + ), + ); + builder.set_chain_source_esplora_with_headers( + url, + headers, + Some(sync_config), + ) + }, + (None, None) => builder.set_chain_source_esplora(url, Some(sync_config)), + _ => { + return Err(InitFailure::LdkNodeStartFailure( + NodeError::WalletOperationFailed, + )); + }, + } }, ChainSource::Electrum(url) => builder.set_chain_source_electrum(url, None), ChainSource::BitcoindRPC { host, port, user, password } => { diff --git a/orange-sdk/tests/integration_tests.rs b/orange-sdk/tests/integration_tests.rs index 40a3305..79dd8ad 100644 --- a/orange-sdk/tests/integration_tests.rs +++ b/orange-sdk/tests/integration_tests.rs @@ -1,6 +1,6 @@ #![cfg(feature = "_test-utils")] -use crate::test_utils::{generate_blocks, open_channel_from_lsp, wait_next_event}; +use crate::test_utils::{generate_blocks, open_channel_from_lsp, wait_for_tx, wait_next_event}; use bitcoin_payment_instructions::amount::Amount; use bitcoin_payment_instructions::http_resolver::HTTPHrnResolver; use bitcoin_payment_instructions::{ParseError, PaymentInstructions}; @@ -370,12 +370,14 @@ async fn test_receive_to_ln() { } #[tokio::test(flavor = "multi_thread")] -async fn test_receive_to_onchain() { +#[test_log::test] +async fn test_receive_onchain() { test_utils::run_test(|params| async move { let wallet = Arc::clone(¶ms.wallet); let lsp = Arc::clone(¶ms.lsp); let bitcoind = Arc::clone(¶ms.bitcoind); let third_party = Arc::clone(¶ms.third_party); + let electrsd = Arc::clone(¶ms.electrsd); let starting_bal = wallet.get_balance().await.unwrap(); assert_eq!(starting_bal.available_balance(), Amount::ZERO); @@ -389,8 +391,10 @@ async fn test_receive_to_onchain() { .send_to_address(&uri.address.unwrap(), recv_amt.sats().unwrap(), None) .unwrap(); + wait_for_tx(&electrsd.client, sent_txid).await; + // confirm transaction - generate_blocks(&bitcoind, 6); + generate_blocks(&bitcoind, &electrsd, 6).await; // check we received on-chain, should be pending // wait for payment success @@ -435,9 +439,9 @@ async fn test_receive_to_onchain() { // a rebalance should be initiated, we need to mine the channel opening transaction // for it to be confirmed and reflected in the wallet's history - generate_blocks(&bitcoind, 6); + generate_blocks(&bitcoind, &electrsd, 6).await; tokio::time::sleep(Duration::from_secs(5)).await; // wait for sync - generate_blocks(&bitcoind, 6); // confirm the channel opening transaction + generate_blocks(&bitcoind, &electrsd, 6).await; // confirm the channel opening transaction tokio::time::sleep(Duration::from_secs(5)).await; // wait for sync // wait for rebalance to be initiated @@ -482,13 +486,14 @@ async fn test_receive_to_onchain() { } #[tokio::test(flavor = "multi_thread")] -// #[test_log::test] +#[test_log::test] async fn test_receive_to_onchain_with_channel() { test_utils::run_test(|params| async move { let wallet = Arc::clone(¶ms.wallet); let lsp = Arc::clone(¶ms.lsp); let bitcoind = Arc::clone(¶ms.bitcoind); let third_party = Arc::clone(¶ms.third_party); + let electrsd = Arc::clone(¶ms.electrsd); let start = open_channel_from_lsp(&wallet, Arc::clone(&third_party)).await; @@ -508,10 +513,13 @@ async fn test_receive_to_onchain_with_channel() { .send_to_address(&uri.address.unwrap(), recv_amt.sats().unwrap(), None) .unwrap(); - println!("Sent txid: {}", sent_txid); + println!("Sent txid: {sent_txid}"); + + wait_for_tx(&electrsd.client, sent_txid).await; // confirm transaction - generate_blocks(&bitcoind, 6); + generate_blocks(&bitcoind, &electrsd, 6).await; + wallet.sync_ln_wallet().unwrap(); // check we received on-chain, should be pending // wait for payment success @@ -521,6 +529,7 @@ async fn test_receive_to_onchain_with_channel() { }) .await; + println!("waiting for onchain recv event"); let event = wait_next_event(&wallet).await; match event { Event::OnchainPaymentReceived { txid, amount_sat, status, .. } => { @@ -531,6 +540,7 @@ async fn test_receive_to_onchain_with_channel() { ev => panic!("Expected OnchainPaymentReceived event, got {ev:?}"), } + println!("waiting for splice pending event"); let event = wait_next_event(&wallet).await; match event { Event::SplicePending { counterparty_node_id, .. } => { @@ -540,7 +550,7 @@ async fn test_receive_to_onchain_with_channel() { } // confirm splice - generate_blocks(&bitcoind, 6); + generate_blocks(&bitcoind, &electrsd, 6).await; tokio::time::sleep(Duration::from_secs(5)).await; let event = wait_next_event(&wallet).await; @@ -589,12 +599,13 @@ async fn run_test_pay_lightning_from_self_custody(amountless: bool) { let wallet = Arc::clone(¶ms.wallet); let bitcoind = Arc::clone(¶ms.bitcoind); let third_party = Arc::clone(¶ms.third_party); + let electrsd = Arc::clone(¶ms.electrsd); // get a channel so we can make a payment open_channel_from_lsp(&wallet, Arc::clone(&third_party)).await; // wait for sync - generate_blocks(&bitcoind, 6); + generate_blocks(&bitcoind, &electrsd, 6).await; test_utils::wait_for_condition("wallet sync after channel open", || async { wallet.channels().iter().any(|a| a.confirmations.is_some_and(|c| c > 0) && a.is_usable) && third_party @@ -692,12 +703,13 @@ async fn run_test_pay_bolt12_from_self_custody(amountless: bool) { let wallet = Arc::clone(¶ms.wallet); let bitcoind = Arc::clone(¶ms.bitcoind); let third_party = Arc::clone(¶ms.third_party); + let electrsd = Arc::clone(¶ms.electrsd); // get a channel so we can make a payment open_channel_from_lsp(&wallet, Arc::clone(&third_party)).await; // wait for sync - generate_blocks(&bitcoind, 6); + generate_blocks(&bitcoind, &electrsd, 6).await; test_utils::wait_for_condition("wallet sync after channel open", || async { wallet.channels().iter().any(|a| a.confirmations.is_some_and(|c| c > 0) && a.is_usable) && third_party @@ -786,6 +798,7 @@ async fn test_pay_onchain_from_self_custody() { let wallet = Arc::clone(¶ms.wallet); let bitcoind = Arc::clone(¶ms.bitcoind); let third_party = Arc::clone(¶ms.third_party); + let electrsd = Arc::clone(¶ms.electrsd); // disable rebalancing so we have on-chain funds wallet.set_rebalance_enabled(false); @@ -806,7 +819,7 @@ async fn test_pay_onchain_from_self_custody() { .unwrap(); // confirm tx - generate_blocks(&bitcoind, 6); + generate_blocks(&bitcoind, &electrsd, 6).await; // wait for node to sync and see the balance update test_utils::wait_for_condition("wallet sync after on-chain receive", || async { @@ -826,7 +839,7 @@ async fn test_pay_onchain_from_self_custody() { tokio::time::sleep(Duration::from_secs(1)).await; // confirm the tx - generate_blocks(&bitcoind, 6); + generate_blocks(&bitcoind, &electrsd, 6).await; // sleep for a second to wait for sync tokio::time::sleep(Duration::from_secs(1)).await; @@ -901,6 +914,7 @@ async fn test_pay_onchain_from_channel() { let wallet = Arc::clone(¶ms.wallet); let bitcoind = Arc::clone(¶ms.bitcoind); let third_party = Arc::clone(¶ms.third_party); + let electrsd = Arc::clone(¶ms.electrsd); // get a channel so we can make a payment let recv = open_channel_from_lsp(&wallet, Arc::clone(&third_party)).await; @@ -913,7 +927,7 @@ async fn test_pay_onchain_from_channel() { assert_eq!(starting_bal.pending_balance, Amount::ZERO); // wait for sync - generate_blocks(&bitcoind, 6); + generate_blocks(&bitcoind, &electrsd, 6).await; test_utils::wait_for_condition("wallet sync after channel open", || async { wallet.channels().iter().any(|a| a.confirmations.is_some_and(|c| c > 0) && a.is_usable) }) @@ -931,7 +945,7 @@ async fn test_pay_onchain_from_channel() { tokio::time::sleep(Duration::from_secs(1)).await; // confirm the tx - generate_blocks(&bitcoind, 6); + generate_blocks(&bitcoind, &electrsd, 6).await; // sleep for a second to wait for sync tokio::time::sleep(Duration::from_secs(1)).await; @@ -1010,6 +1024,7 @@ async fn test_force_close_handling() { let lsp = Arc::clone(¶ms.lsp); let bitcoind = Arc::clone(¶ms.bitcoind); let third_party = Arc::clone(¶ms.third_party); + let electrsd = Arc::clone(¶ms.electrsd); let starting_bal = wallet.get_balance().await.unwrap(); assert_eq!(starting_bal.available_balance(), Amount::ZERO); @@ -1022,7 +1037,7 @@ async fn test_force_close_handling() { open_channel_from_lsp(&wallet, Arc::clone(&third_party)).await; // mine some blocks to ensure the channel is confirmed - generate_blocks(&bitcoind, 6); + generate_blocks(&bitcoind, &electrsd, 6).await; // get channel details let channel = lsp @@ -1058,6 +1073,7 @@ async fn test_close_all_channels() { let lsp = Arc::clone(¶ms.lsp); let bitcoind = Arc::clone(¶ms.bitcoind); let third_party = Arc::clone(¶ms.third_party); + let electrsd = Arc::clone(¶ms.electrsd); let starting_bal = wallet.get_balance().await.unwrap(); assert_eq!(starting_bal.available_balance(), Amount::ZERO); @@ -1070,7 +1086,7 @@ async fn test_close_all_channels() { open_channel_from_lsp(&wallet, Arc::clone(&third_party)).await; // mine some blocks to ensure the channel is confirmed - generate_blocks(&bitcoind, 6); + generate_blocks(&bitcoind, &electrsd, 6).await; // init closing all channels wallet.close_channels().unwrap(); @@ -1489,6 +1505,7 @@ async fn test_payment_network_mismatch() { test_utils::run_test(|params| async move { let wallet = Arc::clone(¶ms.wallet); let bitcoind = Arc::clone(¶ms.bitcoind); + let electrsd = Arc::clone(¶ms.electrsd); // disable rebalancing so we have on-chain funds wallet.set_rebalance_enabled(false); @@ -1505,7 +1522,7 @@ async fn test_payment_network_mismatch() { .unwrap(); // confirm tx - generate_blocks(&bitcoind, 6); + generate_blocks(&bitcoind, &electrsd, 6).await; test_utils::wait_for_condition("wallet sync after on-chain receive", || async { wallet.get_balance().await.unwrap().pending_balance >= recv_amount }) @@ -1550,13 +1567,14 @@ async fn test_concurrent_payments() { test_utils::run_test(|params| async move { let wallet = Arc::clone(¶ms.wallet); let bitcoind = Arc::clone(¶ms.bitcoind); + let electrsd = Arc::clone(¶ms.electrsd); let third_party = Arc::clone(¶ms.third_party); // First, build up sufficient balance for concurrent sending let _channel_amount = open_channel_from_lsp(&wallet, Arc::clone(&third_party)).await; // Wait for sync - generate_blocks(&bitcoind, 6); + generate_blocks(&bitcoind, &electrsd, 6).await; test_utils::wait_for_condition("wallet sync after channel open", || async { wallet.channels().iter().any(|a| a.confirmations.is_some_and(|c| c > 0) && a.is_usable) && third_party @@ -2145,13 +2163,14 @@ async fn test_lsp_connectivity_fallback() { let wallet = Arc::clone(¶ms.wallet); let lsp = Arc::clone(¶ms.lsp); let bitcoind = Arc::clone(¶ms.bitcoind); + let electrsd = Arc::clone(¶ms.electrsd); let third_party = Arc::clone(¶ms.third_party); // open a channel with the LSP open_channel_from_lsp(&wallet, Arc::clone(&third_party)).await; // confirm channel - generate_blocks(&bitcoind, 6); + generate_blocks(&bitcoind, &electrsd, 6).await; test_utils::wait_for_condition("wallet sync after channel open", || async { wallet.channels().iter().any(|a| a.confirmations.is_some_and(|c| c > 0) && a.is_usable) && third_party diff --git a/orange-sdk/tests/test_utils.rs b/orange-sdk/tests/test_utils.rs index 7253c85..7c9049d 100644 --- a/orange-sdk/tests/test_utils.rs +++ b/orange-sdk/tests/test_utils.rs @@ -9,11 +9,14 @@ use cdk::types::FeeReserve; use cdk_ldk_node::{BitcoinRpcConfig, GossipSource}; use corepc_node::client::bitcoin::Network; use corepc_node::{Conf, Node as Bitcoind, get_available_port}; +use electrsd::ElectrsD; +use electrsd::electrum_client::ElectrumApi; use ldk_node::bitcoin::hashes::Hash; use ldk_node::lightning::ln::msgs::SocketAddress; use ldk_node::liquidity::LSPS2ServiceConfig; use ldk_node::payment::PaymentStatus; use ldk_node::{Node, bitcoin}; +use orange_sdk::bitcoin::Txid; #[cfg(not(feature = "_cashu-tests"))] use orange_sdk::trusted_wallet::dummy::DummyTrustedWalletExtraConfig; use orange_sdk::{ @@ -61,17 +64,26 @@ pub async fn wait_next_event(wallet: &orange_sdk::Wallet) -> orange_sdk::Event { event } -fn create_bitcoind(uuid: Uuid) -> Bitcoind { +async fn create_bitcoind(uuid: Uuid) -> (Arc, Arc) { let mut conf = Conf::default(); - conf.args.push("-txindex"); + // conf.args.push("-txindex"); + conf.args.push("-rest"); conf.args.push("-rpcworkqueue=200"); + conf.args.push("-fallbackfee=0.00002"); conf.staticdir = Some(temp_dir().join(format!("orange-test-{uuid}/bitcoind"))); let bitcoind = Bitcoind::with_conf(corepc_node::downloaded_exe_path().unwrap(), &conf) .unwrap_or_else(|_| panic!("Failed to start bitcoind for test {uuid}")); - // Wait for bitcoind to be ready before returning + // Wait for bitcoind to be ready before electrsd starts wait_for_bitcoind_ready(&bitcoind); + let mut electrsd_conf = electrsd::Conf::default(); + electrsd_conf.http_enabled = true; + electrsd_conf.network = "regtest"; + let electrsd = + ElectrsD::with_conf(electrsd::downloaded_exe_path().unwrap(), &bitcoind, &electrsd_conf) + .unwrap_or_else(|_| panic!("Failed to start electrsd for test {uuid}")); + // mine 101 blocks to get some spendable funds, split it up into multiple calls // to avoid potentially hitting RPC timeouts on slower CI systems let address = bitcoind.client.new_address().unwrap(); @@ -79,7 +91,9 @@ fn create_bitcoind(uuid: Uuid) -> Bitcoind { let _block_hashes = bitcoind.client.generate_to_address(1, &address).unwrap(); } - bitcoind + wait_for_block(&electrsd.client, 101).await; + + (Arc::new(bitcoind), Arc::new(electrsd)) } fn wait_for_bitcoind_ready(bitcoind: &Bitcoind) { @@ -103,12 +117,18 @@ fn wait_for_bitcoind_ready(bitcoind: &Bitcoind) { } } -pub fn generate_blocks(bitcoind: &Bitcoind, num: usize) { +pub async fn generate_blocks(bitcoind: &Bitcoind, electrs: &ElectrsD, num: usize) { + let blockchain_info = + bitcoind.client.get_blockchain_info().expect("failed to get blockchain info"); + let cur_height = blockchain_info.blocks; + let address = bitcoind.client.new_address().unwrap(); let _block_hashes = bitcoind .client .generate_to_address(num, &address) .unwrap_or_else(|_| panic!("failed to generate {num} blocks")); + + wait_for_block(&electrs.client, cur_height as usize + num).await; } fn create_lsp(uuid: Uuid, bitcoind: &Bitcoind) -> Arc { @@ -213,10 +233,10 @@ fn create_third_party(uuid: Uuid, bitcoind: &Bitcoind) -> Arc { ldk_node } -fn fund_node(node: &Node, bitcoind: &Bitcoind) { +async fn fund_node(node: &Node, bitcoind: &Bitcoind, electrsd: &ElectrsD) { let addr = node.onchain_payment().new_address().unwrap(); bitcoind.client.send_to_address(&addr, bitcoin::Amount::from_btc(1.0).unwrap()).unwrap(); - generate_blocks(bitcoind, 6); + generate_blocks(bitcoind, electrsd, 6).await; } #[derive(Clone)] @@ -225,6 +245,7 @@ pub struct TestParams { pub lsp: Arc, pub third_party: Arc, pub bitcoind: Arc, + pub electrsd: Arc, #[cfg(feature = "_cashu-tests")] pub _mint: Arc, } @@ -262,13 +283,13 @@ where async fn build_test_nodes() -> TestParams { let test_id = Uuid::now_v7(); - let bitcoind = Arc::new(create_bitcoind(test_id)); + let (bitcoind, electrsd) = create_bitcoind(test_id).await; let lsp = create_lsp(test_id, &bitcoind); - fund_node(&lsp, &bitcoind); + fund_node(&lsp, &bitcoind, &electrsd).await; let third_party = create_third_party(test_id, &bitcoind); let start_bal = third_party.list_balances().total_onchain_balance_sats; - fund_node(&third_party, &bitcoind); + fund_node(&third_party, &bitcoind, &electrsd).await; // wait for node to sync (needs blocking wait as we are not in async context here) let third = Arc::clone(&third_party); @@ -283,7 +304,7 @@ async fn build_test_nodes() -> TestParams { // open a channel from payer to LSP third_party.open_channel(lsp.node_id(), lsp_listen.clone(), 10_000_000, None, None).unwrap(); wait_for_tx_broadcast(&bitcoind); - generate_blocks(&bitcoind, 6); + generate_blocks(&bitcoind, &electrsd, 6).await; // wait for channel ready (needs blocking wait as we are not in async context here) let third_party_clone = Arc::clone(&third_party); @@ -309,9 +330,11 @@ async fn build_test_nodes() -> TestParams { }; let tmp = temp_dir().join(format!("orange-test-{test_id}/ldk-node")); - let cookie = bitcoind.params.get_cookie_values().unwrap().unwrap(); - let bitcoind_port = bitcoind.params.rpc_socket.port(); + // take esplora url and just get the port, as we know it's running on localhost + let base_url = electrsd.esplora_url.as_ref().unwrap(); + let port = base_url.split(':').last().unwrap(); + let esplora_url = format!("http://localhost:{port}"); let wallet_config = WalletConfig { storage_config: StorageConfig::LocalSQLite(tmp.to_str().unwrap().to_string()), @@ -319,12 +342,7 @@ async fn build_test_nodes() -> TestParams { scorer_url: None, rgs_url: None, tunables: Tunables::default(), - chain_source: ChainSource::BitcoindRPC { - host: "127.0.0.1".to_string(), - port: bitcoind_port, - user: cookie.user, - password: cookie.password, - }, + chain_source: ChainSource::Esplora { url: esplora_url, username: None, password: None }, lsp: (lsp_listen, lsp_node_id, None), network: Network::Regtest, seed: Seed::Seed64(seed), @@ -418,7 +436,7 @@ async fn build_test_nodes() -> TestParams { .client .send_to_address(&addr, bitcoin::Amount::from_btc(1.0).unwrap()) .unwrap(); - generate_blocks(&bitcoind_clone, 6); + generate_blocks(&bitcoind_clone, &electrsd, 6).await; // wait for cdk node to sync wait_for_condition("cdk node sync after funding", || { @@ -438,7 +456,7 @@ async fn build_test_nodes() -> TestParams { ) .unwrap(); wait_for_tx_broadcast(&bitcoind_clone); - generate_blocks(&bitcoind_clone, 6); + generate_blocks(&bitcoind_clone, &electrsd, 6).await; // wait for sync/channel ready wait_for_condition("cdk channel to become usable", || { @@ -450,18 +468,14 @@ async fn build_test_nodes() -> TestParams { mint }; + let esplora_url = format!("http://{}", electrsd.esplora_url.as_ref().unwrap()); let tmp = temp_dir().join(format!("orange-test-{test_id}/wallet")); let wallet_config = WalletConfig { storage_config: StorageConfig::LocalSQLite(tmp.to_str().unwrap().to_string()), logger_type: LoggerType::LogFacade, scorer_url: None, tunables: Tunables::default(), - chain_source: ChainSource::BitcoindRPC { - host: "127.0.0.1".to_string(), - port: bitcoind_port, - user: cookie.user, - password: cookie.password, - }, + chain_source: ChainSource::Esplora { url: esplora_url, username: None, password: None }, lsp: (lsp_listen, lsp_node_id, None), rgs_url: None, network: Network::Regtest, @@ -473,11 +487,11 @@ async fn build_test_nodes() -> TestParams { }; let wallet = Arc::new(Wallet::new(wallet_config).await.unwrap()); - return TestParams { wallet, lsp, third_party, bitcoind, _mint: mint }; + return TestParams { wallet, lsp, third_party, bitcoind, electrsd, _mint: mint }; }; #[cfg(not(feature = "_cashu-tests"))] - TestParams { wallet, lsp, third_party, bitcoind } + TestParams { wallet, lsp, third_party, bitcoind, electrsd } } pub async fn open_channel_from_lsp(wallet: &orange_sdk::Wallet, payer: Arc) -> Amount { @@ -532,3 +546,59 @@ fn wait_for_tx_broadcast(bitcoind: &Bitcoind) { std::thread::sleep(Duration::from_millis(250)); } } + +pub(crate) async fn wait_for_block(electrs: &E, min_height: usize) { + let mut header = match electrs.block_headers_subscribe() { + Ok(header) => header, + Err(_) => { + // While subscribing should succeed the first time around, we ran into some cases where + // it didn't. Since we can't proceed without subscribing, we try again after a delay + // and panic if it still fails. + tokio::time::sleep(Duration::from_secs(3)).await; + electrs.block_headers_subscribe().expect("failed to subscribe to block headers") + }, + }; + loop { + if header.height >= min_height { + break; + } + header = exponential_backoff_poll(|| { + electrs.ping().expect("failed to ping electrs"); + electrs.block_headers_pop().expect("failed to pop block header") + }) + .await; + } +} + +pub(crate) async fn wait_for_tx(electrs: &E, txid: Txid) { + if electrs.transaction_get(&txid).is_ok() { + return; + } + + exponential_backoff_poll(|| { + electrs.ping().unwrap(); + electrs.transaction_get(&txid).ok() + }) + .await; +} + +pub(crate) async fn exponential_backoff_poll(mut poll: F) -> T +where + F: FnMut() -> Option, +{ + let mut delay = Duration::from_millis(64); + let mut tries = 0; + loop { + match poll() { + Some(data) => break data, + None if delay.as_millis() < 512 => { + delay = delay.mul_f32(2.0); + }, + + None => {}, + } + assert!(tries < 20, "Reached max tries."); + tries += 1; + tokio::time::sleep(delay).await; + } +} From 9629741311355f7b040729529c967d919980a40f Mon Sep 17 00:00:00 2001 From: benthecarman Date: Thu, 18 Dec 2025 16:59:15 -0600 Subject: [PATCH 18/20] final fixups --- orange-sdk/Cargo.toml | 6 +++--- orange-sdk/src/ffi/orange/wallet.rs | 8 ++++---- orange-sdk/src/lib.rs | 14 +++++++------- orange-sdk/src/lightning_wallet.rs | 17 +++++++++++++---- .../src/trusted_wallet/spark/spark_store.rs | 4 ++-- orange-sdk/tests/test_utils.rs | 12 +++++++++--- 6 files changed, 38 insertions(+), 23 deletions(-) diff --git a/orange-sdk/Cargo.toml b/orange-sdk/Cargo.toml index 0dd33c6..29a7356 100644 --- a/orange-sdk/Cargo.toml +++ b/orange-sdk/Cargo.toml @@ -25,7 +25,7 @@ graduated-rebalancer = { path = "../graduated-rebalancer", version = "0.1.0" } ldk-node = { version = "0.7.0" } lightning-macros = "0.2.0" -bitcoin-payment-instructions = { workspace = true } +bitcoin-payment-instructions = { workspace = true, features = ["http"] } chrono = { version = "0.4", default-features = false } rand = { version = "0.8.5", optional = true } breez-sdk-spark = { git = "https://github.com/breez/spark-sdk.git", rev = "1f2e9995230cd582d6b4aa7d06d76b99defb635e", default-features = false, features = ["rustls-tls"], optional = true } @@ -36,8 +36,8 @@ serde_json = { version = "1.0", optional = true } async-trait = "0.1" log = "0.4.28" -corepc-node = { version = "0.10.1", features = ["27_2", "download"], optional = true } -electrsd = { version = "0.36.1", default-features = false, features = ["esplora_a33e97e1", "corepc-node_27_2"], optional = true } +corepc-node = { version = "0.10.1", features = ["29_0", "download"], optional = true } +electrsd = { version = "0.36.1", default-features = false, features = ["esplora_a33e97e1", "corepc-node_29_0"], optional = true } cdk-ldk-node = { version = "0.14.2", optional = true } cdk-sqlite = { version = "0.14.2", optional = true } cdk-axum = { version = "0.14.2", optional = true } diff --git a/orange-sdk/src/ffi/orange/wallet.rs b/orange-sdk/src/ffi/orange/wallet.rs index 3025b4f..6be9515 100644 --- a/orange-sdk/src/ffi/orange/wallet.rs +++ b/orange-sdk/src/ffi/orange/wallet.rs @@ -196,10 +196,10 @@ impl Wallet { /// Authenticates the user via [LNURL-auth] for the given LNURL string. /// /// [LNURL-auth]: https://github.com/lnurl/luds/blob/luds/04.md - pub fn lnurl_auth(&self, lnurl: &str) -> Result<(), WalletError> { - self.inner.lnurl_auth(lnurl)?; - Ok(()) - } + // pub fn lnurl_auth(&self, lnurl: &str) -> Result<(), WalletError> { + // self.inner.lnurl_auth(lnurl)?; + // Ok(()) + // } /// Returns the wallet's configured tunables. pub fn get_tunables(&self) -> Arc { diff --git a/orange-sdk/src/lib.rs b/orange-sdk/src/lib.rs index 33acf24..31b57ad 100644 --- a/orange-sdk/src/lib.rs +++ b/orange-sdk/src/lib.rs @@ -1277,13 +1277,13 @@ impl Wallet { Ok(()) } - /// Authenticates the user via [LNURL-auth] for the given LNURL string. - /// - /// [LNURL-auth]: https://github.com/lnurl/luds/blob/luds/04.md - pub fn lnurl_auth(&self, _lnurl: &str) -> Result<(), WalletError> { - // todo wait for merge, self.inner.ln_wallet.inner.ldk_node.lnurl_auth(lnurl)?; - Ok(()) - } + // Authenticates the user via [LNURL-auth] for the given LNURL string. + // + // [LNURL-auth]: https://github.com/lnurl/luds/blob/luds/04.md + // pub fn lnurl_auth(&self, _lnurl: &str) -> Result<(), WalletError> { + // // todo wait for merge, self.inner.ln_wallet.inner.ldk_node.lnurl_auth(lnurl)?; + // Ok(()) + // } /// Returns the wallet's configured tunables. pub fn get_tunables(&self) -> Tunables { diff --git a/orange-sdk/src/lightning_wallet.rs b/orange-sdk/src/lightning_wallet.rs index cd261e7..b09e07c 100644 --- a/orange-sdk/src/lightning_wallet.rs +++ b/orange-sdk/src/lightning_wallet.rs @@ -393,15 +393,12 @@ impl LightningWallet { let channels = self.inner.ldk_node.list_channels(); let channel = channels.iter().find(|c| c.counterparty_node_id == self.inner.lsp_node_id); - // todo fix this, for now leave some onchain balance for fees - let amt = amount.saturating_sub(Amount::from_sats(10_000).unwrap()); - match channel { Some(chan) => { self.inner.ldk_node.splice_in( &chan.user_channel_id, chan.counterparty_node_id, - amt.sats_rounding_up(), + amount.sats_rounding_up(), )?; Ok(chan.user_channel_id) }, @@ -561,6 +558,18 @@ impl graduated_rebalancer::LightningWallet for LightningWallet { fn splice_to_lsp_channel( &self, amt: Amount, ) -> Pin> + Send + '_>> { + let bal = self.inner.ldk_node.list_balances(); + // if we don't have enough onchain balance, return error + // if we are within 1,000 sats of the amount, reduce the amount to account for fees + if bal.spendable_onchain_balance_sats < amt.sats_rounding_up() { + return Box::pin(async move { Err(NodeError::InsufficientFunds) }); + } else if bal.spendable_onchain_balance_sats < amt.sats_rounding_up() + 1_000 { + let reduced_amt = amt.saturating_sub(Amount::from_sats(1_000).expect("valid amount")); + return Box::pin(async move { + self.splice_balance_into_channel(reduced_amt).await.map(|c| c.0) + }); + } + Box::pin(async move { self.splice_balance_into_channel(amt).await.map(|c| c.0) }) } diff --git a/orange-sdk/src/trusted_wallet/spark/spark_store.rs b/orange-sdk/src/trusted_wallet/spark/spark_store.rs index 3f50946..5524b59 100644 --- a/orange-sdk/src/trusted_wallet/spark/spark_store.rs +++ b/orange-sdk/src/trusted_wallet/spark/spark_store.rs @@ -3,14 +3,14 @@ use std::sync::Arc; use crate::io; -use ldk_node::DynStore; -use ldk_node::lightning::util::persist::KVStore; use breez_sdk_spark::{ DepositInfo, ListPaymentsRequest, Payment, PaymentDetails, PaymentMetadata, StorageError, UpdateDepositPayload, }; +use ldk_node::DynStore; use ldk_node::lightning::util::persist::KVSTORE_NAMESPACE_KEY_MAX_LEN; +use ldk_node::lightning::util::persist::KVStore; const SPARK_PRIMARY_NAMESPACE: &str = "spark"; const SPARK_CACHE_NAMESPACE: &str = "cache"; diff --git a/orange-sdk/tests/test_utils.rs b/orange-sdk/tests/test_utils.rs index 7c9049d..e516ee4 100644 --- a/orange-sdk/tests/test_utils.rs +++ b/orange-sdk/tests/test_utils.rs @@ -235,7 +235,9 @@ fn create_third_party(uuid: Uuid, bitcoind: &Bitcoind) -> Arc { async fn fund_node(node: &Node, bitcoind: &Bitcoind, electrsd: &ElectrsD) { let addr = node.onchain_payment().new_address().unwrap(); - bitcoind.client.send_to_address(&addr, bitcoin::Amount::from_btc(1.0).unwrap()).unwrap(); + let res = + bitcoind.client.send_to_address(&addr, bitcoin::Amount::from_btc(1.0).unwrap()).unwrap(); + wait_for_tx(&electrsd.client, res.txid().unwrap()).await; generate_blocks(bitcoind, electrsd, 6).await; } @@ -333,7 +335,7 @@ async fn build_test_nodes() -> TestParams { // take esplora url and just get the port, as we know it's running on localhost let base_url = electrsd.esplora_url.as_ref().unwrap(); - let port = base_url.split(':').last().unwrap(); + let port = base_url.split(':').next_back().unwrap(); let esplora_url = format!("http://localhost:{port}"); let wallet_config = WalletConfig { @@ -468,7 +470,11 @@ async fn build_test_nodes() -> TestParams { mint }; - let esplora_url = format!("http://{}", electrsd.esplora_url.as_ref().unwrap()); + // take esplora url and just get the port, as we know it's running on localhost + let base_url = electrsd.esplora_url.as_ref().unwrap(); + let port = base_url.split(':').next_back().unwrap(); + let esplora_url = format!("http://localhost:{port}"); + let tmp = temp_dir().join(format!("orange-test-{test_id}/wallet")); let wallet_config = WalletConfig { storage_config: StorageConfig::LocalSQLite(tmp.to_str().unwrap().to_string()), From f4d3c2984e701ffad6054182a0a1ac15db1b2514 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Fri, 19 Dec 2025 14:11:31 -0600 Subject: [PATCH 19/20] Fix runtime in cli --- examples/cli/src/main.rs | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/examples/cli/src/main.rs b/examples/cli/src/main.rs index a3c13e7..7821335 100644 --- a/examples/cli/src/main.rs +++ b/examples/cli/src/main.rs @@ -16,7 +16,6 @@ use std::path::PathBuf; use std::str::FromStr; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; -use tokio::runtime::Runtime; use tokio::signal; const NETWORK: Network = Network::Bitcoin; // Supports Bitcoin and Regtest @@ -227,16 +226,13 @@ async fn main() -> Result<()> { println!("{}", "Type 'help' for available commands or 'exit' to quit".dimmed()); println!(); - // Create runtime outside async context to avoid drop issues - let runtime = Arc::new(Runtime::new().context("Failed to create tokio runtime")?); - // Initialize wallet once at startup - let mut state = runtime.block_on(WalletState::new())?; + let mut state = WalletState::new().await?; // Set up signal handling for graceful shutdown let shutdown_state = state.shutdown.clone(); let shutdown_wallet = state.wallet.clone(); - runtime.spawn(async move { + tokio::task::spawn(async move { if let Ok(()) = signal::ctrl_c().await { println!("\n{} Shutdown signal received, stopping wallet...", "⏹️".bright_yellow()); shutdown_state.store(true, Ordering::Relaxed); @@ -248,12 +244,12 @@ async fn main() -> Result<()> { // If a command was provided via command line, execute it and start interactive mode if let Some(command) = cli.command { - runtime.block_on(execute_command(command, &mut state))?; + execute_command(command, &mut state).await?; println!(); } // Start interactive mode - runtime.block_on(start_interactive_mode(state)) + start_interactive_mode(state).await } async fn start_interactive_mode(mut state: WalletState) -> Result<()> { From 7b3d6970627205e32f8957651d32ad2f0eaa77ea Mon Sep 17 00:00:00 2001 From: benthecarman Date: Fri, 19 Dec 2025 14:28:20 -0600 Subject: [PATCH 20/20] Hold runtime in bindings --- orange-sdk/src/ffi/orange/wallet.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/orange-sdk/src/ffi/orange/wallet.rs b/orange-sdk/src/ffi/orange/wallet.rs index 6be9515..9b308a9 100644 --- a/orange-sdk/src/ffi/orange/wallet.rs +++ b/orange-sdk/src/ffi/orange/wallet.rs @@ -88,6 +88,7 @@ impl_into_core_type!(SingleUseReceiveUri, OrangeSingleUseReceiveUri); #[derive(Clone, uniffi::Object)] pub struct Wallet { inner: Arc, + _rt: Arc, } #[uniffi::export(async_runtime = "tokio")] @@ -100,7 +101,7 @@ impl Wallet { let inner = rt.block_on(async move { OrangeWallet::new(config).await })?; - Ok(Wallet { inner: Arc::new(inner) }) + Ok(Wallet { inner: Arc::new(inner), _rt: rt }) } pub fn node_id(&self) -> String {