From c3d5bcd2712673dc4e9c3aee2a389ba563b8fb24 Mon Sep 17 00:00:00 2001 From: archipelago Date: Sun, 2 Aug 2026 07:51:08 -0400 Subject: [PATCH] fix(wallet): gate lightning on CHANNELS, not just node state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A running LND with zero channels happily mints an invoice — it is simply unpayable, because nobody has a route in. So the state-only gate let receive through and handed the user a useless invoice, and let send walk to confirm. Neither errored, so the funding modal (wired to failures) never fired. requireLightningReady(direction) now asks lnd.listchannels and checks the liquidity that actually matters for the attempt: total_inbound to receive, total_outbound to send. It fails OPEN on an RPC error — a transient blip should not block a working wallet. The no-funds mode says plainly that a channel is needed, in the direction's own terms (inbound vs outbound), and offers both routes: "Open a channel" straight to the channels screen where the Zeus/Olympus flow is already prefilled, and "Setup Guide" to the run-lightning-node walkthrough for someone who wants the whole path explained. Buttons wrap rather than squeeze on narrow screens. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/components/AppLauncherOverlay.vue | 2 +- .../src/components/LightningRequiredModal.vue | 45 ++++++++++++++----- .../src/components/ReceiveBitcoinModal.vue | 2 +- neode-ui/src/components/SendBitcoinModal.vue | 4 +- .../src/composables/useLightningRequired.ts | 34 ++++++++++++++ .../src/views/web5/Web5SendReceiveModals.vue | 2 +- 6 files changed, 74 insertions(+), 15 deletions(-) diff --git a/neode-ui/src/components/AppLauncherOverlay.vue b/neode-ui/src/components/AppLauncherOverlay.vue index 4bae8a02..cb6bb640 100644 --- a/neode-ui/src/components/AppLauncherOverlay.vue +++ b/neode-ui/src/components/AppLauncherOverlay.vue @@ -529,7 +529,7 @@ async function approvePayment() { } else if (method === 'lightning') { // Both arms below need a Lightning node — paying an invoice and minting // one. With none installed, raise the install modal instead of failing. - if (!lightning.requireLightningNode()) return + if (!(await lightning.requireLightningReady('send'))) return if (pay.invoice) { // Tracked to a real terminal state — slow routing is not a failure. const res = await rpcClient.payLightningInvoice({ payment_request: pay.invoice }) diff --git a/neode-ui/src/components/LightningRequiredModal.vue b/neode-ui/src/components/LightningRequiredModal.vue index f41728a5..831bac89 100644 --- a/neode-ui/src/components/LightningRequiredModal.vue +++ b/neode-ui/src/components/LightningRequiredModal.vue @@ -11,9 +11,19 @@ @close="onClose" >

- Your Lightning node is running, but it has no funds or inbound liquidity - yet — so it can't send or be paid. The Lightning setup walks you through - funding it and opening a channel. + Your Lightning node is running, but it has no payment channel yet. + + + Open one with Zeus Olympus from the + channels screen — it's prefilled there, and needs 150,000–1,500,000 + on-chain sats.

Lightning payments need a Lightning node that's actually running. Yours is @@ -81,7 +91,7 @@ started. You can close this and carry on; the install keeps running.

