diff --git a/core/archipelago/src/api/rpc/lnd/channels.rs b/core/archipelago/src/api/rpc/lnd/channels.rs index 80648fd9..c5e58ee4 100644 --- a/core/archipelago/src/api/rpc/lnd/channels.rs +++ b/core/archipelago/src/api/rpc/lnd/channels.rs @@ -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")) diff --git a/core/archipelago/src/api/rpc/middleware.rs b/core/archipelago/src/api/rpc/middleware.rs index da078543..5233d8ff 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/public/assets/img/bg-web5.jpg b/neode-ui/public/assets/img/bg-web5.jpg index 537c3a3d..190e46b4 100644 Binary files a/neode-ui/public/assets/img/bg-web5.jpg and b/neode-ui/public/assets/img/bg-web5.jpg differ diff --git a/neode-ui/src/components/LightningChannelsPanel.vue b/neode-ui/src/components/LightningChannelsPanel.vue new file mode 100644 index 00000000..7adb8057 --- /dev/null +++ b/neode-ui/src/components/LightningChannelsPanel.vue @@ -0,0 +1,433 @@ + + + diff --git a/neode-ui/src/components/WalletSettingsModal.vue b/neode-ui/src/components/WalletSettingsModal.vue index a9559b46..38f5a666 100644 --- a/neode-ui/src/components/WalletSettingsModal.vue +++ b/neode-ui/src/components/WalletSettingsModal.vue @@ -11,6 +11,17 @@ >{{ tab.label }} + +
+

+ Lightning channels on this node. Open a channel to a peer to send and receive Lightning payments. +

+ +
+ +
+
+

@@ -145,6 +156,7 @@ import { ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import { rpcClient } from '@/api/rpc-client' import BaseModal from '@/components/BaseModal.vue' +import LightningChannelsPanel from '@/components/LightningChannelsPanel.vue' const { t } = useI18n() @@ -152,10 +164,11 @@ const props = defineProps<{ show: boolean }>() const emit = defineEmits<{ close: []; changed: [] }>() const tabs = [ + { key: 'channels' as const, label: 'Channels' }, { key: 'cashu' as const, label: 'Cashu Mints' }, { key: 'fedimint' as const, label: 'Fedimint Federations' }, ] -const activeTab = ref<'cashu' | 'fedimint'>('cashu') +const activeTab = ref<'channels' | 'cashu' | 'fedimint'>('channels') // Backed by wallet.fedimint-list / -join / -leave (fedimint-clientd HTTP bridge). // Join degrades gracefully with a clear error if the Fedimint client app isn't installed. diff --git a/neode-ui/src/views/apps/LightningChannels.vue b/neode-ui/src/views/apps/LightningChannels.vue index 2a55d735..2e427ccb 100644 --- a/neode-ui/src/views/apps/LightningChannels.vue +++ b/neode-ui/src/views/apps/LightningChannels.vue @@ -10,317 +10,13 @@

Lightning Channels

- -
-
-

Total Outbound

-

{{ formatSats(summary.total_outbound) }}

-
-
-

Total Inbound

-

{{ formatSats(summary.total_inbound) }}

-
-
-

Channels

-

{{ channels.length }}

-
-
- - -
- -
- - - -
- - - - -

Loading channels...

-
- - -
-

{{ error }}

- -
- - -
- - - -

No channels yet

-

Open a channel to start sending and receiving Lightning payments.

-
- - -
-
- - - - - Refreshing channels... -
-
- {{ error }} -
-
-
-
- - {{ ch.status.replace('_', ' ') }} -
- -
- - -

- {{ ch.remote_pubkey }} -

- - -
-
- Local: {{ formatSats(ch.local_balance) }} - Remote: {{ formatSats(ch.remote_balance) }} -
-
-
-
-
-

- Capacity: {{ formatSats(ch.capacity) }} -

-
-
-
-
- - - -
-
-

Open Channel

- -
-
- - -

Format: pubkey@host:port

-
-
- - -

Minimum 20,000 sats

-
-
- -
-

{{ openError }}

-
- -
- - -
-
-
-
- - - -
-
-

Close Channel?

-

This will cooperatively close the channel with peer {{ closeTarget.remote_pubkey.slice(0, 16) }}...

-
-

{{ closeError }}

-
-
- - -
-
-
-
+
diff --git a/neode-ui/src/views/apps/__tests__/LightningChannels.test.ts b/neode-ui/src/views/apps/__tests__/LightningChannels.test.ts index b188a810..53adcc8a 100644 --- a/neode-ui/src/views/apps/__tests__/LightningChannels.test.ts +++ b/neode-ui/src/views/apps/__tests__/LightningChannels.test.ts @@ -1,10 +1,10 @@ import { flushPromises, mount } from '@vue/test-utils' import { describe, expect, it, vi } from 'vitest' -import LightningChannels from '../LightningChannels.vue' +import LightningChannels from '@/components/LightningChannelsPanel.vue' import { rpcClient } from '@/api/rpc-client' vi.mock('vue-router', () => ({ - useRouter: () => ({ replace: vi.fn() }), + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), })) vi.mock('@/api/rpc-client', () => ({