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
commit 9b1c090e21
7 changed files with 521 additions and 315 deletions

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?; 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,20 +248,43 @@ 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 mut 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,
}); });
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 let resp = client
.post(format!("{LND_REST_BASE_URL}/v1/channels")) .post(format!("{LND_REST_BASE_URL}/v1/channels"))

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 726 KiB

After

Width:  |  Height:  |  Size: 608 KiB

View File

@ -0,0 +1,433 @@
<template>
<div>
<!-- Liquidity Summary -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6" :class="{ 'md:gap-3 mb-4': compact }">
<div class="glass-card p-4" :class="{ 'p-3 bg-white/5': compact }">
<p class="text-white/60 text-sm mb-1">Total Outbound</p>
<p class="text-white text-xl font-bold" :class="{ 'text-base': compact }">{{ formatSats(summary.total_outbound) }}</p>
</div>
<div class="glass-card p-4" :class="{ 'p-3 bg-white/5': compact }">
<p class="text-white/60 text-sm mb-1">Total Inbound</p>
<p class="text-white text-xl font-bold" :class="{ 'text-base': compact }">{{ formatSats(summary.total_inbound) }}</p>
</div>
<div class="glass-card p-4" :class="{ 'p-3 bg-white/5': compact }">
<p class="text-white/60 text-sm mb-1">Channels</p>
<p class="text-white text-xl font-bold" :class="{ 'text-base': compact }">{{ channels.length }}</p>
</div>
</div>
<!-- Open Channel Button -->
<div class="flex justify-end mb-4">
<button @click="showOpenModal = true" class="glass-button px-4 py-2 rounded-lg text-sm font-medium flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Open Channel
</button>
</div>
<Transition name="content-fade" mode="out-in">
<!-- Loading -->
<div v-if="loading && channels.length === 0" key="loading" class="glass-card p-12 text-center">
<svg class="animate-spin h-8 w-8 text-blue-400 mx-auto mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p class="text-white/70">Loading channels...</p>
</div>
<!-- Error -->
<div v-else-if="error && channels.length === 0" key="error" class="glass-card p-6 text-center">
<p class="text-red-300 mb-4">{{ error }}</p>
<button @click="loadChannels" class="glass-button px-4 py-2 rounded-lg text-sm">Retry</button>
</div>
<!-- No Channels -->
<div v-else-if="channels.length === 0" key="empty" class="glass-card p-8 text-center">
<svg class="w-16 h-16 text-white/20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<p class="text-white/70 mb-2">No channels yet</p>
<p class="text-white/50 text-sm">Open a channel to start sending and receiving Lightning payments.</p>
</div>
<!-- Channel List -->
<div v-else key="channels" class="space-y-3">
<div v-if="loading" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Refreshing channels...
</div>
<div v-else-if="error" class="p-3 rounded-lg border border-red-400/20 bg-red-500/10 text-red-200/85 text-sm">
{{ error }}
</div>
<div
v-for="ch in channels"
:key="ch.chan_id || ch.channel_point"
class="glass-card p-4"
:class="{ 'bg-white/5': compact }"
>
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<span
class="w-2 h-2 rounded-full"
:class="{
'bg-green-400': ch.status === 'active',
'bg-yellow-400': ch.status === 'pending_open',
'bg-red-400': ch.status === 'inactive',
}"
></span>
<span class="text-white/80 text-sm font-medium capitalize">{{ ch.status.replace('_', ' ') }}</span>
</div>
<button
v-if="ch.status !== 'pending_open'"
@click="confirmClose(ch)"
class="text-red-400/70 hover:text-red-400 text-xs transition-colors"
>
Close
</button>
</div>
<!-- Peer -->
<p class="text-white/50 text-xs font-mono mb-3 truncate" :title="ch.remote_pubkey">
{{ ch.remote_pubkey }}
</p>
<!-- Capacity Bar -->
<div class="mb-2">
<div class="flex justify-between text-xs text-white/60 mb-1">
<span>Local: {{ formatSats(ch.local_balance) }}</span>
<span>Remote: {{ formatSats(ch.remote_balance) }}</span>
</div>
<div class="h-2 bg-white/10 rounded-full overflow-hidden flex">
<div
class="bg-blue-400 h-full transition-all"
:style="{ width: capacityPercent(ch.local_balance, ch.capacity) + '%' }"
></div>
<div
class="bg-orange-400 h-full transition-all"
:style="{ width: capacityPercent(ch.remote_balance, ch.capacity) + '%' }"
></div>
</div>
<p class="text-white/40 text-xs mt-1 text-center">
Capacity: {{ formatSats(ch.capacity) }}
</p>
</div>
<!-- Funding tx -->
<div v-if="fundingTxid(ch)" class="flex justify-end">
<button
@click="openInMempool(fundingTxid(ch))"
class="flex items-center gap-1 text-blue-400/70 hover:text-blue-400 text-xs transition-colors"
:title="fundingTxid(ch)"
>
Funding tx in Mempool
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</button>
</div>
</div>
</div>
</Transition>
<!-- Open Channel Modal -->
<Teleport to="body">
<div v-if="showOpenModal" class="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showOpenModal = false">
<div class="glass-card p-6 w-full max-w-md mx-4 max-h-[90vh] overflow-y-auto">
<h2 class="text-lg font-bold text-white mb-4">Open Channel</h2>
<div class="space-y-4">
<div>
<label class="text-white/60 text-sm block mb-1">Peer URI</label>
<input
v-model="openForm.peerUri"
type="text"
placeholder="pubkey@host:port"
class="w-full input-glass"
/>
<p class="text-white/40 text-xs mt-1">Format: pubkey@host:port</p>
</div>
<div>
<label class="text-white/60 text-sm block mb-1">Amount (sats)</label>
<input
v-model.number="openForm.amount"
type="number"
min="20000"
placeholder="100000"
class="w-full input-glass"
/>
<p class="text-white/40 text-xs mt-1">Minimum 20,000 sats</p>
</div>
<!-- Fee selection -->
<div>
<label class="text-white/60 text-sm block mb-1">Fee</label>
<div class="flex gap-1 p-1 bg-white/5 rounded-lg">
<button
v-for="preset in feePresets"
:key="preset.key"
@click="openForm.feePreset = preset.key"
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors"
:class="openForm.feePreset === preset.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>{{ preset.label }}</button>
</div>
<p v-if="openForm.feePreset !== 'custom'" class="text-white/40 text-xs mt-1">
{{ feePresets.find(p => p.key === openForm.feePreset)?.hint }}
</p>
<div v-else class="grid grid-cols-2 gap-3 mt-2">
<div>
<label class="text-white/60 text-xs block mb-1">Target confirmations</label>
<input
v-model.number="openForm.customConfTarget"
type="number"
min="1"
max="1008"
placeholder="6"
class="w-full input-glass"
/>
</div>
<div>
<label class="text-white/60 text-xs block mb-1">Sats per vByte</label>
<input
v-model.number="openForm.customSatPerVbyte"
type="number"
min="1"
max="5000"
placeholder="—"
class="w-full input-glass"
/>
</div>
<p class="text-white/40 text-xs col-span-2">Set one sats per vByte takes precedence when both are set</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 v-if="openError" class="mt-3 alert-error">
<p class="text-xs">{{ openError }}</p>
</div>
<div class="flex gap-3 mt-6">
<button @click="showOpenModal = false" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
<button
@click="openChannel"
:disabled="openingChannel"
class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium"
>
{{ openingChannel ? 'Opening...' : 'Open Channel' }}
</button>
</div>
</div>
</div>
</Teleport>
<!-- Close Confirmation Modal -->
<Teleport to="body">
<div v-if="closeTarget" class="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeTarget = null">
<div class="glass-card p-6 w-full max-w-sm mx-4">
<h2 class="text-lg font-bold text-white mb-2">Close Channel?</h2>
<p class="text-white/60 text-sm mb-4">This will cooperatively close the channel with peer {{ closeTarget.remote_pubkey.slice(0, 16) }}...</p>
<div v-if="closeError" class="mb-3 alert-error">
<p class="text-xs">{{ closeError }}</p>
</div>
<div class="flex gap-3">
<button @click="closeTarget = null" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
<button
@click="closeChannel"
:disabled="closingChannel"
class="flex-1 glass-button glass-button-danger px-4 py-2 rounded-lg text-sm font-medium"
>
{{ closingChannel ? 'Closing...' : 'Close' }}
</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
defineProps<{ compact?: boolean }>()
const router = useRouter()
interface Channel {
chan_id: string
remote_pubkey: string
capacity: number
local_balance: number
remote_balance: number
active: boolean
status: string
channel_point: string
}
type FeePreset = 'standard' | 'medium' | 'fast' | 'custom'
const feePresets: { key: FeePreset; label: string; hint?: string; confTarget?: number }[] = [
{ key: 'standard', label: 'Standard', hint: 'Confirms within ~6 blocks (about an hour)', confTarget: 6 },
{ key: 'medium', label: 'Medium', hint: 'Confirms within ~3 blocks (about 30 minutes)', confTarget: 3 },
{ key: 'fast', label: 'Fast', hint: 'Targets the next block', confTarget: 1 },
{ key: 'custom', label: 'Custom' },
]
const loading = ref(true)
const error = ref<string | null>(null)
const channels = ref<Channel[]>([])
const summary = ref({ total_inbound: 0, total_outbound: 0 })
const showOpenModal = ref(false)
const defaultOpenForm = () => ({
peerUri: '',
amount: 100000,
private: false,
feePreset: 'standard' as FeePreset,
customConfTarget: null as number | null,
customSatPerVbyte: null as number | null,
})
const openForm = ref(defaultOpenForm())
const openingChannel = ref(false)
const openError = ref<string | null>(null)
const closeTarget = ref<Channel | null>(null)
const closingChannel = ref(false)
const closeError = ref<string | null>(null)
function formatSats(sats: number): string {
if (sats >= 100_000_000) return `${(sats / 100_000_000).toFixed(2)} BTC`
if (sats >= 1_000_000) return `${(sats / 1_000_000).toFixed(1)}M sats`
if (sats >= 1_000) return `${(sats / 1_000).toFixed(1)}k sats`
return `${sats} sats`
}
function fundingTxid(ch: Channel): string {
const txid = ch.channel_point.split(':')[0] || ''
return /^[0-9a-fA-F]{64}$/.test(txid) ? txid : ''
}
function openInMempool(txid: string) {
if (!txid) return
router.push({ name: 'app-session', params: { appId: 'mempool' }, query: { path: `/tx/${txid}` } })
}
function capacityPercent(amount: number, capacity: number): number {
if (capacity <= 0) return 0
return Math.round((amount / capacity) * 100)
}
async function loadChannels() {
const hadChannels = channels.value.length > 0
loading.value = true
error.value = null
try {
const result = await rpcClient.call<{ channels: Channel[]; total_inbound: number; total_outbound: number }>({
method: 'lnd.listchannels',
timeout: 15000,
})
channels.value = result.channels || []
summary.value = {
total_inbound: result.total_inbound || 0,
total_outbound: result.total_outbound || 0,
}
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to load channels'
if (!hadChannels) channels.value = []
} finally {
loading.value = false
}
}
function feeParams(): { target_conf?: number; sat_per_vbyte?: number } | null {
const form = openForm.value
if (form.feePreset !== 'custom') {
return { target_conf: feePresets.find(p => p.key === form.feePreset)?.confTarget ?? 6 }
}
const rate = form.customSatPerVbyte
const conf = form.customConfTarget
if (rate != null && rate !== 0) {
if (rate < 1 || rate > 5000) { openError.value = 'Sats per vByte must be between 1 and 5000'; return null }
return { sat_per_vbyte: Math.floor(rate) }
}
if (conf != null && conf !== 0) {
if (conf < 1 || conf > 1008) { openError.value = 'Target confirmations must be between 1 and 1008'; return null }
return { target_conf: Math.floor(conf) }
}
openError.value = 'Custom fee requires target confirmations or sats per vByte'
return null
}
async function openChannel() {
if (openingChannel.value) return
openError.value = null
const uri = openForm.value.peerUri.trim()
if (!uri) { openError.value = 'Peer URI is required'; return }
if (openForm.value.amount < 20000) { openError.value = 'Minimum 20,000 sats'; return }
const fee = feeParams()
if (!fee) return
const parts = uri.split('@')
const pubkey = parts[0]
const address = parts[1] || undefined
openingChannel.value = true
try {
await rpcClient.call({
method: 'lnd.openchannel',
params: { pubkey, address, amount: openForm.value.amount, private: openForm.value.private, ...fee },
// Server may wait up to 35s for a synchronous peer connect before opening
timeout: 60000,
})
showOpenModal.value = false
openForm.value = defaultOpenForm()
await loadChannels()
} catch (err: unknown) {
openError.value = err instanceof Error ? err.message : 'Failed to open channel'
} finally {
openingChannel.value = false
}
}
function confirmClose(ch: Channel) {
closeTarget.value = ch
closeError.value = null
}
async function closeChannel() {
if (closingChannel.value || !closeTarget.value) return
closeError.value = null
closingChannel.value = true
try {
await rpcClient.call({
method: 'lnd.closechannel',
params: { channel_point: closeTarget.value.channel_point },
timeout: 30000,
})
closeTarget.value = null
await loadChannels()
} catch (err: unknown) {
closeError.value = err instanceof Error ? err.message : 'Failed to close channel'
} finally {
closingChannel.value = false
}
}
onMounted(loadChannels)
defineExpose({ channels, loadChannels })
</script>