-
+
@@ -90,11 +100,16 @@ class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium" @click="openApps" >Open My Apps - +
@@ -140,7 +155,7 @@ const nodes: NodeChoice[] = [ const router = useRouter() const modalTitle = computed(() => { - if (lightningStatusIs('no-funds')) return 'Lightning not funded yet' + if (lightningStatusIs('no-funds')) return 'You need a Lightning channel' if (lightningStatusIs('stopped')) return 'Lightning node not running' return 'Lightning node required' }) @@ -177,6 +192,16 @@ function lightningStatusIs(s: string) { /** The Lightning goal already owns funding + channel-opening, so reuse it * rather than duplicating that flow inside this modal. */ function openLightningSetup() { + lightning.close() + // The channels screen already has the prefilled Zeus/Olympus open-channel + // flow, so send the user straight to the thing that solves it rather than + // to the wizard that would only point here anyway. + router.push('/dashboard/apps/lnd/channels') +} + +/** The guided walkthrough, for someone who wants the whole path explained + * rather than to be dropped straight into the open-channel form. */ +function openSetupGuide() { lightning.close() router.push('/dashboard/goals/run-lightning-node') } diff --git a/neode-ui/src/components/ReceiveBitcoinModal.vue b/neode-ui/src/components/ReceiveBitcoinModal.vue index 4b77e29e..085133e1 100644 --- a/neode-ui/src/components/ReceiveBitcoinModal.vue +++ b/neode-ui/src/components/ReceiveBitcoinModal.vue @@ -156,7 +156,7 @@ async function receive() { // No Lightning implementation installed is not an error — it is a // missing prerequisite. Raise the install modal instead of letting // lnd.createinvoice fail with connection-refused (FED-08 follow-up). - if (!lightning.requireLightningNode()) return + if (!(await lightning.requireLightningReady('receive'))) return if (!invoiceAmount.value) { error.value = t('receiveBitcoin.enterAnAmount'); return } const res = await rpcClient.call<{ payment_request: string }>({ method: 'lnd.createinvoice', diff --git a/neode-ui/src/components/SendBitcoinModal.vue b/neode-ui/src/components/SendBitcoinModal.vue index 84c5ff32..52b0720d 100644 --- a/neode-ui/src/components/SendBitcoinModal.vue +++ b/neode-ui/src/components/SendBitcoinModal.vue @@ -513,14 +513,14 @@ async function loadConfirmBalance() { } } -function review() { +async function review() { error.value = '' const method = effectiveMethod.value const d = dest.value.trim() if (method === 'lightning') { // Gate BEFORE the confirm step, not at submit: walking a user through // review-and-confirm only to fail on a missing node is the defect. - if (!lightning.requireLightningNode()) return + if (!(await lightning.requireLightningReady('send'))) return if (!d) { error.value = t('web5.pasteInvoice'); return } invoiceAmountSats.value = parseBolt11AmountSats(d) } else { diff --git a/neode-ui/src/composables/useLightningRequired.ts b/neode-ui/src/composables/useLightningRequired.ts index cca8e95e..4a378525 100644 --- a/neode-ui/src/composables/useLightningRequired.ts +++ b/neode-ui/src/composables/useLightningRequired.ts @@ -17,6 +17,7 @@ // lnd container at all. import { ref } from 'vue' import { useAppStore } from '@/stores/app' +import { rpcClient } from '@/api/rpc-client' import { PackageState } from '@/types/api' /** Package ids that provide a Lightning node. @@ -37,6 +38,8 @@ export type LightningStatus = 'absent' | 'stopped' | 'running' | 'no-funds' // global modal mounted in App.vue. const show = ref(false) const status = ref('absent') +/** Which direction raised the funding modal, so the copy can be specific. */ +const fundingDirection = ref<'send' | 'receive'>('receive') export function useLightningRequired() { const appStore = useAppStore() @@ -113,13 +116,44 @@ export function useLightningRequired() { return true } + /** + * The full readiness gate: node installed AND running AND with liquidity in + * the direction being attempted. + * + * Node state alone is not enough — LND happily mints an invoice with zero + * channels, so a state-only gate hands the user an invoice nobody can pay + * (and a send that can only fail). `receive` needs inbound liquidity, + * `send` needs outbound. + * + * Fails OPEN on an RPC error: if we cannot read the channel list we let the + * attempt proceed rather than block a working wallet on a transient blip. + */ + async function requireLightningReady(direction: 'send' | 'receive'): Promise { + if (!requireLightningNode()) return false + try { + const res = await rpcClient.call<{ total_inbound?: number; total_outbound?: number }>({ + method: 'lnd.listchannels', + timeout: 15000, + }) + const liquidity = direction === 'receive' ? res?.total_inbound ?? 0 : res?.total_outbound ?? 0 + if (liquidity > 0) return true + fundingDirection.value = direction + openLightningFunding() + return false + } catch { + return true + } + } + return { show, + fundingDirection, status, lightningStatus, hasLightningNode, requireLightningNode, openLightningFunding, + requireLightningReady, handleLightningFailure, close, } diff --git a/neode-ui/src/views/web5/Web5SendReceiveModals.vue b/neode-ui/src/views/web5/Web5SendReceiveModals.vue index a9ac9899..6f7ceace 100644 --- a/neode-ui/src/views/web5/Web5SendReceiveModals.vue +++ b/neode-ui/src/views/web5/Web5SendReceiveModals.vue @@ -471,7 +471,7 @@ async function unifiedReceive() { if (receiveMethod.value === 'lightning') { // Missing prerequisite, not an error — raise the install modal rather // than letting lnd.createinvoice fail with connection-refused. - if (!lightning.requireLightningNode()) return + if (!(await lightning.requireLightningReady('receive'))) return if (!receiveInvoiceAmount.value || receiveInvoiceAmount.value < 1) { unifiedReceiveError.value = t('web5.enterAmount') return