fix(lightning): channel open raced async peer connect, errors were swallowed

- 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 <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-07-10 15:04:13 +01:00
parent e56c5af3ba
commit c141a733b4
3 changed files with 51 additions and 9 deletions

View File

@ -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?; 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()) { if let Some(addr) = params.get("address").and_then(|v| v.as_str()) {
// Validate peer address format (host:port) // Validate peer address format (host:port)
if addr.len() > 256 || addr.contains('\0') || addr.contains(' ') { if addr.len() > 256 || addr.contains('\0') || addr.contains(' ') {
@ -210,19 +225,35 @@ impl RpcHandler {
} }
let connect_body = serde_json::json!({ let connect_body = serde_json::json!({
"addr": { "pubkey": pubkey, "host": addr }, "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")) .post(format!("{LND_REST_BASE_URL}/v1/peers"))
.header("Grpc-Metadata-macaroon", &macaroon_hex) .header("Grpc-Metadata-macaroon", &macaroon_hex)
.json(&connect_body) .json(&connect_body)
.timeout(std::time::Duration::from_secs(35))
.send() .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!({ let open_body = serde_json::json!({
"node_pubkey_string": pubkey, "node_pubkey_string": pubkey,
"local_funding_amount": amount.to_string(), "local_funding_amount": amount.to_string(),
"private": private,
}); });
let resp = client let resp = client

View File

@ -61,6 +61,9 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
"Session", "Session",
"Failed to pull", "Failed to pull",
"Failed to start", "Failed to start",
"Failed to open channel",
"Failed to close channel",
"Failed to connect to peer",
"Container", "Container",
"Image", "Image",
"Bitcoin address", "Bitcoin address",

View File

@ -155,6 +155,13 @@
/> />
<p class="text-white/40 text-xs mt-1">Minimum 20,000 sats</p> <p class="text-white/40 text-xs mt-1">Minimum 20,000 sats</p>
</div> </div>
<div>
<label class="flex items-center gap-2 cursor-pointer">
<input v-model="openForm.private" type="checkbox" class="accent-blue-500" />
<span class="text-white/60 text-sm">Private channel (unannounced)</span>
</label>
<p class="text-white/40 text-xs mt-1">Some nodes (e.g. LSPs like Olympus) only accept unannounced channels</p>
</div>
</div> </div>
<div v-if="openError" class="mt-3 alert-error"> <div v-if="openError" class="mt-3 alert-error">
@ -221,7 +228,7 @@ const channels = ref<Channel[]>([])
const summary = ref({ total_inbound: 0, total_outbound: 0 }) const summary = ref({ total_inbound: 0, total_outbound: 0 })
const showOpenModal = ref(false) const showOpenModal = ref(false)
const openForm = ref({ peerUri: '', amount: 100000 }) const openForm = ref({ peerUri: '', amount: 100000, private: false })
const openingChannel = ref(false) const openingChannel = ref(false)
const openError = ref<string | null>(null) const openError = ref<string | null>(null)
@ -279,11 +286,12 @@ async function openChannel() {
try { try {
await rpcClient.call({ await rpcClient.call({
method: 'lnd.openchannel', method: 'lnd.openchannel',
params: { pubkey, address, amount: openForm.value.amount }, params: { pubkey, address, amount: openForm.value.amount, private: openForm.value.private },
timeout: 30000, // Server may wait up to 35s for a synchronous peer connect before opening
timeout: 60000,
}) })
showOpenModal.value = false showOpenModal.value = false
openForm.value = { peerUri: '', amount: 100000 } openForm.value = { peerUri: '', amount: 100000, private: false }
await loadChannels() await loadChannels()
} catch (err: unknown) { } catch (err: unknown) {
openError.value = err instanceof Error ? err.message : 'Failed to open channel' openError.value = err instanceof Error ? err.message : 'Failed to open channel'