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
@@ -196,6 +196,9 @@ import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
|
||||
import AppLoadingScreen from '@/components/AppLoadingScreen.vue'
|
||||
import { DEFAULT_APP_ICON } from '@/views/apps/appsConfig'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useLightningRequired } from '@/composables/useLightningRequired'
|
||||
|
||||
const lightning = useLightningRequired()
|
||||
|
||||
interface PaymentRequest {
|
||||
request_id: string
|
||||
@@ -524,6 +527,9 @@ async function approvePayment() {
|
||||
})
|
||||
receipt = { method: 'ecash', token: res.token, amount_sats: res.amount_sats }
|
||||
} else if (method === 'lightning') {
|
||||
// Both arms below need a Lightning node — paying an invoice and minting
|
||||
// one. With none installed, raise the install modal instead of failing.
|
||||
if (!lightning.requireLightningNode()) return
|
||||
if (pay.invoice) {
|
||||
// Tracked to a real terminal state — slow routing is not a failure.
|
||||
const res = await rpcClient.payLightningInvoice({ payment_request: pay.invoice })
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<!-- z-3600: this is raised from INSIDE another modal (Receive, the Web5
|
||||
send/receive sheet, the app launcher's paywall), so it must sit above
|
||||
the standard modal layer (3000) but below the app overlay (4000) —
|
||||
same reasoning as ExternalExplorerModal. -->
|
||||
<BaseModal
|
||||
:show="lightning.show.value"
|
||||
title="Lightning node required"
|
||||
max-width="max-w-md"
|
||||
z-index="z-[3600]"
|
||||
@close="onClose"
|
||||
>
|
||||
<p class="text-sm text-white/70 leading-relaxed">
|
||||
Creating a Lightning invoice needs a Lightning node running on this
|
||||
Archipelago node. You don't have one installed yet — pick an
|
||||
implementation below and it'll be installed for you.
|
||||
</p>
|
||||
|
||||
<div class="mt-4 space-y-2">
|
||||
<div
|
||||
v-for="node in nodes"
|
||||
:key="node.id"
|
||||
class="rounded-xl border border-white/10 bg-white/[0.04] p-3"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-white">{{ node.name }}</span>
|
||||
<span
|
||||
v-if="!node.available"
|
||||
class="text-[10px] uppercase tracking-wide px-2 py-0.5 rounded-full bg-white/10 text-white/50"
|
||||
>Coming soon</span>
|
||||
</div>
|
||||
<p class="text-xs text-white/50 mt-0.5 leading-relaxed">{{ node.blurb }}</p>
|
||||
</div>
|
||||
<button
|
||||
v-if="node.available"
|
||||
:disabled="installing !== null"
|
||||
class="shrink-0 glass-button glass-button-warning px-3.5 py-1.5 rounded-lg text-xs font-medium disabled:opacity-50"
|
||||
@click="install(node.id)"
|
||||
>
|
||||
{{ installing === node.id ? 'Installing…' : 'Install' }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
disabled
|
||||
class="shrink-0 glass-button px-3.5 py-1.5 rounded-lg text-xs opacity-40 cursor-not-allowed"
|
||||
>Install</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="mt-3 alert-error text-sm">{{ error }}</p>
|
||||
|
||||
<p v-if="installing" class="mt-3 text-xs text-white/50 leading-relaxed">
|
||||
This takes a few minutes — the image has to be pulled and the node
|
||||
started. You can close this and carry on; the install keeps running.
|
||||
</p>
|
||||
|
||||
<div class="flex gap-2 mt-6">
|
||||
<button class="flex-1 glass-button px-4 py-2 rounded-lg text-sm" @click="onClose">
|
||||
{{ installing ? 'Close' : 'Not now' }}
|
||||
</button>
|
||||
</div>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import BaseModal from '@/components/BaseModal.vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useLightningRequired } from '@/composables/useLightningRequired'
|
||||
|
||||
interface NodeChoice {
|
||||
id: string
|
||||
name: string
|
||||
blurb: string
|
||||
/** False renders the row as "Coming soon" with a dead Install button. */
|
||||
available: boolean
|
||||
}
|
||||
|
||||
// Core Lightning is listed deliberately while unavailable: the choice is the
|
||||
// point of this modal, and showing it greyed tells the user the platform is
|
||||
// not LND-only. When its app id lands in the catalog, flip `available` here
|
||||
// and add the id to LIGHTNING_NODE_APP_IDS — nothing else changes.
|
||||
const nodes: NodeChoice[] = [
|
||||
{
|
||||
id: 'lnd',
|
||||
name: 'LND',
|
||||
blurb: 'Lightning Network Daemon. The implementation Archipelago ships today — wallet, channels and payments are wired to it.',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 'core-lightning',
|
||||
name: 'Core Lightning',
|
||||
blurb: 'Blockstream\'s implementation. Not packaged yet — it will appear here as a choice once it ships.',
|
||||
available: false,
|
||||
},
|
||||
]
|
||||
|
||||
const appStore = useAppStore()
|
||||
const lightning = useLightningRequired()
|
||||
|
||||
/** App id currently installing, or null. */
|
||||
const installing = ref<string | null>(null)
|
||||
const error = ref('')
|
||||
|
||||
async function install(id: string) {
|
||||
installing.value = id
|
||||
error.value = ''
|
||||
try {
|
||||
await appStore.installPackage(id, '', 'latest')
|
||||
// The gate reads install state from the package list, so once the install
|
||||
// lands the modal simply stops being raised. Close on success rather than
|
||||
// holding the user here watching a spinner.
|
||||
lightning.close()
|
||||
} catch (err) {
|
||||
error.value = `Install failed: ${err instanceof Error ? err.message : 'Unknown error'}`
|
||||
} finally {
|
||||
installing.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
error.value = ''
|
||||
lightning.close()
|
||||
}
|
||||
</script>
|
||||
@@ -91,8 +91,10 @@ import { useI18n } from 'vue-i18n'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import BaseModal from '@/components/BaseModal.vue'
|
||||
import { explainReceiveAddressFailure } from '@/utils/bitcoinReceive'
|
||||
import { useLightningRequired } from '@/composables/useLightningRequired'
|
||||
|
||||
const { t } = useI18n()
|
||||
const lightning = useLightningRequired()
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
@@ -154,6 +156,10 @@ async function receive() {
|
||||
error.value = ''
|
||||
try {
|
||||
if (receiveMethod.value === 'lightning') {
|
||||
// No Lightning implementation installed is not an error — it is a
|
||||
// missing prerequisite. Raise the install modal instead of letting
|
||||
// lnd.createinvoice fail with connection-refused (FED-08 follow-up).
|
||||
if (!lightning.requireLightningNode()) return
|
||||
if (!invoiceAmount.value) { error.value = t('receiveBitcoin.enterAnAmount'); return }
|
||||
const res = await rpcClient.call<{ payment_request: string }>({
|
||||
method: 'lnd.createinvoice',
|
||||
|
||||
Reference in New Issue
Block a user