fix(wallet): gate lightning on CHANNELS, not just node state
Demo images / Build & push demo images (push) Successful in 3m24s
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:
co-authored by
Claude Opus 5
parent
700947ea3c
commit
c3d5bcd271
@@ -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,000–1,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',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user