View File

@ -11,6 +11,17 @@
>{{ tab.label }}</button> >{{ tab.label }}</button>
</div> </div>
<!-- ===================== Lightning Channels ===================== -->
<div v-show="activeTab === 'channels'">
<p class="text-white/60 text-sm mb-4">
Lightning channels on this node. Open a channel to a peer to send and receive Lightning payments.
</p>
<LightningChannelsPanel v-if="show" compact />
<div class="flex gap-3 mt-4">
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
</div>
</div>
<!-- ===================== Cashu Mints ===================== --> <!-- ===================== Cashu Mints ===================== -->
<div v-show="activeTab === 'cashu'"> <div v-show="activeTab === 'cashu'">
<p class="text-white/60 text-sm mb-4"> <p class="text-white/60 text-sm mb-4">
@ -145,6 +156,7 @@ import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import BaseModal from '@/components/BaseModal.vue' import BaseModal from '@/components/BaseModal.vue'
import LightningChannelsPanel from '@/components/LightningChannelsPanel.vue'
const { t } = useI18n() const { t } = useI18n()
@ -152,10 +164,11 @@ const props = defineProps<{ show: boolean }>()
const emit = defineEmits<{ close: []; changed: [] }>() const emit = defineEmits<{ close: []; changed: [] }>()
const tabs = [ const tabs = [
{ key: 'channels' as const, label: 'Channels' },
{ key: 'cashu' as const, label: 'Cashu Mints' }, { key: 'cashu' as const, label: 'Cashu Mints' },
{ key: 'fedimint' as const, label: 'Fedimint Federations' }, { 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). // 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. // Join degrades gracefully with a clear error if the Fedimint client app isn't installed.

View File

@ -10,317 +10,13 @@
<h1 class="text-2xl font-bold text-white mb-6">Lightning Channels</h1> <h1 class="text-2xl font-bold text-white mb-6">Lightning Channels</h1>
<!-- Liquidity Summary --> <LightningChannelsPanel />
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div class="glass-card p-4">
<p class="text-white/60 text-sm mb-1">Total Outbound</p>
<p class="text-white text-xl font-bold">{{ formatSats(summary.total_outbound) }}</p>
</div>
<div class="glass-card p-4">
<p class="text-white/60 text-sm mb-1">Total Inbound</p>
<p class="text-white text-xl font-bold">{{ formatSats(summary.total_inbound) }}</p>
</div>
<div class="glass-card p-4">
<p class="text-white/60 text-sm mb-1">Channels</p>
<p class="text-white text-xl font-bold">{{ channels.length }}</p>
</div>
</div>
<!-- Open Channel Button -->
<div class="flex justify-end mb-4">
<button @click="showOpenModal = true" class="glass-button px-4 py-2 rounded-lg text-sm font-medium flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Open Channel
</button>
</div>
<Transition name="content-fade" mode="out-in">
<!-- Loading -->
<div v-if="loading && channels.length === 0" key="loading" class="glass-card p-12 text-center">
<svg class="animate-spin h-8 w-8 text-blue-400 mx-auto mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p class="text-white/70">Loading channels...</p>
</div>
<!-- Error -->
<div v-else-if="error && channels.length === 0" key="error" class="glass-card p-6 text-center">
<p class="text-red-300 mb-4">{{ error }}</p>
<button @click="loadChannels" class="glass-button px-4 py-2 rounded-lg text-sm">Retry</button>
</div>
<!-- No Channels -->
<div v-else-if="channels.length === 0" key="empty" class="glass-card p-8 text-center">
<svg class="w-16 h-16 text-white/20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<p class="text-white/70 mb-2">No channels yet</p>
<p class="text-white/50 text-sm">Open a channel to start sending and receiving Lightning payments.</p>
</div>
<!-- Channel List -->
<div v-else key="channels" class="space-y-3">
<div v-if="loading" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Refreshing channels...
</div>
<div v-else-if="error" class="p-3 rounded-lg border border-red-400/20 bg-red-500/10 text-red-200/85 text-sm">
{{ error }}
</div>
<div
v-for="ch in channels"
:key="ch.chan_id || ch.channel_point"
class="glass-card p-4"
>
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<span
class="w-2 h-2 rounded-full"
:class="{
'bg-green-400': ch.status === 'active',
'bg-yellow-400': ch.status === 'pending_open',
'bg-red-400': ch.status === 'inactive',
}"
></span>
<span class="text-white/80 text-sm font-medium capitalize">{{ ch.status.replace('_', ' ') }}</span>
</div>
<button
v-if="ch.status !== 'pending_open'"
@click="confirmClose(ch)"
class="text-red-400/70 hover:text-red-400 text-xs transition-colors"
>
Close
</button>
</div>
<!-- Peer -->
<p class="text-white/50 text-xs font-mono mb-3 truncate" :title="ch.remote_pubkey">
{{ ch.remote_pubkey }}
</p>
<!-- Capacity Bar -->
<div class="mb-2">
<div class="flex justify-between text-xs text-white/60 mb-1">
<span>Local: {{ formatSats(ch.local_balance) }}</span>
<span>Remote: {{ formatSats(ch.remote_balance) }}</span>
</div>
<div class="h-2 bg-white/10 rounded-full overflow-hidden flex">
<div
class="bg-blue-400 h-full transition-all"
:style="{ width: capacityPercent(ch.local_balance, ch.capacity) + '%' }"
></div>
<div
class="bg-orange-400 h-full transition-all"
:style="{ width: capacityPercent(ch.remote_balance, ch.capacity) + '%' }"
></div>
</div>
<p class="text-white/40 text-xs mt-1 text-center">
Capacity: {{ formatSats(ch.capacity) }}
</p>
</div>
</div>
</div>
</Transition>
<!-- Open Channel Modal -->
<Teleport to="body">
<div v-if="showOpenModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showOpenModal = false">
<div class="glass-card p-6 w-full max-w-md mx-4">
<h2 class="text-lg font-bold text-white mb-4">Open Channel</h2>
<div class="space-y-4">
<div>
<label class="text-white/60 text-sm block mb-1">Peer URI</label>
<input
v-model="openForm.peerUri"
type="text"
placeholder="pubkey@host:port"
class="w-full input-glass"
/>
<p class="text-white/40 text-xs mt-1">Format: pubkey@host:port</p>
</div>
<div>
<label class="text-white/60 text-sm block mb-1">Amount (sats)</label>
<input
v-model.number="openForm.amount"
type="number"
min="20000"
placeholder="100000"
class="w-full input-glass"
/>
<p class="text-white/40 text-xs mt-1">Minimum 20,000 sats</p>
</div>
</div>
<div v-if="openError" class="mt-3 alert-error">
<p class="text-xs">{{ openError }}</p>
</div>
<div class="flex gap-3 mt-6">
<button @click="showOpenModal = false" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
<button
@click="openChannel"
:disabled="openingChannel"
class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium"
>
{{ openingChannel ? 'Opening...' : 'Open Channel' }}
</button>
</div>
</div>
</div>
</Teleport>
<!-- Close Confirmation Modal -->
<Teleport to="body">
<div v-if="closeTarget" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeTarget = null">
<div class="glass-card p-6 w-full max-w-sm mx-4">
<h2 class="text-lg font-bold text-white mb-2">Close Channel?</h2>
<p class="text-white/60 text-sm mb-4">This will cooperatively close the channel with peer {{ closeTarget.remote_pubkey.slice(0, 16) }}...</p>
<div v-if="closeError" class="mb-3 alert-error">
<p class="text-xs">{{ closeError }}</p>
</div>
<div class="flex gap-3">
<button @click="closeTarget = null" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
<button
@click="closeChannel"
:disabled="closingChannel"
class="flex-1 glass-button glass-button-danger px-4 py-2 rounded-lg text-sm font-medium"
>
{{ closingChannel ? 'Closing...' : 'Close' }}
</button>
</div>
</div>
</div>
</Teleport>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { rpcClient } from '../../api/rpc-client' import LightningChannelsPanel from '@/components/LightningChannelsPanel.vue'
const router = useRouter() const router = useRouter()
interface Channel {
chan_id: string
remote_pubkey: string
capacity: number
local_balance: number
remote_balance: number
active: boolean
status: string
channel_point: string
}
const loading = ref(true)
const error = ref<string | null>(null)
const channels = ref<Channel[]>([])
const summary = ref({ total_inbound: 0, total_outbound: 0 })
const showOpenModal = ref(false)
const openForm = ref({ peerUri: '', amount: 100000 })
const openingChannel = ref(false)
const openError = ref<string | null>(null)
const closeTarget = ref<Channel | null>(null)
const closingChannel = ref(false)
const closeError = ref<string | null>(null)
function formatSats(sats: number): string {
if (sats >= 100_000_000) return `${(sats / 100_000_000).toFixed(2)} BTC`
if (sats >= 1_000_000) return `${(sats / 1_000_000).toFixed(1)}M sats`
if (sats >= 1_000) return `${(sats / 1_000).toFixed(1)}k sats`
return `${sats} sats`
}
function capacityPercent(amount: number, capacity: number): number {
if (capacity <= 0) return 0
return Math.round((amount / capacity) * 100)
}
async function loadChannels() {
const hadChannels = channels.value.length > 0
loading.value = true
error.value = null
try {
const result = await rpcClient.call<{ channels: Channel[]; total_inbound: number; total_outbound: number }>({
method: 'lnd.listchannels',
timeout: 15000,
})
channels.value = result.channels || []
summary.value = {
total_inbound: result.total_inbound || 0,
total_outbound: result.total_outbound || 0,
}
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to load channels'
if (!hadChannels) channels.value = []
} finally {
loading.value = false
}
}
async function openChannel() {
if (openingChannel.value) return
openError.value = null
const uri = openForm.value.peerUri.trim()
if (!uri) { openError.value = 'Peer URI is required'; return }
if (openForm.value.amount < 20000) { openError.value = 'Minimum 20,000 sats'; return }
const parts = uri.split('@')
const pubkey = parts[0]
const address = parts[1] || undefined
openingChannel.value = true
try {
await rpcClient.call({
method: 'lnd.openchannel',
params: { pubkey, address, amount: openForm.value.amount },
timeout: 30000,
})
showOpenModal.value = false
openForm.value = { peerUri: '', amount: 100000 }
await loadChannels()
} catch (err: unknown) {
openError.value = err instanceof Error ? err.message : 'Failed to open channel'
} finally {
openingChannel.value = false
}
}
function confirmClose(ch: Channel) {
closeTarget.value = ch
closeError.value = null
}
async function closeChannel() {
if (closingChannel.value || !closeTarget.value) return
closeError.value = null
closingChannel.value = true
try {
await rpcClient.call({
method: 'lnd.closechannel',
params: { channel_point: closeTarget.value.channel_point },
timeout: 30000,
})
closeTarget.value = null
await loadChannels()
} catch (err: unknown) {
closeError.value = err instanceof Error ? err.message : 'Failed to close channel'
} finally {
closingChannel.value = false
}
}
onMounted(loadChannels)
defineExpose({ channels, loadChannels })
</script> </script>

View File

@ -1,10 +1,10 @@
import { flushPromises, mount } from '@vue/test-utils' import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import LightningChannels from '../LightningChannels.vue' import LightningChannels from '@/components/LightningChannelsPanel.vue'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
vi.mock('vue-router', () => ({ vi.mock('vue-router', () => ({
useRouter: () => ({ replace: vi.fn() }), useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
})) }))
vi.mock('@/api/rpc-client', () => ({ vi.mock('@/api/rpc-client', () => ({