From c141a733b40ed46214d95baccd272e55e6317546 Mon Sep 17 00:00:00 2001 From: Dorian Date: Fri, 10 Jul 2026 15:04:13 +0100 Subject: [PATCH] fix(lightning): channel open raced async peer connect, errors were swallowed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Connect to peer synchronously (perm=false, 30s timeout) and check the result before opening the channel; previously the connect was fired with perm=true (queued in background, returns immediately) and its result ignored, so the open failed with 'peer is not online' - Let LND channel/peer errors through the error sanitizer so the UI shows the real cause instead of 'Check server logs' - Add private (unannounced) channel option to backend and UI — some peers (e.g. Olympus/ZEUS LSP) reject public channel opens Co-Authored-By: Claude Fable 5 --- core/archipelago/src/api/rpc/lnd/channels.rs | 41 ++++++++++++++++--- core/archipelago/src/api/rpc/middleware.rs | 3 ++ neode-ui/src/views/apps/LightningChannels.vue | 16 ++++++-- 3 files changed, 51 insertions(+), 9 deletions(-) diff --git a/core/archipelago/src/api/rpc/lnd/channels.rs b/core/archipelago/src/api/rpc/lnd/channels.rs index 80648fd9..d1603850 100644 --- a/core/archipelago/src/api/rpc/lnd/channels.rs +++ b/core/archipelago/src/api/rpc/lnd/channels.rs @@ -198,11 +198,26 @@ impl RpcHandler { )); } - info!(peer = pubkey, amount = amount, "Opening Lightning channel"); + let private = params + .get("private") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + info!( + peer = pubkey, + amount = amount, + private = private, + "Opening Lightning channel" + ); let (client, macaroon_hex) = self.lnd_client().await?; - // First connect to the peer if an address is provided + // First connect to the peer if an address is provided. + // perm=false makes LND connect synchronously, so the peer is online + // (or we get a real error) before we attempt the channel open. + // perm=true queues the connection in the background and returns + // immediately, which makes the subsequent open race and fail with + // "peer is not online". if let Some(addr) = params.get("address").and_then(|v| v.as_str()) { // Validate peer address format (host:port) if addr.len() > 256 || addr.contains('\0') || addr.contains(' ') { @@ -210,19 +225,35 @@ impl RpcHandler { } let connect_body = serde_json::json!({ "addr": { "pubkey": pubkey, "host": addr }, - "perm": true + "perm": false, + "timeout": "30" }); - let _ = client + let connect_resp = client .post(format!("{LND_REST_BASE_URL}/v1/peers")) .header("Grpc-Metadata-macaroon", &macaroon_hex) .json(&connect_body) + .timeout(std::time::Duration::from_secs(35)) .send() - .await; + .await + .context("Failed to connect to peer")?; + + if !connect_resp.status().is_success() { + let body: serde_json::Value = connect_resp.json().await.unwrap_or_default(); + let msg = body + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown error"); + // LND returns an error if we already have this peer — that is fine + if !msg.contains("already connected") { + return Err(anyhow::anyhow!("Failed to connect to peer: {}", msg)); + } + } } let open_body = serde_json::json!({ "node_pubkey_string": pubkey, "local_funding_amount": amount.to_string(), + "private": private, }); let resp = client diff --git a/core/archipelago/src/api/rpc/middleware.rs b/core/archipelago/src/api/rpc/middleware.rs index 8cef041e..33939af1 100644 --- a/core/archipelago/src/api/rpc/middleware.rs +++ b/core/archipelago/src/api/rpc/middleware.rs @@ -61,6 +61,9 @@ pub(super) fn sanitize_error_message(msg: &str) -> String { "Session", "Failed to pull", "Failed to start", + "Failed to open channel", + "Failed to close channel", + "Failed to connect to peer", "Container", "Image", "Bitcoin address", diff --git a/neode-ui/src/views/apps/LightningChannels.vue b/neode-ui/src/views/apps/LightningChannels.vue index 6129a276..307272e1 100644 --- a/neode-ui/src/views/apps/LightningChannels.vue +++ b/neode-ui/src/views/apps/LightningChannels.vue @@ -155,6 +155,13 @@ />

Minimum 20,000 sats

+
+ +

Some nodes (e.g. LSPs like Olympus) only accept unannounced channels

+
@@ -221,7 +228,7 @@ const channels = ref([]) const summary = ref({ total_inbound: 0, total_outbound: 0 }) const showOpenModal = ref(false) -const openForm = ref({ peerUri: '', amount: 100000 }) +const openForm = ref({ peerUri: '', amount: 100000, private: false }) const openingChannel = ref(false) const openError = ref(null) @@ -279,11 +286,12 @@ async function openChannel() { try { await rpcClient.call({ method: 'lnd.openchannel', - params: { pubkey, address, amount: openForm.value.amount }, - timeout: 30000, + params: { pubkey, address, amount: openForm.value.amount, private: openForm.value.private }, + // Server may wait up to 35s for a synchronous peer connect before opening + timeout: 60000, }) showOpenModal.value = false - openForm.value = { peerUri: '', amount: 100000 } + openForm.value = { peerUri: '', amount: 100000, private: false } await loadChannels() } catch (err: unknown) { openError.value = err instanceof Error ? err.message : 'Failed to open channel'