feat(wallet): offer to install a Lightning node instead of failing an invoice
Demo images / Build & push demo images (push) Successful in 3m16s
Demo images / Build & push demo images (push) Successful in 3m16s
Creating a Lightning invoice with no Lightning implementation installed failed
at the RPC layer — lnd.createinvoice returned connection-refused and the
Receive screen rendered it as a red error. That reads as the wallet being
broken when the node simply has no Lightning node installed yet.
useLightningRequired() gates the three invoice paths (wallet Receive, the Web5
send/receive sheet, and the app launcher's paywall — both arms there, since
paying an invoice needs a node as much as minting one). With none installed it
raises a modal offering to install one and the caller bails without surfacing
an error at all.
The modal lists the choice rather than assuming LND: LND installs today, Core
Lightning is listed greyed as "Coming soon" so the platform doesn't read as
LND-only. When CLN ships it is two lines — flip `available` and add the id to
LIGHTNING_NODE_APP_IDS.
Detection is install state, NOT reachability, deliberately: an installed node
that is merely stopped or still starting is a different problem ("start it")
and must not be answered with "install a Lightning node".
Also fixes the credentials modal, which painted its own rgba(8,10,18,.98)
navy card instead of the house glass-card — it read as blue against every
other modal. It existed twice (Apps.vue and apps/AppIconGrid.vue); both now
use BaseModal, so they also inherit Esc/focus handling, body scroll lock and
the standard pinned-header/footer scroll contract they were missing. Dead
panel CSS removed from both.
Verified: 4 new tests; full suite 103 files / 826 tests green; npm run build
clean with the new strings present in the built bundle.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
06e0e6954e
commit
5718179e2f
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { useLightningRequired } from '../useLightningRequired'
|
||||
|
||||
// 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
|
||||
// transport.
|
||||
const packages = vi.hoisted(() => ({ value: {} as Record<string, unknown> }))
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
get packages() {
|
||||
return packages.value
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('useLightningRequired', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
packages.value = {}
|
||||
// Module-scope `show` is shared by design (one global modal), so reset it
|
||||
// between cases or the first opener leaks into the next test.
|
||||
useLightningRequired().close()
|
||||
})
|
||||
|
||||
it('lets the action through when a Lightning node is installed', () => {
|
||||
packages.value = { lnd: {}, 'bitcoin-knots': {} }
|
||||
const lightning = useLightningRequired()
|
||||
|
||||
expect(lightning.hasLightningNode()).toBe(true)
|
||||
expect(lightning.requireLightningNode()).toBe(true)
|
||||
expect(lightning.show.value).toBe(false)
|
||||
})
|
||||
|
||||
it('blocks and raises the install modal when no Lightning node is installed', () => {
|
||||
packages.value = { 'bitcoin-knots': {}, immich: {} }
|
||||
const lightning = useLightningRequired()
|
||||
|
||||
expect(lightning.hasLightningNode()).toBe(false)
|
||||
// Returns false so the caller bails WITHOUT surfacing an error string —
|
||||
// that was the whole defect: a missing prerequisite rendered as a failure.
|
||||
expect(lightning.requireLightningNode()).toBe(false)
|
||||
expect(lightning.show.value).toBe(true)
|
||||
})
|
||||
|
||||
it('shares one modal state across call sites', () => {
|
||||
packages.value = {}
|
||||
const a = useLightningRequired()
|
||||
const b = useLightningRequired()
|
||||
|
||||
a.requireLightningNode()
|
||||
expect(b.show.value).toBe(true)
|
||||
b.close()
|
||||
expect(a.show.value).toBe(false)
|
||||
})
|
||||
|
||||
it('treats an empty package list as no Lightning node', () => {
|
||||
packages.value = {}
|
||||
expect(useLightningRequired().hasLightningNode()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
// Shared "this action needs a Lightning node" gate (2026-08-02).
|
||||
//
|
||||
// Creating a Lightning invoice with no Lightning node installed used to fail
|
||||
// at the RPC layer — `lnd.createinvoice` returns a connection-refused error
|
||||
// and the Receive screen showed it as a red failure string. That reads as the
|
||||
// wallet being broken, when in fact the node simply has no Lightning
|
||||
// implementation installed yet.
|
||||
//
|
||||
// Callers ask `requireLightningNode()` BEFORE attempting the call. When no
|
||||
// node is installed it opens the global LightningRequiredModal (which offers
|
||||
// to install one) and returns false, so the caller bails without surfacing an
|
||||
// error at all.
|
||||
//
|
||||
// Detection is install-state, not reachability, on purpose: an installed node
|
||||
// that is merely stopped or still starting is a different situation (wait or
|
||||
// start it) and must NOT be answered with "install a Lightning node".
|
||||
import { ref } from 'vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
/** 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 so the same gate recognises it
|
||||
* with no other change. */
|
||||
export const LIGHTNING_NODE_APP_IDS = ['lnd'] as const
|
||||
|
||||
// Module-scope: one source of truth shared by every caller and the single
|
||||
// global modal mounted in App.vue.
|
||||
const show = ref(false)
|
||||
|
||||
export function useLightningRequired() {
|
||||
const appStore = useAppStore()
|
||||
|
||||
/** True when some Lightning implementation is installed on this node. */
|
||||
function hasLightningNode(): boolean {
|
||||
const installed = Object.keys(appStore.packages ?? {})
|
||||
return installed.some((pkgId) =>
|
||||
(LIGHTNING_NODE_APP_IDS as readonly string[]).includes(pkgId),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate a Lightning-only action. Returns true to proceed; returns false and
|
||||
* opens the install modal when this node has no Lightning implementation.
|
||||
*/
|
||||
function requireLightningNode(): boolean {
|
||||
if (hasLightningNode()) return true
|
||||
show.value = true
|
||||
return false
|
||||
}
|
||||
|
||||
function close() {
|
||||
show.value = false
|
||||
}
|
||||
|
||||
return { show, hasLightningNode, requireLightningNode, close }
|
||||
}
|
||||
Reference in New Issue
Block a user