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.
196 lines
8.2 KiB
TypeScript
196 lines
8.2 KiB
TypeScript
// Shared "this action needs a working Lightning node" gate (2026-08-02).
|
|
//
|
|
// Creating a Lightning invoice used to fail at the RPC layer whenever this
|
|
// node had no usable Lightning implementation — `lnd.createinvoice` returned
|
|
// connection-refused and the Receive screen rendered "Operation failed. Check
|
|
// server logs for details." That reads as the wallet being broken, when the
|
|
// truth is a missing (or stopped) prerequisite the user can act on.
|
|
//
|
|
// Callers ask `requireLightningNode()` BEFORE attempting the call. When there
|
|
// is no usable node it opens the global LightningRequiredModal and returns
|
|
// false, so the caller bails without surfacing an error at all.
|
|
//
|
|
// Keyed on package STATE, not mere presence: `package-data` carries an entry
|
|
// for a Lightning app that is known to this node but not actually running, so
|
|
// `id in packages` is NOT "installed and usable" — that assumption was the
|
|
// first version's bug, and it let the raw RPC error through on a node with no
|
|
// 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.
|
|
*
|
|
* `lnd` ships today. Core Lightning is the next implementation the modal
|
|
* offers — when its app id lands in the catalog, add it here and flip its
|
|
* `available` flag in LightningRequiredModal; nothing else changes. */
|
|
export const LIGHTNING_NODE_APP_IDS = ['lnd'] as const
|
|
|
|
/** `absent` — nothing installed, offer to install one.
|
|
* `stopped` — installed but not running, point the user at My Apps.
|
|
* `no-funds` — running, but there is nothing to pay with / no inbound
|
|
* liquidity to be paid into; send the user to the Lightning setup goal.
|
|
* `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
|
|
// than at composable-call time: a component may legitimately be mounted in
|
|
// a test (or any context) without an active Pinia, and merely *having* this
|
|
// gate available must not be what breaks it.
|
|
/** Best status across every known Lightning implementation. */
|
|
function lightningStatus(): LightningStatus {
|
|
const pkgs = (useAppStore().packages ?? {}) as Record<string, { state?: string } | undefined>
|
|
let best: LightningStatus = 'absent'
|
|
for (const id of LIGHTNING_NODE_APP_IDS) {
|
|
const entry = pkgs[id]
|
|
if (!entry) continue
|
|
if (entry.state === PackageState.Running) return 'running'
|
|
// Present but not running: installing, starting, stopped, exited…
|
|
best = 'stopped'
|
|
}
|
|
return best
|
|
}
|
|
|
|
function hasLightningNode(): boolean {
|
|
return lightningStatus() === 'running'
|
|
}
|
|
|
|
/**
|
|
* Gate a Lightning-only action. Returns true to proceed; returns false and
|
|
* opens the modal (in the mode matching why) when there is no usable node.
|
|
*/
|
|
function requireLightningNode(): boolean {
|
|
const s = lightningStatus()
|
|
if (s === 'running') return true
|
|
status.value = s
|
|
show.value = true
|
|
return false
|
|
}
|
|
|
|
function close() {
|
|
show.value = false
|
|
}
|
|
|
|
/**
|
|
* Raise the same modal in its funding mode: the node is installed and
|
|
* running, but has no usable balance/liquidity yet. Reuses this modal
|
|
* rather than inventing a second one, and routes to the Lightning setup
|
|
* goal where funding and channel-opening already live.
|
|
*/
|
|
function openLightningFunding(reason: FundingReason = 'none') {
|
|
status.value = 'no-funds'
|
|
fundingReason.value = reason
|
|
show.value = true
|
|
}
|
|
|
|
/**
|
|
* Map a failed Lightning attempt onto the funding modal when the node is
|
|
* running but has nothing to pay with / no inbound liquidity. Returns true
|
|
* when it handled the error, so the caller can skip showing a raw string.
|
|
*
|
|
* Matched on the message because LND surfaces these as plain text: there is
|
|
* no distinct error code for "no channels" vs "no route" vs "insufficient
|
|
* balance", and all three mean the same thing to the user — fund me.
|
|
*/
|
|
function handleLightningFailure(err: unknown): boolean {
|
|
if (lightningStatus() !== 'running') return false
|
|
const msg = (err instanceof Error ? err.message : String(err ?? '')).toLowerCase()
|
|
const fundingRelated = [
|
|
'no route',
|
|
'no routes',
|
|
'insufficient',
|
|
'no channel',
|
|
'not enough',
|
|
'balance',
|
|
'unable to find a path',
|
|
'no path',
|
|
].some((needle) => msg.includes(needle))
|
|
if (!fundingRelated) return false
|
|
// 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
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
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
|
|
// 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
|
|
}
|
|
}
|
|
|
|
return {
|
|
show,
|
|
fundingDirection,
|
|
fundingReason,
|
|
status,
|
|
lightningStatus,
|
|
hasLightningNode,
|
|
requireLightningNode,
|
|
openLightningFunding,
|
|
requireLightningReady,
|
|
handleLightningFailure,
|
|
close,
|
|
}
|
|
}
|