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?;
// 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

View File

@ -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",

View File

@ -155,6 +155,13 @@
/>
<p class="text-white/40 text-xs mt-1">Minimum 20,000 sats</p>
</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 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 showOpenModal = ref(false)
const openForm = ref({ peerUri: '', amount: 100000 })
const openForm = ref({ peerUri: '', amount: 100000, private: false })
const openingChannel = ref(false)
const openError = ref<string | null>(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'