2026-08-12 10:55:50 +00:00
|
|
|
// 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'
|
|
|
|
|
|
2026-09-01 11:40:25 -04:00
|
|
|
/** 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'
|
|
|
|
|
|
2026-08-12 10:55:50 +00:00
|
|
|
// 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')
|
2026-09-01 11:40:25 -04:00
|
|
|
/** Why the funding modal opened, so the copy states the node's real state. */
|
|
|
|
|
const fundingReason = ref<FundingReason>('none')
|
2026-08-12 10:55:50 +00:00
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
*/
|
2026-09-01 11:40:25 -04:00
|
|
|
function openLightningFunding(reason: FundingReason = 'none') {
|
2026-08-12 10:55:50 +00:00
|
|
|
status.value = 'no-funds'
|
2026-09-01 11:40:25 -04:00
|
|
|
fundingReason.value = reason
|
2026-08-12 10:55:50 +00:00
|
|
|
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
|
2026-09-01 11:40:25 -04:00
|
|
|
// 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')
|
2026-08-12 10:55:50 +00:00
|
|
|
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 {
|
2026-09-01 11:40:25 -04:00
|
|
|
const res = await rpcClient.call<{
|
|
|
|
|
total_inbound?: number
|
|
|
|
|
total_outbound?: number
|
|
|
|
|
channels?: { status?: string; local_balance?: number; remote_balance?: number }[]
|
|
|
|
|
}>({
|
2026-08-12 10:55:50 +00:00
|
|
|
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
|
2026-09-01 11:40:25 -04:00
|
|
|
// 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')
|
2026-08-12 10:55:50 +00:00
|
|
|
return false
|
|
|
|
|
} catch {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
show,
|
|
|
|
|
fundingDirection,
|
2026-09-01 11:40:25 -04:00
|
|
|
fundingReason,
|
2026-08-12 10:55:50 +00:00
|
|
|
status,
|
|
|
|
|
lightningStatus,
|
|
|
|
|
hasLightningNode,
|
|
|
|
|
requireLightningNode,
|
|
|
|
|
openLightningFunding,
|
|
|
|
|
requireLightningReady,
|
|
|
|
|
handleLightningFailure,
|
|
|
|
|
close,
|
|
|
|
|
}
|
|
|
|
|
}
|