Files
archy/neode-ui/src/composables/useLightningRequired.ts
T
archipelagoandClaude Opus 5 700947ea3c
Demo images / Build & push demo images (push) Successful in 3m18s
feat(wallet): app icons, mobile layout, and a funding mode on the Lightning modal
Icons: each node choice now shows its app icon (lnd.png; Core Lightning's is
vendored from the Umbrel gallery as core-lightning.svg). Vendored rather than
hotlinked on purpose — these nodes run offline/airgapped, and a remote image
would both break there and leak a request to a third-party host on every
render. A missing asset falls back to a neutral bolt glyph so a row can never
render a broken-image box.

Mobile: the choice row keeps icon + name + blurb together and drops the action
to its own full-width line under 26rem, instead of squeezing the description
into a two-word column next to a button.

Funding mode: a node that is running but has no funds / no inbound liquidity
is neither "install one" nor "start it", so the same modal gains a third mode
that explains it and routes to the run-lightning-node goal, where funding and
channel-opening already live — reusing that flow rather than duplicating it.

It fires where the user actually meets the problem: on a failed attempt.
handleLightningFailure() maps a running node's send/receive failure onto the
funding modal, matched on message text because LND surfaces "no route", "no
channels" and "insufficient balance" as plain strings with no distinct code —
and all three mean the same thing to a user: fund me.

Verified: 5 gate tests; npm run build clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 07:43:24 -04:00

127 lines
4.6 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 { 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')
export function useLightningRequired() {
const appStore = useAppStore()
/** Best status across every known Lightning implementation. */
function lightningStatus(): LightningStatus {
const pkgs = (appStore.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
}
return {
show,
status,
lightningStatus,
hasLightningNode,
requireLightningNode,
openLightningFunding,
handleLightningFailure,
close,
}
}