Merge feat/mesh-archy-command: lightning channel controls + open-race fix

Channel open fee control, channels tab in wallet settings, mempool tx
link, and the channel-open async peer-connect race fix. Conflict in
LightningChannels.vue resolved to the branch's extracted
LightningChannelsPanel component, with main's Teleport-to-body modal
fixes and glass-button-warning class (8256fde1) ported into the panel
so they aren't lost. lnd tests 16/16, LightningChannels vitest green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-07-10 10:42:54 -04:00
co-authored by Claude Fable 5
7 changed files with 521 additions and 315 deletions
+67 -6
View File
@@ -198,11 +198,49 @@ impl RpcHandler {
));
}
info!(peer = pubkey, amount = amount, "Opening Lightning channel");
let private = params
.get("private")
.and_then(|v| v.as_bool())
.unwrap_or(false);
// Fee control: either a confirmation target or an explicit fee rate
let target_conf = params.get("target_conf").and_then(|v| v.as_i64());
let sat_per_vbyte = params.get("sat_per_vbyte").and_then(|v| v.as_i64());
if target_conf.is_some() && sat_per_vbyte.is_some() {
return Err(anyhow::anyhow!(
"Invalid fee parameters: specify either target_conf or sat_per_vbyte, not both"
));
}
if let Some(tc) = target_conf {
if !(1..=1008).contains(&tc) {
return Err(anyhow::anyhow!(
"Invalid target_conf: must be between 1 and 1008 blocks"
));
}
}
if let Some(rate) = sat_per_vbyte {
if !(1..=5000).contains(&rate) {
return Err(anyhow::anyhow!(
"Invalid sat_per_vbyte: must be between 1 and 5000"
));
}
}
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,20 +248,43 @@ 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!({
let mut open_body = serde_json::json!({
"node_pubkey_string": pubkey,
"local_funding_amount": amount.to_string(),
"private": private,
});
if let Some(tc) = target_conf {
open_body["target_conf"] = serde_json::json!(tc);
}
if let Some(rate) = sat_per_vbyte {
// LND REST encodes uint64 as a JSON string
open_body["sat_per_vbyte"] = serde_json::json!(rate.to_string());
}
let resp = client
.post(format!("{LND_REST_BASE_URL}/v1/channels"))
@@ -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",