From 4132d25198c9f27bee1b62b57eb410e14eb2351c Mon Sep 17 00:00:00 2001 From: karliatto Date: Thu, 22 Oct 2026 16:50:10 +0200 Subject: [PATCH] Add --wait to open-channel in the CLI `open-channel` returned as soon as the open was initiated, so scripts had to poll list-channels to learn whether the counterparty accepted or rejected the channel. With `--wait`, the CLI subscribes to the event stream before calling open_channel, since the stream is live-only, and waits for a ChannelStateChanged event for the returned user_channel_id. Pending (or Ready, if Pending was missed) counts as success and prints the channel_id and funding_txo. OpenFailed or Closed counts as failure and prints the reason from the server. It does not wait for the funding transaction to confirm. `--wait-timeout ` bounds the wait. On timeout the CLI prints the current entry for the channel from list_channels, if there is one. Exit codes are 0 for pending, 1 for a request or event stream error, 2 for a failed open and 3 for a timeout, matching `pay --wait`. AI tools were used to help write this change. Co-Authored-By: Claude Opus 5.5 --- ldk-server-cli/src/main.rs | 51 ++++++-- ldk-server-cli/src/open_channel_wait.rs | 162 ++++++++++++++++++++++++ ldk-server-cli/src/pay_wait.rs | 4 +- 3 files changed, 202 insertions(+), 15 deletions(-) create mode 100644 ldk-server-cli/src/open_channel_wait.rs diff --git a/ldk-server-cli/src/main.rs b/ldk-server-cli/src/main.rs index 0c912721..3b162272 100644 --- a/ldk-server-cli/src/main.rs +++ b/ldk-server-cli/src/main.rs @@ -78,6 +78,7 @@ use types::{ CliPaginatedResponse, Preimage, }; +mod open_channel_wait; mod pay_wait; mod types; @@ -602,6 +603,24 @@ enum Commands { help = "The difference in the CLTV value between incoming HTLCs and an outbound HTLC forwarded over the channel. This can be updated by using update-channel-config." )] cltv_expiry_delta: Option, + /// Wait for the counterparty to accept or reject the channel. + /// + /// With no `--wait-timeout`, waits until the funding transaction is broadcast or the open + /// fails. Does not wait for the funding transaction to confirm. + #[arg( + long, + help = "Wait until the funding transaction is broadcast or the open fails. Does not wait for confirmations" + )] + wait: bool, + /// Optional timeout in seconds for `--wait`. Omit to wait indefinitely. + #[arg( + long, + value_name = "SECS", + requires = "wait", + value_parser = clap::value_parser!(u64).range(1..), + help = "Seconds to wait when --wait is set. Omit to wait until the open resolves (minimum: 1)" + )] + wait_timeout: Option, }, #[command( about = "Increase the channel balance by the given amount, funds will come from the node's on-chain wallet" @@ -1352,6 +1371,8 @@ async fn main() { forwarding_fee_proportional_millionths, forwarding_fee_base_msat, cltv_expiry_delta, + wait, + wait_timeout, } => { let amount = match channel_amount.to_sat().unwrap_or_else(|e| handle_error_msg(e)) { Some(amount_sats) => open_channel_request::Amount::ChannelAmountSats(amount_sats), @@ -1370,19 +1391,23 @@ async fn main() { )); } - handle_response_result::<_, OpenChannelResponse>( - client - .open_channel(OpenChannelRequest { - node_pubkey, - address, - amount: Some(amount), - push_to_counterparty_msat, - channel_config, - announce_channel, - disable_counterparty_reserve, - }) - .await, - ); + let request = OpenChannelRequest { + node_pubkey, + address, + amount: Some(amount), + push_to_counterparty_msat, + channel_config, + announce_channel, + disable_counterparty_reserve, + }; + if wait { + let timeout = wait_timeout.map(std::time::Duration::from_secs); + open_channel_wait::open_channel_and_wait(&client, request, timeout).await; + } else { + handle_response_result::<_, OpenChannelResponse>( + client.open_channel(request).await, + ); + } }, Commands::SpliceIn { user_channel_id, counterparty_node_id, splice_amount } => { let amount = match splice_amount.to_sat().unwrap_or_else(|e| handle_error_msg(e)) { diff --git a/ldk-server-cli/src/open_channel_wait.rs b/ldk-server-cli/src/open_channel_wait.rs new file mode 100644 index 00000000..87927668 --- /dev/null +++ b/ldk-server-cli/src/open_channel_wait.rs @@ -0,0 +1,162 @@ +// 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 std::time::Duration; + +use ldk_server_client::client::{EventStream, LdkServerClient}; +use ldk_server_client::error::LdkServerError; +use ldk_server_client::error::LdkServerErrorCode::InternalError; +use ldk_server_client::ldk_server_grpc::api::{ListChannelsRequest, OpenChannelRequest}; +use ldk_server_client::ldk_server_grpc::events::event_envelope::Event; +use ldk_server_client::ldk_server_grpc::events::{ChannelState, ChannelStateChanged}; +use ldk_server_client::ldk_server_grpc::types::Channel; +use serde_json::json; + +use crate::{handle_error, print_response, sanitize_for_terminal}; + +const EXIT_OPEN_FAILED: i32 = 2; +const EXIT_OPEN_TIMEOUT: i32 = 3; + +/// Subscribe before opening, then wait until the funding transaction is broadcast or the open +/// fails. +/// +/// The event stream is live-only, so the subscription is opened before `open_channel`. A dropped +/// stream is an error; it is not replayed. `timeout` of `None` waits until the open resolves. +/// This does not wait for the funding transaction to confirm. +/// Exit codes: 0 pending, 1 request or event stream error, 2 failed, 3 timed out. +pub(crate) async fn open_channel_and_wait( + client: &LdkServerClient, request: OpenChannelRequest, timeout: Option, +) { + let mut events = client.subscribe_events().await.map_err(handle_error).unwrap(); + + let response = client.open_channel(request).await.map_err(handle_error).unwrap(); + let user_channel_id = response.user_channel_id; + + let waiting_for = match timeout { + Some(timeout) => format!("waiting up to {}s for the counterparty", timeout.as_secs()), + None => "waiting for the counterparty".to_string(), + }; + eprintln!( + "{}", + sanitize_for_terminal(format!("Channel open initiated: {user_channel_id} ({waiting_for})")) + ); + + let outcome = wait_for_outcome(&mut events, &user_channel_id, timeout) + .await + .map_err(handle_error) + .unwrap(); + + match outcome { + Outcome::Pending(event) => print_response(&json!({ + "user_channel_id": event.user_channel_id, + "channel_id": event.channel_id, + "funding_txo": event.funding_txo, + })), + Outcome::Failed(event) => { + let reason = event + .reason + .map(|reason| reason.message) + .filter(|message| !message.is_empty()) + .unwrap_or_else(|| "no reason given".to_string()); + eprintln!( + "{}", + sanitize_for_terminal(format!("Channel open {user_channel_id} failed: {reason}")) + ); + std::process::exit(EXIT_OPEN_FAILED); + }, + Outcome::TimedOut => { + let secs = timeout.unwrap_or_default().as_secs(); + eprintln!( + "{}", + sanitize_for_terminal(format!( + "Timed out after {secs}s waiting for channel open {user_channel_id}" + )) + ); + match fetch_channel(client, &user_channel_id).await { + Some(channel) => print_response(&channel), + None => eprintln!( + "{}", + sanitize_for_terminal(format!( + "Channel {user_channel_id} is not listed. The open may have failed; check the server log." + )) + ), + } + std::process::exit(EXIT_OPEN_TIMEOUT); + }, + } +} + +enum Outcome { + /// The funding transaction was broadcast. + Pending(ChannelStateChanged), + Failed(ChannelStateChanged), + TimedOut, +} + +async fn wait_for_outcome( + events: &mut EventStream, user_channel_id: &str, timeout: Option, +) -> Result { + let deadline = timeout.map(|timeout| tokio::time::Instant::now() + timeout); + + loop { + let next = events.next_message(); + let message = match deadline { + Some(deadline) => match tokio::time::timeout_at(deadline, next).await { + Ok(message) => message, + Err(_) => return Ok(Outcome::TimedOut), + }, + None => next.await, + }; + + let envelope = match message { + Some(Ok(envelope)) => envelope, + Some(Err(e)) => return Err(e), + None => { + return Err(LdkServerError::new( + InternalError, + format!("event stream ended before channel open {user_channel_id} resolved"), + )) + }, + }; + + if let Some(Event::ChannelStateChanged(event)) = envelope.event { + if let Some(outcome) = classify(event, user_channel_id) { + return Ok(outcome); + } + } + } +} + +/// `None` means the event is for another channel or is not a final state for the open. +fn classify(event: ChannelStateChanged, user_channel_id: &str) -> Option { + if event.user_channel_id != user_channel_id { + return None; + } + match event.state() { + // `Ready` only arrives first if the `Pending` event was missed. + ChannelState::Pending | ChannelState::Ready => Some(Outcome::Pending(event)), + ChannelState::OpenFailed | ChannelState::Closed => Some(Outcome::Failed(event)), + ChannelState::Unspecified => None, + } +} + +async fn fetch_channel(client: &LdkServerClient, user_channel_id: &str) -> Option { + match client.list_channels(ListChannelsRequest {}).await { + Ok(response) => { + response.channels.into_iter().find(|channel| channel.user_channel_id == user_channel_id) + }, + Err(e) => { + eprintln!( + "{}", + sanitize_for_terminal(format!("Warning: failed to list channels: {}", e.message)) + ); + None + }, + } +} diff --git a/ldk-server-cli/src/pay_wait.rs b/ldk-server-cli/src/pay_wait.rs index f5032a03..62a89e89 100644 --- a/ldk-server-cli/src/pay_wait.rs +++ b/ldk-server-cli/src/pay_wait.rs @@ -12,9 +12,9 @@ use std::time::Duration; use ldk_server_client::client::{EventStream, LdkServerClient}; use ldk_server_client::error::LdkServerError; use ldk_server_client::error::LdkServerErrorCode::InternalError; -use ldk_server_client::ldk_server_grpc::api::unified_send_response; use ldk_server_client::ldk_server_grpc::api::{ - GetPaymentDetailsRequest, GetPaymentDetailsResponse, UnifiedSendRequest, UnifiedSendResponse, + unified_send_response, GetPaymentDetailsRequest, GetPaymentDetailsResponse, UnifiedSendRequest, + UnifiedSendResponse, }; use ldk_server_client::ldk_server_grpc::events::event_envelope::Event;