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') { } else if (method === 'lightning') {
// Both arms below need a Lightning node — paying an invoice and minting // Both arms below need a Lightning node — paying an invoice and minting
// one. With none installed, raise the install modal instead of failing. // one. With none installed, raise the install modal instead of failing.
if (!lightning.requireLightningNode()) return if (!(await lightning.requireLightningReady('send'))) return
if (pay.invoice) { if (pay.invoice) {
// Tracked to a real terminal state — slow routing is not a failure. // Tracked to a real terminal state — slow routing is not a failure.
const res = await rpcClient.payLightningInvoice({ payment_request: pay.invoice }) const res = await rpcClient.payLightningInvoice({ payment_request: pay.invoice })
@@ -11,9 +11,19 @@
@close="onClose" @close="onClose"
> >
<p v-if="lightning.status.value === 'no-funds'" class="text-sm text-white/70 leading-relaxed"> <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 Your Lightning node is running, but it has no payment channel yet.
yet — so it can't send or be paid. The Lightning setup walks you through <template v-if="lightning.fundingDirection.value === 'receive'">
funding it and opening a channel. 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>
<p v-else-if="lightning.status.value === 'stopped'" class="text-sm text-white/70 leading-relaxed"> <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 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. started. You can close this and carry on; the install keeps running.
</p> </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"> <button class="flex-1 glass-button px-4 py-2 rounded-lg text-sm" @click="onClose">
{{ installing ? 'Close' : 'Not now' }} {{ installing ? 'Close' : 'Not now' }}
</button> </button>
@@ -90,11 +100,16 @@
class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium"
@click="openApps" @click="openApps"
>Open My Apps</button> >Open My Apps</button>
<button <template v-else-if="lightning.status.value === 'no-funds'">
v-else-if="lightning.status.value === 'no-funds'" <button
class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm"
@click="openLightningSetup" @click="openSetupGuide"
>Set up Lightning</button> >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> </div>
</BaseModal> </BaseModal>
</template> </template>
@@ -140,7 +155,7 @@ const nodes: NodeChoice[] = [
const router = useRouter() const router = useRouter()
const modalTitle = computed(() => { 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' if (lightningStatusIs('stopped')) return 'Lightning node not running'
return 'Lightning node required' return 'Lightning node required'
}) })
@@ -177,6 +192,16 @@ function lightningStatusIs(s: string) {
/** The Lightning goal already owns funding + channel-opening, so reuse it /** The Lightning goal already owns funding + channel-opening, so reuse it
* rather than duplicating that flow inside this modal. */ * rather than duplicating that flow inside this modal. */
function openLightningSetup() { 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() lightning.close()
router.push('/dashboard/goals/run-lightning-node') 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 // No Lightning implementation installed is not an error — it is a
// missing prerequisite. Raise the install modal instead of letting // missing prerequisite. Raise the install modal instead of letting
// lnd.createinvoice fail with connection-refused (FED-08 follow-up). // 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 } if (!invoiceAmount.value) { error.value = t('receiveBitcoin.enterAnAmount'); return }
const res = await rpcClient.call<{ payment_request: string }>({ const res = await rpcClient.call<{ payment_request: string }>({
method: 'lnd.createinvoice', method: 'lnd.createinvoice',
+2 -2
View File
@@ -513,14 +513,14 @@ async function loadConfirmBalance() {
} }
} }
function review() { async function review() {
error.value = '' error.value = ''
const method = effectiveMethod.value const method = effectiveMethod.value
const d = dest.value.trim() const d = dest.value.trim()
if (method === 'lightning') { if (method === 'lightning') {
// Gate BEFORE the confirm step, not at submit: walking a user through // 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. // 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 } if (!d) { error.value = t('web5.pasteInvoice'); return }
invoiceAmountSats.value = parseBolt11AmountSats(d) invoiceAmountSats.value = parseBolt11AmountSats(d)
} else { } else {
@@ -17,6 +17,7 @@
// lnd container at all. // lnd container at all.
import { ref } from 'vue' import { ref } from 'vue'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { rpcClient } from '@/api/rpc-client'
import { PackageState } from '@/types/api' import { PackageState } from '@/types/api'
/** Package ids that provide a Lightning node. /** 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. // global modal mounted in App.vue.
const show = ref(false) const show = ref(false)
const status = ref<LightningStatus>('absent') 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() { export function useLightningRequired() {
const appStore = useAppStore() const appStore = useAppStore()
@@ -113,13 +116,44 @@ export function useLightningRequired() {
return true 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 { return {
show, show,
fundingDirection,
status, status,
lightningStatus, lightningStatus,
hasLightningNode, hasLightningNode,
requireLightningNode, requireLightningNode,
openLightningFunding, openLightningFunding,
requireLightningReady,
handleLightningFailure, handleLightningFailure,
close, close,
} }
@@ -471,7 +471,7 @@ async function unifiedReceive() {
if (receiveMethod.value === 'lightning') { if (receiveMethod.value === 'lightning') {
// Missing prerequisite, not an error raise the install modal rather // Missing prerequisite, not an error raise the install modal rather
// than letting lnd.createinvoice fail with connection-refused. // than letting lnd.createinvoice fail with connection-refused.
if (!lightning.requireLightningNode()) return if (!(await lightning.requireLightningReady('receive'))) return
if (!receiveInvoiceAmount.value || receiveInvoiceAmount.value < 1) { if (!receiveInvoiceAmount.value || receiveInvoiceAmount.value < 1) {
unifiedReceiveError.value = t('web5.enterAmount') unifiedReceiveError.value = t('web5.enterAmount')
return return