fix(wallet): the Lightning funding gate states the node's real channel state
Demo images / Build & push demo images (push) Successful in 3m38s
Demo images / Build & push demo images (push) Successful in 3m38s
"LND thinks I do not have a channel" while the wallet showed plenty of liquidity (framework-pt, 2026-09-01): the send gate sums outbound over FULLY-OPEN channels only, which is correct — a just-opened channel sits in LND's pending list until it has ~3 confirmations, and an open channel can have all its balance on the far side — but the modal then claimed the node had NO channel at all, in every one of those states, and pointed the user at opening another one. The gate already fetched the full channel list; it now records WHY liquidity is zero and the modal says the truth per state: - pending channels -> "your new channel is waiting for on-chain confirmations, it unlocks automatically, nothing is needed from you" (and no "Open a channel" button — that would send the user to fix a problem they don't have, possibly opening a second channel) - open channels, zero on the needed side -> "balance is on the far side — you can receive but there's nothing to send right now" - payment refused with a routing/liquidity error -> says so, instead of claiming no channels - only a genuinely channel-less node keeps the open-one guidance Eleven unit tests pin the state machine, including the regression case (pending-only -> 'pending', not 'none') and fail-open on RPC errors.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { useLightningRequired } from '../useLightningRequired'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
// The gate reads install state off the app store's package list. Stub the
|
||||
// store rather than the RPC layer so the test pins the decision, not the
|
||||
@@ -14,6 +15,12 @@ vi.mock('@/stores/app', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('useLightningRequired', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
@@ -73,4 +80,82 @@ describe('useLightningRequired', () => {
|
||||
packages.value = {}
|
||||
expect(useLightningRequired().lightningStatus()).toBe('absent')
|
||||
})
|
||||
|
||||
describe('requireLightningReady states the node\u2019s real funding state', () => {
|
||||
beforeEach(() => {
|
||||
packages.value = { lnd: { state: 'running' } }
|
||||
vi.mocked(rpcClient.call).mockReset()
|
||||
})
|
||||
|
||||
it('says the channel is confirming, not \u201cno channel\u201d, while pending', async () => {
|
||||
// The regression (framework-pt, 2026-09-01): a just-opened channel
|
||||
// sits in LND's pending list; the outbound sum is legitimately 0, but
|
||||
// the modal claimed the node had no channel at all.
|
||||
vi.mocked(rpcClient.call).mockResolvedValue({
|
||||
total_inbound: 0,
|
||||
total_outbound: 0,
|
||||
channels: [{ status: 'pending_open', local_balance: 900000, remote_balance: 0 }],
|
||||
})
|
||||
const lightning = useLightningRequired()
|
||||
|
||||
expect(await lightning.requireLightningReady('send')).toBe(false)
|
||||
expect(lightning.show.value).toBe(true)
|
||||
expect(lightning.status.value).toBe('no-funds')
|
||||
expect(lightning.fundingReason.value).toBe('pending')
|
||||
})
|
||||
|
||||
it('says the balance is on the far side when channels exist but outbound is 0', async () => {
|
||||
vi.mocked(rpcClient.call).mockResolvedValue({
|
||||
total_inbound: 985000,
|
||||
total_outbound: 0,
|
||||
channels: [{ status: 'active', local_balance: 0, remote_balance: 985000 }],
|
||||
})
|
||||
const lightning = useLightningRequired()
|
||||
|
||||
expect(await lightning.requireLightningReady('send')).toBe(false)
|
||||
expect(lightning.fundingReason.value).toBe('far-side')
|
||||
// The same node CAN receive — the gate must pass for the other way.
|
||||
vi.mocked(rpcClient.call).mockResolvedValue({
|
||||
total_inbound: 985000,
|
||||
total_outbound: 0,
|
||||
channels: [{ status: 'active', local_balance: 0, remote_balance: 985000 }],
|
||||
})
|
||||
expect(await lightning.requireLightningReady('receive')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the open-a-channel guidance only when there truly is no channel', async () => {
|
||||
vi.mocked(rpcClient.call).mockResolvedValue({
|
||||
total_inbound: 0,
|
||||
total_outbound: 0,
|
||||
channels: [],
|
||||
})
|
||||
const lightning = useLightningRequired()
|
||||
|
||||
expect(await lightning.requireLightningReady('send')).toBe(false)
|
||||
expect(lightning.fundingReason.value).toBe('none')
|
||||
})
|
||||
|
||||
it('fails OPEN on an RPC error \u2014 a transient blip must not block a working wallet', async () => {
|
||||
vi.mocked(rpcClient.call).mockRejectedValue(new Error('Failed to fetch'))
|
||||
const lightning = useLightningRequired()
|
||||
|
||||
expect(await lightning.requireLightningReady('send')).toBe(true)
|
||||
expect(lightning.show.value).toBe(false)
|
||||
})
|
||||
|
||||
it('maps a routing/liquidity payment failure onto the modal without claiming \u201cno channel\u201d', () => {
|
||||
const lightning = useLightningRequired()
|
||||
|
||||
expect(lightning.handleLightningFailure(new Error('Payment failed: unable to find a path to destination'))).toBe(true)
|
||||
expect(lightning.status.value).toBe('no-funds')
|
||||
expect(lightning.fundingReason.value).toBe('failed-payment')
|
||||
})
|
||||
|
||||
it('leaves non-funding payment errors to the caller', () => {
|
||||
const lightning = useLightningRequired()
|
||||
|
||||
expect(lightning.handleLightningFailure(new Error('Payment failed: Not Found'))).toBe(false)
|
||||
expect(lightning.show.value).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -34,12 +34,27 @@ export const LIGHTNING_NODE_APP_IDS = ['lnd'] as const
|
||||
* `running` — good to go. */
|
||||
export type LightningStatus = 'absent' | 'stopped' | 'running' | 'no-funds'
|
||||
|
||||
/** WHY the funding modal opened — the old copy always said "you have no
|
||||
* channel yet", which was a lie three ways: a just-opened channel sits in
|
||||
* LND's pending list (invisible to the outbound sum) until it has ~3
|
||||
* confirmations, channels can exist with all their balance on the far
|
||||
* side, and a payment failure can look like a funding problem. The user
|
||||
* sees "no channel" while looking at a wallet full of pending liquidity
|
||||
* (framework-pt, 2026-09-01: "LND thinks I do not have a channel").
|
||||
* `none` — genuinely no channels, the open-one flow is right.
|
||||
* `pending` — channel(s) exist but are still confirming on-chain.
|
||||
* `far-side` — open channel(s), but the needed direction has zero balance.
|
||||
* `failed-payment` — LND refused a payment; looks like routing/liquidity. */
|
||||
export type FundingReason = 'none' | 'pending' | 'far-side' | 'failed-payment'
|
||||
|
||||
// Module-scope: one source of truth shared by every caller and the single
|
||||
// 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')
|
||||
/** Why the funding modal opened, so the copy states the node's real state. */
|
||||
const fundingReason = ref<FundingReason>('none')
|
||||
|
||||
export function useLightningRequired() {
|
||||
// The store is resolved lazily, inside the functions that need it, rather
|
||||
@@ -86,8 +101,9 @@ export function useLightningRequired() {
|
||||
* rather than inventing a second one, and routes to the Lightning setup
|
||||
* goal where funding and channel-opening already live.
|
||||
*/
|
||||
function openLightningFunding() {
|
||||
function openLightningFunding(reason: FundingReason = 'none') {
|
||||
status.value = 'no-funds'
|
||||
fundingReason.value = reason
|
||||
show.value = true
|
||||
}
|
||||
|
||||
@@ -114,7 +130,9 @@ export function useLightningRequired() {
|
||||
'no path',
|
||||
].some((needle) => msg.includes(needle))
|
||||
if (!fundingRelated) return false
|
||||
openLightningFunding()
|
||||
// LND refused the payment itself — not necessarily "no channels", so
|
||||
// the modal must not claim it is. Most often this is routing/liquidity.
|
||||
openLightningFunding('failed-payment')
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -133,14 +151,28 @@ export function useLightningRequired() {
|
||||
async function requireLightningReady(direction: 'send' | 'receive'): Promise<boolean> {
|
||||
if (!requireLightningNode()) return false
|
||||
try {
|
||||
const res = await rpcClient.call<{ total_inbound?: number; total_outbound?: number }>({
|
||||
const res = await rpcClient.call<{
|
||||
total_inbound?: number
|
||||
total_outbound?: number
|
||||
channels?: { status?: string; local_balance?: number; remote_balance?: 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()
|
||||
// Zero in the needed direction — say WHY, from the same response.
|
||||
// The channel list carries pending entries (status 'pending_open');
|
||||
// the totals deliberately exclude them (nothing is spendable through
|
||||
// an unconfirmed channel), so "0 outbound + pending channels" is the
|
||||
// just-opened-a-channel state, not "no channel".
|
||||
const channels = res?.channels ?? []
|
||||
const hasPending = channels.some(c => c.status === 'pending_open')
|
||||
const hasOpen = channels.some(
|
||||
c => c.status === 'active' || c.status === 'inactive' || (!c.status && (c.local_balance || c.remote_balance)),
|
||||
)
|
||||
openLightningFunding(hasPending ? 'pending' : hasOpen ? 'far-side' : 'none')
|
||||
return false
|
||||
} catch {
|
||||
return true
|
||||
@@ -150,6 +182,7 @@ export function useLightningRequired() {
|
||||
return {
|
||||
show,
|
||||
fundingDirection,
|
||||
fundingReason,
|
||||
status,
|
||||
lightningStatus,
|
||||
hasLightningNode,
|
||||
|
||||
Reference in New Issue
Block a user