From d5ef72ffc176df0d687a5986a151701cafc774fe Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Thu, 25 Jun 2026 10:12:07 +0100 Subject: [PATCH 1/2] fix(regtest): wait for LDK BOLT12 readiness Gate LN regtest startup on an actual LDK BOLT12 quote payment before\nrunning the test suite. Reconnect CLN to the restarted LDK mint node avoid dropping the stopped setup node, which can panic in ldk-node 0.7. Keep CLN fetchinvoice bounded with a Tokio timeout instead of sending the\nRPC timeout field, which CLN rejects when serialized as a float. --- .../src/bin/start_regtest_mints.rs | 185 ++++++++++++++++-- .../cdk-integration-tests/src/init_regtest.rs | 7 +- .../src/ln_regtest/ln_client/cln_client.rs | 82 ++++++-- misc/itests.sh | 21 ++ 4 files changed, 252 insertions(+), 43 deletions(-) diff --git a/crates/cdk-integration-tests/src/bin/start_regtest_mints.rs b/crates/cdk-integration-tests/src/bin/start_regtest_mints.rs index 8e69f24c3b..c5800eef98 100644 --- a/crates/cdk-integration-tests/src/bin/start_regtest_mints.rs +++ b/crates/cdk-integration-tests/src/bin/start_regtest_mints.rs @@ -19,21 +19,26 @@ use std::time::Duration; use anyhow::{bail, Result}; use bip39::Mnemonic; -use cashu::Amount; +use cashu::{Amount, CurrencyUnit, PaymentMethod}; +use cdk::wallet::Wallet; use cdk_integration_tests::cli::CommonArgs; -use cdk_integration_tests::init_regtest::start_regtest_end; +use cdk_integration_tests::init_regtest::{get_cln_dir, start_regtest_end}; +use cdk_integration_tests::ln_regtest::ln_client::{ClnClient, LightningClient}; use cdk_integration_tests::shared; -use cdk_ldk_node::{CdkLdkNode, CdkLdkNodeBuilder}; +use cdk_ldk_node::CdkLdkNodeBuilder; use cdk_mintd::config::LoggingConfig; +use cdk_sqlite::wallet::memory; use clap::Parser; use ldk_node::lightning::ln::msgs::SocketAddress; use tokio::runtime::Runtime; use tokio::signal; use tokio::signal::unix::SignalKind; use tokio::sync::{oneshot, Notify}; -use tokio::time::timeout; +use tokio::time::{sleep, timeout}; use tokio_util::sync::CancellationToken; +const LDK_NODE_P2P_PORT: u16 = 8092; + #[derive(Parser)] #[command(name = "start-regtest-mints")] #[command(about = "Start regtest mints", long_about = None)] @@ -205,16 +210,13 @@ async fn start_lnd_mint( Ok(handle) } -/// Start regtest LDK mint using the library -/// If `existing_node` is provided, it will be used instead of creating a new one. -/// This allows the mint to use a node that was already set up (e.g., with channels). +/// Start regtest LDK mint using the library. async fn start_ldk_mint( temp_dir: &Path, port: u16, database_type: &str, shutdown: Arc, runtime: Option>, - existing_node: Option, ) -> Result> { let ldk_work_dir = temp_dir.join("ldk_mint"); @@ -237,7 +239,7 @@ async fn start_ldk_mint( ldk_node_announce_addresses: None, storage_dir_path: Some(ldk_work_dir.to_string_lossy().to_string()), ldk_node_host: Some("127.0.0.1".to_string()), - ldk_node_port: Some(port + 10), // Use a different port for the LDK node P2P connections + ldk_node_port: Some(LDK_NODE_P2P_PORT), gossip_source_type: None, rgs_url: None, webserver_host: Some("127.0.0.1".to_string()), @@ -267,13 +269,6 @@ async fn start_ldk_mint( println!("LDK mint shutdown signal received"); }; - // Both nodes now use the same seed, so the standard flow should work. - // The existing_node parameter is kept for API compatibility but not used - // since run_mintd_with_shutdown will create its own node with the same seed. - if existing_node.is_some() { - println!("Using existing LDK node configuration (same seed as mint)"); - } - match cdk_mintd::run_mintd_with_shutdown( &ldk_work_dir, &settings, @@ -292,6 +287,134 @@ async fn start_ldk_mint( Ok(handle) } +async fn wait_for_ldk_bolt12_ready( + temp_dir: &Path, + ldk_port: u16, + ldk_node_id: &str, + shutdown_notify: Arc, +) -> Result<()> { + let mint_url = format!("http://127.0.0.1:{ldk_port}"); + let readiness_amount = Amount::from(1); + let start_time = std::time::Instant::now(); + let max_wait = Duration::from_secs(120); + let mut attempt = 1; + let mut last_error = None; + + println!("Waiting for LDK mint BOLT12 readiness on port {ldk_port}..."); + + let wallet = Wallet::new( + &mint_url, + CurrencyUnit::Sat, + Arc::new(memory::empty().await?), + Mnemonic::generate(12)?.to_seed_normalized(""), + None, + )?; + + let cln_client = ClnClient::new(get_cln_dir(temp_dir, "one"), None).await?; + match cln_client + .connect_peer( + ldk_node_id.to_string(), + "127.0.0.1".to_string(), + LDK_NODE_P2P_PORT, + ) + .await + { + Ok(_) => tracing::info!("CLN reconnected to LDK mint node for readiness check"), + Err(err) => { + tracing::warn!("CLN reconnect to LDK mint node failed before readiness check: {err}") + } + } + + loop { + if shutdown_notify.is_cancelled() { + bail!("Canceled waiting for LDK mint BOLT12 readiness"); + } + + if start_time.elapsed() > max_wait { + let last_error = last_error + .as_deref() + .unwrap_or("no readiness attempt completed before timeout"); + bail!("Timeout waiting for LDK mint BOLT12 readiness: {last_error}"); + } + + let mint_quote = match wallet + .mint_quote( + PaymentMethod::BOLT12, + Some(readiness_amount), + None, + None, + ) + .await + { + Ok(quote) => quote, + Err(err) => { + last_error = Some(format!("quote creation failed: {err}")); + tracing::warn!( + "LDK BOLT12 readiness attempt {attempt}: quote creation failed: {err}" + ); + sleep(Duration::from_secs(2)).await; + attempt += 1; + continue; + } + }; + + match cln_client + .pay_bolt12_offer(None, mint_quote.request.clone()) + .await + { + Ok(_) => (), + Err(err) => { + last_error = Some(format!("payment failed: {err}")); + tracing::warn!("LDK BOLT12 readiness attempt {attempt}: payment failed: {err}"); + sleep(Duration::from_secs(2)).await; + attempt += 1; + continue; + } + } + + let quote_poll_start = std::time::Instant::now(); + let mut last_status_error = None; + while quote_poll_start.elapsed() <= Duration::from_secs(20) { + if shutdown_notify.is_cancelled() { + bail!("Canceled waiting for LDK mint BOLT12 readiness"); + } + + match wallet.check_mint_quote_status(&mint_quote.id).await { + Ok(quote_state) => { + if quote_state.amount_paid >= readiness_amount { + println!("LDK mint BOLT12 readiness confirmed on port {ldk_port}"); + return Ok(()); + } + } + Err(err) => { + last_status_error = Some(err.to_string()); + tracing::warn!( + "LDK BOLT12 readiness attempt {attempt}: quote status check failed: {err}" + ); + } + } + + sleep(Duration::from_secs(1)).await; + } + + tracing::warn!( + "LDK BOLT12 readiness attempt {attempt}: payment was sent but quote {} was not observed as paid", + mint_quote.id + ); + last_error = Some(match last_status_error { + Some(err) => format!( + "payment was sent but quote {} was not observed as paid; last status check failed: {err}", + mint_quote.id + ), + None => format!( + "payment was sent but quote {} was not observed as paid", + mint_quote.id + ), + }); + attempt += 1; + } +} + /// Create settings for an LDK mint fn create_ldk_settings( port: u16, @@ -400,6 +523,11 @@ fn apply_database_settings( Ok(()) } +fn signal_mints_ready(temp_dir: &Path) -> Result<()> { + fs::write(temp_dir.join(".ready"), "ready\n")?; + Ok(()) +} + /// Create settings for an onchain-only mint fn create_onchain_settings(port: u16) -> cdk_mintd::config::Settings { cdk_mintd::config::Settings { @@ -563,6 +691,7 @@ fn main() -> Result<()> { .await?; println!("Onchain-only mint is ready on port {}!", args.cln_port); + signal_mints_ready(&temp_dir)?; // Wait for shutdown shutdown_clone_one.notified().await; @@ -591,7 +720,7 @@ fn main() -> Result<()> { }, vec![SocketAddress::TcpIpV4 { addr: [127, 0, 0, 1], - port: 8092, + port: LDK_NODE_P2P_PORT, }], ) .with_seed(test_mnemonic.clone()); @@ -624,7 +753,7 @@ fn main() -> Result<()> { }, vec![SocketAddress::TcpIpV4 { addr: [127, 0, 0, 1], - port: 8092, + port: LDK_NODE_P2P_PORT, }], ) .with_seed(test_mnemonic); @@ -634,6 +763,12 @@ fn main() -> Result<()> { }; let inner_node = cdk_ldk.node(); + let ldk_node_id = inner_node.node_id().to_string(); + // The setup node is stopped by `start_regtest_end` after channels are + // opened. `ldk-node` 0.7 can panic when dropping a stopped node, so keep + // this setup wrapper alive for the short-lived regtest process and let + // the mint restart from the same storage and seed. + std::mem::forget(cdk_ldk); let temp_dir_clone = temp_dir.clone(); let shutdown_clone_two = Arc::clone(&shutdown_clone); @@ -667,14 +802,13 @@ fn main() -> Result<()> { start_lnd_mint(&temp_dir, args.lnd_port, &args.database_type, shutdown_clone.clone()) .await?; - // Start LDK mint (using the existing node that was already set up with channels) + // Start LDK mint from the node storage that was set up with channels. let ldk_handle = start_ldk_mint( &temp_dir, args.ldk_port, &args.database_type, shutdown_clone.clone(), Some(rt_clone), - Some(cdk_ldk), ) .await?; @@ -716,7 +850,7 @@ fn main() -> Result<()> { Arc::clone(&cancel_token) ), ) { - Ok(_) => println!("All mints are ready!"), + Ok(_) => println!("All mint HTTP endpoints are ready!"), Err(e) => { if cancel_token.is_cancelled() { bail!("Startup canceled by user"); @@ -730,6 +864,15 @@ fn main() -> Result<()> { bail!("Token canceled"); } + wait_for_ldk_bolt12_ready( + &temp_dir, + args.ldk_port, + &ldk_node_id, + Arc::clone(&cancel_token), + ) + .await?; + signal_mints_ready(&temp_dir)?; + println!("All regtest mints started successfully!"); println!("CLN mint: http://{}:{}", args.mint_addr, args.cln_port); println!("LND mint: http://{}:{}", args.mint_addr, args.lnd_port); diff --git a/crates/cdk-integration-tests/src/init_regtest.rs b/crates/cdk-integration-tests/src/init_regtest.rs index 8ac874376d..2e7d5bf341 100644 --- a/crates/cdk-integration-tests/src/init_regtest.rs +++ b/crates/cdk-integration-tests/src/init_regtest.rs @@ -554,7 +554,12 @@ pub async fn start_regtest_end( lnd_client.wait_channels_active().await?; - node.stop()?; + let stop_result = node.stop(); + // This setup-only node is intentionally restarted by the mint from + // the same storage and seed. `ldk-node` 0.7 can panic when dropping + // a stopped node, so avoid running its Drop implementation here. + std::mem::forget(node); + stop_result?; } else { cln_client.wait_channels_active().await?; diff --git a/crates/cdk-integration-tests/src/ln_regtest/ln_client/cln_client.rs b/crates/cdk-integration-tests/src/ln_regtest/ln_client/cln_client.rs index 81f66857ff..692d45c6af 100644 --- a/crates/cdk-integration-tests/src/ln_regtest/ln_client/cln_client.rs +++ b/crates/cdk-integration-tests/src/ln_regtest/ln_client/cln_client.rs @@ -21,7 +21,7 @@ use cln_rpc::primitives::{Amount, AmountOrAll, AmountOrAny, PublicKey}; use cln_rpc::{ClnRpc, Request}; use lightning::offers::offer::Offer; use tokio::sync::Mutex; -use tokio::time::sleep; +use tokio::time::{sleep, timeout}; use uuid::Uuid; use super::types::{Balance, ConnectInfo}; @@ -141,28 +141,68 @@ impl ClnClient { amount_msat: Option, offer: String, ) -> Result { - let mut cln_client = self.client.lock().await; - - let cln_response = cln_client - .call_typed(&FetchinvoiceRequest { - amount_msat: amount_msat.map(Amount::from_msat), - payer_note: None, - quantity: None, - recurrence_counter: None, - recurrence_label: None, - recurrence_start: None, - timeout: None, - offer, - bip353: None, - payer_metadata: None, + let max_attempts = 5; + let mut attempts_remaining = max_attempts; + let mut delay = Duration::from_millis(500); + + let invoice = loop { + let attempt = max_attempts - attempts_remaining + 1; + let cln_response = timeout(Duration::from_secs(15), async { + let mut cln_client = self.client.lock().await; + cln_client + .call_typed(&FetchinvoiceRequest { + amount_msat: amount_msat.map(Amount::from_msat), + payer_note: None, + quantity: None, + recurrence_counter: None, + recurrence_label: None, + recurrence_start: None, + timeout: None, + offer: offer.clone(), + bip353: None, + payer_metadata: None, + }) + .await }) - .await?; + .await; - let invoice = cln_response.invoice; + match cln_response { + Ok(Ok(response)) => break response.invoice, + Ok(Err(err)) => { + let err = anyhow!("CLN fetchinvoice for BOLT12 offer failed: {err}"); + tracing::warn!( + "CLN fetchinvoice for BOLT12 offer failed on attempt {attempt}/{max_attempts}: {err}" + ); + + attempts_remaining -= 1; + if attempts_remaining == 0 { + return Err(err); + } - drop(cln_client); + sleep(delay).await; + delay = std::cmp::min(delay * 2, Duration::from_secs(5)); + } + Err(_) => { + let err = + anyhow!("CLN fetchinvoice for BOLT12 offer timed out after 15 seconds"); + tracing::warn!( + "CLN fetchinvoice for BOLT12 offer failed on attempt {attempt}/{max_attempts}: {err}" + ); + + attempts_remaining -= 1; + if attempts_remaining == 0 { + return Err(err); + } - self.pay_invoice(invoice).await + sleep(delay).await; + delay = std::cmp::min(delay * 2, Duration::from_secs(5)); + } + } + }; + + self.pay_invoice(invoice) + .await + .map_err(|err| anyhow!("CLN failed to pay fetched BOLT12 invoice: {err}")) } pub async fn get_bolt12_offer( @@ -416,7 +456,7 @@ impl LightningClient for ClnClient { label: None, riskfactor: None, maxfeepercent: None, - retry_for: None, + retry_for: Some(10), maxdelay: None, exemptfee: None, localinvreqid: None, @@ -608,7 +648,7 @@ impl LightningClient for ClnClient { label: None, riskfactor: None, maxfeepercent: None, - retry_for: None, + retry_for: Some(10), maxdelay: None, exemptfee: None, localinvreqid: None, diff --git a/misc/itests.sh b/misc/itests.sh index 08f07d24f7..9cba6ef586 100755 --- a/misc/itests.sh +++ b/misc/itests.sh @@ -209,6 +209,27 @@ if [[ "$SUITE" != "onchain" ]]; then done fi +READY_FILE_PATH="$CDK_ITESTS_DIR/.ready" +max_wait=300 +wait_count=0 +while [ $wait_count -lt $max_wait ]; do + if [ -f "$READY_FILE_PATH" ]; then + echo "Regtest mints readiness file found at: $READY_FILE_PATH" + break + fi + if ! ps -p "$CDK_REGTEST_PID" > /dev/null 2>&1; then + echo "ERROR: Regtest mints process exited before readiness file was created" + exit 1 + fi + wait_count=$((wait_count + 1)) + sleep 1 +done + +if [ ! -f "$READY_FILE_PATH" ]; then + echo "ERROR: Regtest mints did not become ready within $max_wait seconds" + exit 1 +fi + # Run cargo test if [[ "$SUITE" == "all" || "$SUITE" == "ln" ]]; then echo "Running regtest test with CLN mint and CLN client" From c3cd0d6c01e295424f4cb159fd9aa143aa13cf68 Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Sat, 27 Jun 2026 21:40:25 +0100 Subject: [PATCH 2/2] fix(regtest): harden LDK BOLT12 readiness Retry the LDK-to-LND channel open until both sides report the expected active/usable channel. Validate the full LDK/CLN/LND topology before signaling regtest readiness, and include channel summaries when setup fails. Retry CLN peer reconnect during BOLT12 readiness, reconnect CLN RPC after fetchinvoice timeouts, derive the LDK P2P port from the configured mint port, and preserve early regtest process exit status in the integration test wrapper. For the LDK mint BOLT12 melt integration test, retry the melt with a fresh CLN single-use offer if the first outgoing BOLT12 payment attempt fails. --- .../src/bin/start_regtest.rs | 6 +- .../src/bin/start_regtest_mints.rs | 62 ++++--- .../cdk-integration-tests/src/init_regtest.rs | 174 ++++++++++++++++-- .../src/ln_regtest/ln_client/cln_client.rs | 43 +++++ .../src/ln_regtest/ln_client/lnd_client.rs | 85 ++++++++- crates/cdk-integration-tests/tests/bolt12.rs | 48 +++-- misc/fake_itests.sh | 2 +- misc/itests.sh | 14 +- misc/mintd_payment_processor.sh | 2 +- 9 files changed, 370 insertions(+), 66 deletions(-) diff --git a/crates/cdk-integration-tests/src/bin/start_regtest.rs b/crates/cdk-integration-tests/src/bin/start_regtest.rs index 210e3ab0fe..b4b6c27ae3 100644 --- a/crates/cdk-integration-tests/src/bin/start_regtest.rs +++ b/crates/cdk-integration-tests/src/bin/start_regtest.rs @@ -84,10 +84,14 @@ async fn main() -> Result<()> { }); match timeout(Duration::from_secs(300), rx).await { - Ok(_) => { + Ok(Ok(())) => { tracing::info!("Regtest set up"); signal_progress(&temp_dir); } + Ok(Err(err)) => { + tracing::error!("regtest setup task exited before signaling readiness: {err}"); + anyhow::bail!("Could not set up regtest"); + } Err(_) => { tracing::error!("regtest setup timed out after 5 minutes"); anyhow::bail!("Could not set up regtest"); diff --git a/crates/cdk-integration-tests/src/bin/start_regtest_mints.rs b/crates/cdk-integration-tests/src/bin/start_regtest_mints.rs index c5800eef98..17277e321c 100644 --- a/crates/cdk-integration-tests/src/bin/start_regtest_mints.rs +++ b/crates/cdk-integration-tests/src/bin/start_regtest_mints.rs @@ -37,7 +37,14 @@ use tokio::sync::{oneshot, Notify}; use tokio::time::{sleep, timeout}; use tokio_util::sync::CancellationToken; -const LDK_NODE_P2P_PORT: u16 = 8092; +const LDK_NODE_P2P_PORT_OFFSET: u16 = 10; + +fn derive_ldk_node_p2p_port(ldk_port: u16) -> Result { + match ldk_port.checked_add(LDK_NODE_P2P_PORT_OFFSET) { + Some(port) => Ok(port), + None => bail!("LDK mint port {ldk_port} is too high to derive a P2P port"), + } +} #[derive(Parser)] #[command(name = "start-regtest-mints")] @@ -219,6 +226,7 @@ async fn start_ldk_mint( runtime: Option>, ) -> Result> { let ldk_work_dir = temp_dir.join("ldk_mint"); + let ldk_node_p2p_port = derive_ldk_node_p2p_port(port)?; // Create work directory for LDK mint fs::create_dir_all(&ldk_work_dir)?; @@ -239,7 +247,7 @@ async fn start_ldk_mint( ldk_node_announce_addresses: None, storage_dir_path: Some(ldk_work_dir.to_string_lossy().to_string()), ldk_node_host: Some("127.0.0.1".to_string()), - ldk_node_port: Some(LDK_NODE_P2P_PORT), + ldk_node_port: Some(ldk_node_p2p_port), gossip_source_type: None, rgs_url: None, webserver_host: Some("127.0.0.1".to_string()), @@ -290,6 +298,7 @@ async fn start_ldk_mint( async fn wait_for_ldk_bolt12_ready( temp_dir: &Path, ldk_port: u16, + ldk_node_p2p_port: u16, ldk_node_id: &str, shutdown_notify: Arc, ) -> Result<()> { @@ -311,19 +320,6 @@ async fn wait_for_ldk_bolt12_ready( )?; let cln_client = ClnClient::new(get_cln_dir(temp_dir, "one"), None).await?; - match cln_client - .connect_peer( - ldk_node_id.to_string(), - "127.0.0.1".to_string(), - LDK_NODE_P2P_PORT, - ) - .await - { - Ok(_) => tracing::info!("CLN reconnected to LDK mint node for readiness check"), - Err(err) => { - tracing::warn!("CLN reconnect to LDK mint node failed before readiness check: {err}") - } - } loop { if shutdown_notify.is_cancelled() { @@ -337,14 +333,32 @@ async fn wait_for_ldk_bolt12_ready( bail!("Timeout waiting for LDK mint BOLT12 readiness: {last_error}"); } - let mint_quote = match wallet - .mint_quote( - PaymentMethod::BOLT12, - Some(readiness_amount), - None, - None, + match cln_client + .connect_peer( + ldk_node_id.to_string(), + "127.0.0.1".to_string(), + ldk_node_p2p_port, ) .await + { + Ok(_) => tracing::info!("CLN reconnected to LDK mint node for readiness check"), + Err(err) => { + let err = err.to_string(); + if !err.to_lowercase().contains("already connected") { + last_error = Some(format!("peer reconnect failed: {err}")); + tracing::warn!( + "LDK BOLT12 readiness attempt {attempt}: CLN reconnect to LDK mint node failed: {err}" + ); + sleep(Duration::from_secs(2)).await; + attempt += 1; + continue; + } + } + } + + let mint_quote = match wallet + .mint_quote(PaymentMethod::BOLT12, Some(readiness_amount), None, None) + .await { Ok(quote) => quote, Err(err) => { @@ -701,6 +715,7 @@ fn main() -> Result<()> { let ldk_work_dir = temp_dir.join("ldk_mint"); fs::create_dir_all(ldk_work_dir.join("logs"))?; + let ldk_node_p2p_port = derive_ldk_node_p2p_port(args.ldk_port)?; let test_mnemonic: Mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" .parse() .expect("Failed to parse test mnemonic"); @@ -720,7 +735,7 @@ fn main() -> Result<()> { }, vec![SocketAddress::TcpIpV4 { addr: [127, 0, 0, 1], - port: LDK_NODE_P2P_PORT, + port: ldk_node_p2p_port, }], ) .with_seed(test_mnemonic.clone()); @@ -753,7 +768,7 @@ fn main() -> Result<()> { }, vec![SocketAddress::TcpIpV4 { addr: [127, 0, 0, 1], - port: LDK_NODE_P2P_PORT, + port: ldk_node_p2p_port, }], ) .with_seed(test_mnemonic); @@ -867,6 +882,7 @@ fn main() -> Result<()> { wait_for_ldk_bolt12_ready( &temp_dir, args.ldk_port, + ldk_node_p2p_port, &ldk_node_id, Arc::clone(&cancel_token), ) diff --git a/crates/cdk-integration-tests/src/init_regtest.rs b/crates/cdk-integration-tests/src/init_regtest.rs index 2e7d5bf341..fcee5a3240 100644 --- a/crates/cdk-integration-tests/src/init_regtest.rs +++ b/crates/cdk-integration-tests/src/init_regtest.rs @@ -4,7 +4,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; -use anyhow::Result; +use anyhow::{bail, Result}; use cdk::types::FeeReserve; use cdk_cln::Cln as CdkCln; use cdk_common::database::DynKVStore; @@ -286,6 +286,70 @@ where Ok(()) } +fn summarize_ldk_channels(node: &Node) -> String { + let channel_summary = node + .list_channels() + .iter() + .map(|channel| { + let confirmations = match channel.confirmations { + Some(confirmations) => confirmations.to_string(), + None => "none".to_string(), + }; + format!( + "peer={} usable={} ready={} confirmations={} inbound_msat={} outbound_msat={}", + channel.counterparty_node_id, + channel.is_usable, + channel.is_channel_ready, + confirmations, + channel.inbound_capacity_msat, + channel.outbound_capacity_msat + ) + }) + .collect::>() + .join("; "); + + if channel_summary.is_empty() { + "none".to_string() + } else { + channel_summary + } +} + +async fn wait_ldk_channels_usable(node: &Node, peer_ids: &[&str], max_checks: u32) -> Result<()> { + let mut count = 0; + while count < max_checks { + node.sync_wallets()?; + let channels = node.list_channels(); + let missing_peers = peer_ids + .iter() + .copied() + .filter(|peer_id| { + !channels.iter().any(|channel| { + channel.counterparty_node_id.to_string() == *peer_id && channel.is_usable + }) + }) + .collect::>(); + + if missing_peers.is_empty() { + tracing::info!("All expected LDK channels are usable"); + return Ok(()); + } + + tracing::warn!( + "Waiting for usable LDK channels with peers: {}", + missing_peers.join(", ") + ); + + count += 1; + tokio::time::sleep(Duration::from_secs(2)).await; + } + + bail!( + "Timeout waiting for usable LDK channels; channels: {}", + summarize_ldk_channels(node) + ) +} + pub async fn start_regtest_end( work_dir: &Path, sender: Sender<()>, @@ -493,7 +557,7 @@ pub async fn start_regtest_end( .connect_peer(cln_two_info.pubkey, listen_addr.clone(), cln_two_info.port) .await?; - tracing::info!("Opening channel from lnd to ldk"); + tracing::info!("Preparing channel from ldk to lnd"); let cln_info = cln_client.get_connect_info().await?; @@ -523,26 +587,83 @@ pub async fn start_regtest_end( true, )?; - // lnd_client - // .open_channel(1_500_000, &pubkey.to_string(), Some(750_000)) - // .await - // .unwrap(); + let ldk_pubkey = pubkey.to_string(); + let lnd_pubkey = lnd_info.pubkey.clone(); + let mut ldk_lnd_channel_error = None; + let mut delay = Duration::from_millis(500); + let max_retries = 5; + for attempt in 1..=max_retries { + generate_block(&bitcoin_client)?; + lnd_client.wait_chain_sync().await?; + + tracing::info!("Opening channel from ldk to lnd (attempt {attempt}/{max_retries})"); + let open_result = node.open_announced_channel( + lnd_pubkey.parse()?, + SocketAddress::TcpIpV4 { + addr: [127, 0, 0, 1], + port: lnd_info.port, + }, + 1_000_000, + Some(500_000_000), + None, + ); - generate_block(&bitcoin_client)?; - lnd_client.wait_chain_sync().await?; + match open_result { + Ok(_) => { + generate_block(&bitcoin_client)?; + cln_client.wait_chain_sync().await?; + lnd_client.wait_chain_sync().await?; + + match wait_ldk_channels_usable(&node, &[lnd_pubkey.as_str()], 10).await { + Ok(_) => match lnd_client + .wait_channel_active_with_peer_attempts(&ldk_pubkey, 10) + .await + { + Ok(_) => { + ldk_lnd_channel_error = None; + break; + } + Err(err) => { + tracing::warn!( + "LND did not report an active channel with LDK after attempt {attempt}/{max_retries}: {err}" + ); + ldk_lnd_channel_error = Some(err); + } + }, + Err(err) => { + tracing::warn!( + "LDK did not report a usable channel with LND after attempt {attempt}/{max_retries}: {err}" + ); + ldk_lnd_channel_error = Some(err); + } + } + } + Err(err) => { + let err = anyhow::Error::from(err); + tracing::warn!( + "LDK failed to open a channel with LND on attempt {attempt}/{max_retries}: {err}" + ); + ldk_lnd_channel_error = Some(err); + } + } - node.open_announced_channel( - lnd_info.pubkey.parse()?, - SocketAddress::TcpIpV4 { - addr: [127, 0, 0, 1], - port: lnd_info.port, - }, - 1_000_000, - Some(500_000_000), - None, - )?; + if attempt < max_retries { + tracing::warn!( + "Retrying LDK to LND channel open in {} ms; LDK channels: {}", + delay.as_millis(), + summarize_ldk_channels(&node) + ); + tokio::time::sleep(delay).await; + delay = std::cmp::min(delay * 2, Duration::from_secs(5)); + } + } - generate_block(&bitcoin_client)?; + if let Some(err) = ldk_lnd_channel_error { + bail!( + "Failed to open a usable LDK to LND channel after {max_retries} attempts: {err}; LDK channels: {}", + summarize_ldk_channels(&node) + ); + } tracing::info!("Ldk channels opened"); @@ -550,6 +671,21 @@ pub async fn start_regtest_end( tracing::info!("Ldk wallet synced"); + wait_ldk_channels_usable( + &node, + &[cln_info.pubkey.as_str(), lnd_info.pubkey.as_str()], + 100, + ) + .await?; + + cln_client + .wait_channel_active_with_peer(&pubkey.to_string()) + .await?; + + lnd_client + .wait_channel_active_with_peer(&pubkey.to_string()) + .await?; + cln_client.wait_channels_active().await?; lnd_client.wait_channels_active().await?; diff --git a/crates/cdk-integration-tests/src/ln_regtest/ln_client/cln_client.rs b/crates/cdk-integration-tests/src/ln_regtest/ln_client/cln_client.rs index 692d45c6af..238e17881e 100644 --- a/crates/cdk-integration-tests/src/ln_regtest/ln_client/cln_client.rs +++ b/crates/cdk-integration-tests/src/ln_regtest/ln_client/cln_client.rs @@ -36,6 +36,12 @@ pub struct ClnClient { } impl ClnClient { + async fn reconnect(&self) -> Result<()> { + let cln_client = cln_rpc::ClnRpc::new(&self.rpc_path).await?; + *self.client.lock().await = cln_client; + Ok(()) + } + /// Open a private (unannounced) channel to a peer pub async fn open_private_channel( &self, @@ -136,6 +142,38 @@ impl ClnClient { } } + pub async fn wait_channel_active_with_peer(&self, peer_id: &str) -> Result<()> { + let peer_id = PublicKey::from_str(peer_id)?.to_string(); + let mut count = 0; + while count < 100 { + let channels = self.list_channels().await?; + let peer_channels = channels + .channels + .iter() + .filter(|channel| { + channel.source.to_string() == peer_id + || channel.destination.to_string() == peer_id + }) + .collect::>(); + + if peer_channels.iter().any(|channel| channel.active) { + tracing::info!("CLN channel with peer {peer_id} is active"); + return Ok(()); + } + + if peer_channels.is_empty() { + tracing::warn!("No CLN channel found with peer {peer_id}"); + } else { + tracing::warn!("CLN channel with peer {peer_id} is not active yet"); + } + + count += 1; + sleep(Duration::from_secs(2)).await; + } + + bail!("Time out exceeded waiting for active CLN channel with peer {peer_id}") + } + pub async fn pay_bolt12_offer( &self, amount_msat: Option, @@ -188,6 +226,11 @@ impl ClnClient { tracing::warn!( "CLN fetchinvoice for BOLT12 offer failed on attempt {attempt}/{max_attempts}: {err}" ); + if let Err(reconnect_err) = self.reconnect().await { + tracing::warn!( + "Failed to reconnect CLN RPC after fetchinvoice timeout: {reconnect_err}" + ); + } attempts_remaining -= 1; if attempts_remaining == 0 { diff --git a/crates/cdk-integration-tests/src/ln_regtest/ln_client/lnd_client.rs b/crates/cdk-integration-tests/src/ln_regtest/ln_client/lnd_client.rs index 25d7ecbd40..78ac0d51d4 100644 --- a/crates/cdk-integration-tests/src/ln_regtest/ln_client/lnd_client.rs +++ b/crates/cdk-integration-tests/src/ln_regtest/ln_client/lnd_client.rs @@ -114,17 +114,96 @@ impl LndClient { Ok(balance as u64) } + + pub async fn wait_channel_active_with_peer_attempts( + &self, + peer_id: &str, + max_checks: u32, + ) -> Result<()> { + let peer = hex::decode(peer_id)?; + let mut count = 0; + while count < max_checks { + let channels = self + .client + .lock() + .await + .lightning() + .list_channels(ListChannelsRequest { + active_only: true, + inactive_only: false, + public_only: false, + private_only: false, + peer: peer.clone(), + ..Default::default() + }) + .await? + .into_inner(); + + if !channels.channels.is_empty() { + tracing::info!("LND channel with peer {peer_id} is active"); + return Ok(()); + } + + tracing::warn!("LND channel with peer {peer_id} is not active yet"); + count += 1; + sleep(Duration::from_secs(2)).await; + } + + let channels = self + .client + .lock() + .await + .lightning() + .list_channels(ListChannelsRequest { + active_only: false, + inactive_only: false, + public_only: false, + private_only: false, + peer, + ..Default::default() + }) + .await? + .into_inner(); + let channel_summary = channels + .channels + .iter() + .map(|channel| { + format!( + "remote_pubkey={} active={} capacity={} local_balance={}", + channel.remote_pubkey, channel.active, channel.capacity, channel.local_balance + ) + }) + .collect::>() + .join("; "); + let channel_summary = if channel_summary.is_empty() { + "none".to_string() + } else { + channel_summary + }; + + bail!("Timeout waiting for active LND channel with peer {peer_id}; channels: {channel_summary}") + } + + pub async fn wait_channel_active_with_peer(&self, peer_id: &str) -> Result<()> { + self.wait_channel_active_with_peer_attempts(peer_id, 100) + .await + } } #[async_trait] impl LightningClient for LndClient { async fn get_connect_info(&self) -> Result { let info = self.get_info().await?; - let uri = info.uris.first().unwrap(); + let uri = info + .uris + .first() + .ok_or_else(|| anyhow!("LND did not report a connect URI"))?; - let parsed = parse_uri(uri); + let mut parsed = + parse_uri(uri).ok_or_else(|| anyhow!("Could not parse LND connect URI: {uri}"))?; + parsed.pubkey = info.identity_pubkey; - Ok(parsed.unwrap()) + Ok(parsed) } async fn get_new_onchain_address(&self) -> Result { diff --git a/crates/cdk-integration-tests/tests/bolt12.rs b/crates/cdk-integration-tests/tests/bolt12.rs index 9a5b0b5259..02641fcc98 100644 --- a/crates/cdk-integration-tests/tests/bolt12.rs +++ b/crates/cdk-integration-tests/tests/bolt12.rs @@ -320,20 +320,40 @@ async fn test_regtest_bolt12_melt() -> Result<()> { ) .await?; - let offer = cln_client - .get_bolt12_offer(Some(100_000), true, "hhhhhhhh".to_string()) - .await?; - - let quote = wallet - .melt_quote(PaymentMethod::BOLT12, offer.to_string(), None, None) - .await?; - - let prepared = wallet - .prepare_melt("e.id, std::collections::HashMap::new()) - .await?; - let melt = prepared.confirm().await?; - - assert_eq!(melt.amount(), 100.into()); + let max_attempts = if is_ldk_mint() { 3 } else { 1 }; + let mut attempt = 1; + loop { + let offer = cln_client + .get_bolt12_offer( + Some(100_000), + true, + format!("test_regtest_bolt12_melt_{attempt}"), + ) + .await?; + + let quote = wallet + .melt_quote(PaymentMethod::BOLT12, offer.to_string(), None, None) + .await?; + + let prepared = wallet + .prepare_melt("e.id, std::collections::HashMap::new()) + .await?; + + match prepared.confirm().await { + Ok(melt) => { + assert_eq!(melt.amount(), 100.into()); + break; + } + Err(err) if attempt < max_attempts => { + tracing::warn!( + "BOLT12 melt attempt {attempt}/{max_attempts} failed; retrying with a fresh offer: {err}" + ); + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + attempt += 1; + } + Err(err) => return Err(err.into()), + } + } Ok(()) } diff --git a/misc/fake_itests.sh b/misc/fake_itests.sh index 8c1580c3ff..bfd3467710 100755 --- a/misc/fake_itests.sh +++ b/misc/fake_itests.sh @@ -176,7 +176,7 @@ fi # Run second test only if the first one succeeded echo "Running happy_path_mint_wallet test" -run_test happy_path_mint_wallet -- --nocapture +run_test happy_path_mint_wallet -- --nocapture --test-threads 1 status2=$? # Exit if the second test failed diff --git a/misc/itests.sh b/misc/itests.sh index 9cba6ef586..a6e83a5a28 100755 --- a/misc/itests.sh +++ b/misc/itests.sh @@ -219,7 +219,13 @@ while [ $wait_count -lt $max_wait ]; do fi if ! ps -p "$CDK_REGTEST_PID" > /dev/null 2>&1; then echo "ERROR: Regtest mints process exited before readiness file was created" - exit 1 + if wait "$CDK_REGTEST_PID"; then + exit 1 + else + status=$? + echo "Regtest mints exited with status $status" + exit "$status" + fi fi wait_count=$((wait_count + 1)) sleep 1 @@ -241,7 +247,7 @@ if [[ "$SUITE" == "all" || "$SUITE" == "ln" ]]; then fi echo "Running happy_path_mint_wallet test with CLN mint and CLN client" - run_test happy_path_mint_wallet + run_test happy_path_mint_wallet -- --test-threads 1 if [ $? -ne 0 ]; then echo "happy_path_mint_wallet with cln mint test failed, exiting" exit 1 @@ -292,7 +298,7 @@ if [[ "$SUITE" == "all" || "$SUITE" == "ln" ]]; then fi echo "Running happy_path_mint_wallet test with LND mint and LND client" - run_test happy_path_mint_wallet + run_test happy_path_mint_wallet -- --test-threads 1 if [ $? -ne 0 ]; then echo "happy_path_mint_wallet test with LND mint failed, exiting" exit 1 @@ -347,7 +353,7 @@ fi if [[ "$SUITE" == "all" || "$SUITE" == "ln" ]]; then echo "Running happy_path_mint_wallet test with LDK mint and CLN client" export CDK_TEST_LIGHTNING_CLIENT="cln" # Use CLN client for LDK tests - run_test happy_path_mint_wallet + run_test happy_path_mint_wallet -- --test-threads 1 if [ $? -ne 0 ]; then echo "happy_path_mint_wallet test with LDK mint failed, exiting" exit 1 diff --git a/misc/mintd_payment_processor.sh b/misc/mintd_payment_processor.sh index b4c848bd02..01b8eb20d1 100755 --- a/misc/mintd_payment_processor.sh +++ b/misc/mintd_payment_processor.sh @@ -255,7 +255,7 @@ while true; do done -run_test happy_path_mint_wallet +run_test happy_path_mint_wallet -- --test-threads 1 # Capture the exit status of cargo test test_status=$?