fix(wallet): gate lightning on CHANNELS, not just node state
Demo images / Build & push demo images (push) Successful in 3m24s

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) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-02 07:51:08 -04:00
co-authored by Claude Opus 5
parent 700947ea3c
commit c3d5bcd271
6 changed files with 74 additions and 15 deletions
@@ -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 })
@@ -11,9 +11,19 @@
@close="onClose"
>
<p v-if="lightning.status.value === 'no-funds'" class="text-sm text-white/70 leading-relaxed">
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.
<template v-if="lightning.fundingDirection.value === 'receive'">
Receiving needs <span class="text-white/90">inbound liquidity</span> — a
channel with funds on the far side — otherwise any invoice you create
is unpayable.
</template>
<template v-else>
Sending needs <span class="text-white/90">outbound liquidity</span> — a
funded channel to route through.
</template>
Open one with <span class="text-white/90">Zeus Olympus</span> from the
channels screen — it's prefilled there, and needs 150,0001,500,000
on-chain sats.
</p>
<p v-else-if="lightning.status.value === 'stopped'" class="text-sm text-white/70 leading-relaxed">
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.
</p>
<div class="flex gap-2 mt-6">
<div class="flex flex-wrap gap-2 mt-6">
<button class="flex-1 glass-button px-4 py-2 rounded-lg text-sm" @click="onClose">
{{ installing ? 'Close' : 'Not now' }}
</button>
@@ -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</button>
<button
v-else-if="lightning.status.value === 'no-funds'"
class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium"
@click="openLightningSetup"
>Set up Lightning</button>
<template v-else-if="lightning.status.value === 'no-funds'">
<button
class="flex-1 glass-button px-4 py-2 rounded-lg text-sm"
@click="openSetupGuide"
>Setup Guide</button>
<button
class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium"
@click="openLightningSetup"
>Open a channel</button>
</template>
</div>
</BaseModal>
</template>
@@ -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')
}
@@ -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',
+2 -2
View File
@@ -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 {
@@ -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<LightningStatus>('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<boolean> {
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,
}
@@ -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