Demo images / Build & push demo images (push) Successful in 3m20s
Clicking "Open a channel" or "Setup Guide" navigated correctly but left the wallet's send/receive modal floating over the destination. The Lightning modal itself did close — the parent did not. Tab views are KeepAlive'd, so navigating deactivates the owner rather than unmounting it, and its Teleported modal keeps rendering. BaseModal now emits close on any route change while shown, fixing the class in one place rather than per button. Every modal here is a transient dialog; none should survive navigation. Two tests pin it, including that a hidden modal stays quiet. Also fixes a test-only regression fromfa26c5fc: useLightningRequired() resolved the Pinia store at composable-call time, so merely having the gate in SendBitcoinModal made it unmountable without an active Pinia (PaidTick mounts it bare). The store is now resolved lazily inside the function that needs it — a gate should never be what breaks a component's ability to mount. That one shipped because I verifiedfa26c5fcwith targeted tests and a build but had not re-run the full suite since5718179e. Verified: full suite 103 files / 827 tests green; npm run build clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
163 lines
6.2 KiB
TypeScript
163 lines
6.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'
|
|
|
|
// 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')
|
|
|
|
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() {
|
|
status.value = 'no-funds'
|
|
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
|
|
openLightningFunding()
|
|
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,
|
|
}
|
|
}
|