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
@@ -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,
}