fix(wallet): gate lightning on node STATE, gate send too, add a shared CopyButton

Three defects from testing the previous commit on archi-dev-box:

1. The gate keyed on `id in packages`, which is not "installed and usable" —
   package-data carries an entry for a Lightning app that is known but not
   running. On a box with no lnd container at all the gate passed and the raw
   error came through as "Operation failed. Check server logs for details."
   Now keyed on PackageState.Running.

2. Because installed-but-stopped is a real and different situation, the modal
   has two modes: absent offers the install choices, stopped says the node
   isn't running and offers "Open My Apps". Neither dead-ends in an error.

3. Lightning SEND let you walk all the way to confirm-send with no node. The
   gate now runs in review(), before the confirm step — failing at submit
   after a review screen is the defect, not a smaller version of it.

Also adds CopyButton, the start of one consistent copy affordance: icon +
label, an emerald tick held 1.6s, a fixed box so the width never jumps, and a
document.execCommand fallback so copy still works over plain http on a LAN IP
(navigator.clipboard rejects on insecure origins, which is how a lot of nodes
are reached). Converted the wallet's own copies — the lightning invoice the
user reported, plus the on-chain/Ark addresses and the payment hash/txid.

20 of 25 copy sites across 15 other files still use ad-hoc markup; converting
them is mechanical but was not attempted here rather than half-done.

Verified: 5 gate tests; npm run build clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-02 07:36:25 -04:00
co-authored by Claude Opus 5
parent 90ce4bcc46
commit fa26c5fc56
6 changed files with 240 additions and 60 deletions
+141
View File
@@ -0,0 +1,141 @@
<template>
<button
type="button"
class="copy-btn"
:class="[sizeClass, copied ? 'copy-btn-copied' : '']"
:aria-label="copied ? 'Copied' : label"
@click.stop="copy"
>
<!-- Icon swaps to a tick on success; both are the same box so the button
never changes width mid-feedback (the jump was half the reason the
old ad-hoc buttons felt broken). -->
<svg v-if="!copied" class="copy-btn-icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-2M8 5a2 2 0 002 2h4a2 2 0 002-2M8 5a2 2 0 012-2h4a2 2 0 012 2m0 0h2a2 2 0 012 2v3" />
</svg>
<svg v-else class="copy-btn-icon" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
<span v-if="!iconOnly" class="copy-btn-text">{{ copied ? copiedLabel : label }}</span>
</button>
</template>
<script setup lang="ts">
import { ref, computed, onUnmounted } from 'vue'
// One copy button for the whole app. Before this, 25 call sites each rolled
// their own markup and feedback — some flipped a label, some did nothing at
// all, and the widths jumped. Every copy affordance should look and behave
// identically, so this owns both the visual and the timing.
const props = withDefaults(defineProps<{
/** Text to place on the clipboard. */
value: string
/** Button label in the idle state. */
label?: string
/** Label shown while the success state is held. */
copiedLabel?: string
/** Icon with no text — for tight rows next to a truncated value. */
iconOnly?: boolean
size?: 'sm' | 'md'
}>(), {
label: 'Copy',
copiedLabel: 'Copied',
iconOnly: false,
size: 'sm',
})
const emit = defineEmits<{ copied: [] }>()
/** How long the success state is held. Long enough to register, short enough
* that a second copy doesn't feel blocked. */
const FEEDBACK_MS = 1600
const copied = ref(false)
let timer: ReturnType<typeof setTimeout> | null = null
const sizeClass = computed(() => (props.size === 'md' ? 'copy-btn-md' : 'copy-btn-sm'))
async function copy() {
try {
await navigator.clipboard.writeText(props.value)
} catch {
// Clipboard can reject (insecure origin, denied permission). Fall back to
// the legacy path so the button still works over plain http on a LAN IP,
// which is how plenty of nodes are reached.
try {
const ta = document.createElement('textarea')
ta.value = props.value
ta.setAttribute('readonly', '')
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
} catch {
return // genuinely could not copy — don't claim success
}
}
copied.value = true
emit('copied')
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
copied.value = false
timer = null
}, FEEDBACK_MS)
}
onUnmounted(() => {
if (timer) clearTimeout(timer)
})
</script>
<style scoped>
.copy-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
border-radius: 0.5rem;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.75);
transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
white-space: nowrap;
}
.copy-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.copy-btn-sm {
padding: 0.35rem 0.6rem;
font-size: 0.75rem;
}
.copy-btn-md {
padding: 0.5rem 0.85rem;
font-size: 0.875rem;
}
.copy-btn-icon {
width: 0.95rem;
height: 0.95rem;
flex-shrink: 0;
}
.copy-btn-md .copy-btn-icon {
width: 1.05rem;
height: 1.05rem;
}
/* Success: emerald, matching the paid/settled language used elsewhere in the wallet. */
.copy-btn-copied {
background: rgba(16, 185, 129, 0.16);
border-color: rgba(16, 185, 129, 0.4);
color: rgb(110, 231, 183);
}
.copy-btn-copied:hover {
background: rgba(16, 185, 129, 0.22);
color: rgb(110, 231, 183);
}
@media (prefers-reduced-motion: reduce) {
.copy-btn {
transition: none;
}
}
</style>
@@ -5,18 +5,23 @@
same reasoning as ExternalExplorerModal. --> same reasoning as ExternalExplorerModal. -->
<BaseModal <BaseModal
:show="lightning.show.value" :show="lightning.show.value"
title="Lightning node required" :title="lightning.status.value === 'stopped' ? 'Lightning node not running' : 'Lightning node required'"
max-width="max-w-md" max-width="max-w-md"
z-index="z-[3600]" z-index="z-[3600]"
@close="onClose" @close="onClose"
> >
<p class="text-sm text-white/70 leading-relaxed"> <p v-if="lightning.status.value === 'stopped'" class="text-sm text-white/70 leading-relaxed">
Creating a Lightning invoice needs a Lightning node running on this Lightning payments need a Lightning node that's actually running. Yours is
Archipelago node. You don't have one installed yet pick an installed but isn't running right now — start it from My Apps and try
implementation below and it'll be installed for you. again.
</p>
<p v-else class="text-sm text-white/70 leading-relaxed">
Lightning payments need 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> </p>
<div class="mt-4 space-y-2"> <div v-if="lightning.status.value !== 'stopped'" class="mt-4 space-y-2">
<div <div
v-for="node in nodes" v-for="node in nodes"
:key="node.id" :key="node.id"
@@ -61,6 +66,11 @@
<button class="flex-1 glass-button px-4 py-2 rounded-lg text-sm" @click="onClose"> <button class="flex-1 glass-button px-4 py-2 rounded-lg text-sm" @click="onClose">
{{ installing ? 'Close' : 'Not now' }} {{ installing ? 'Close' : 'Not now' }}
</button> </button>
<button
v-if="lightning.status.value === 'stopped'"
class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium"
@click="openApps"
>Open My Apps</button>
</div> </div>
</BaseModal> </BaseModal>
</template> </template>
@@ -68,6 +78,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import BaseModal from '@/components/BaseModal.vue' import BaseModal from '@/components/BaseModal.vue'
import { useRouter } from 'vue-router'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { useLightningRequired } from '@/composables/useLightningRequired' import { useLightningRequired } from '@/composables/useLightningRequired'
@@ -98,6 +109,7 @@ const nodes: NodeChoice[] = [
}, },
] ]
const router = useRouter()
const appStore = useAppStore() const appStore = useAppStore()
const lightning = useLightningRequired() const lightning = useLightningRequired()
@@ -121,6 +133,11 @@ async function install(id: string) {
} }
} }
function openApps() {
lightning.close()
router.push('/dashboard/apps')
}
function onClose() { function onClose() {
error.value = '' error.value = ''
lightning.close() lightning.close()
@@ -25,7 +25,7 @@
<canvas ref="lightningQrCanvas" class="mx-auto mb-3 rounded-lg" style="image-rendering: pixelated;"></canvas> <canvas ref="lightningQrCanvas" class="mx-auto mb-3 rounded-lg" style="image-rendering: pixelated;"></canvas>
<p class="text-white/50 text-xs mb-1">{{ t('receiveBitcoin.invoiceShareLabel') }}</p> <p class="text-white/50 text-xs mb-1">{{ t('receiveBitcoin.invoiceShareLabel') }}</p>
<p class="text-xs font-mono text-white/80 break-all">{{ invoiceResult }}</p> <p class="text-xs font-mono text-white/80 break-all">{{ invoiceResult }}</p>
<button @click="copyText(invoiceResult)" class="mt-2 text-xs text-orange-400 hover:text-orange-300">{{ t('common.copy') }}</button> <CopyButton :value="invoiceResult" :label="t('common.copy')" class="mt-2" />
</div> </div>
</div> </div>
@@ -38,7 +38,7 @@
<canvas ref="onchainQrCanvas" class="mx-auto mb-3 rounded-lg" style="image-rendering: pixelated;"></canvas> <canvas ref="onchainQrCanvas" class="mx-auto mb-3 rounded-lg" style="image-rendering: pixelated;"></canvas>
<p class="text-white/50 text-xs mb-2">{{ t('receiveBitcoin.yourBitcoinAddress') }}</p> <p class="text-white/50 text-xs mb-2">{{ t('receiveBitcoin.yourBitcoinAddress') }}</p>
<p class="text-sm font-mono text-white/90 break-all">{{ onchainAddress }}</p> <p class="text-sm font-mono text-white/90 break-all">{{ onchainAddress }}</p>
<button @click="copyText(onchainAddress)" class="mt-2 text-xs text-orange-400 hover:text-orange-300">{{ t('common.copy') }}</button> <CopyButton :value="onchainAddress" :label="t('common.copy')" class="mt-2" />
</div> </div>
<div v-else class="mb-3 text-center"> <div v-else class="mb-3 text-center">
<p class="text-white/50 text-sm mb-2">{{ t('web5.generateFreshAddress') }}</p> <p class="text-white/50 text-sm mb-2">{{ t('web5.generateFreshAddress') }}</p>
@@ -52,7 +52,7 @@
<canvas ref="arkQrCanvas" class="mx-auto mb-3 rounded-lg" style="image-rendering: pixelated;"></canvas> <canvas ref="arkQrCanvas" class="mx-auto mb-3 rounded-lg" style="image-rendering: pixelated;"></canvas>
<p class="text-white/50 text-xs mb-2">Your Ark address</p> <p class="text-white/50 text-xs mb-2">Your Ark address</p>
<p class="text-sm font-mono text-white/90 break-all">{{ arkAddress }}</p> <p class="text-sm font-mono text-white/90 break-all">{{ arkAddress }}</p>
<button @click="copyText(arkAddress)" class="mt-2 text-xs text-orange-400 hover:text-orange-300">{{ t('common.copy') }}</button> <CopyButton :value="arkAddress" :label="t('common.copy')" class="mt-2" />
</div> </div>
<div v-else class="mb-3 text-center"> <div v-else class="mb-3 text-center">
<p class="text-white/50 text-sm mb-2">Generate a fresh Ark address to receive off-chain sats instantly.</p> <p class="text-white/50 text-sm mb-2">Generate a fresh Ark address to receive off-chain sats instantly.</p>
@@ -90,6 +90,7 @@ import { ref, nextTick, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import BaseModal from '@/components/BaseModal.vue' import BaseModal from '@/components/BaseModal.vue'
import CopyButton from '@/components/CopyButton.vue'
import { explainReceiveAddressFailure } from '@/utils/bitcoinReceive' import { explainReceiveAddressFailure } from '@/utils/bitcoinReceive'
import { useLightningRequired } from '@/composables/useLightningRequired' import { useLightningRequired } from '@/composables/useLightningRequired'
@@ -147,10 +148,6 @@ function close() {
emit('close') emit('close')
} }
function copyText(text: string) {
navigator.clipboard.writeText(text).catch(() => {})
}
async function receive() { async function receive() {
processing.value = true processing.value = true
error.value = '' error.value = ''
+8 -17
View File
@@ -25,20 +25,14 @@
<p class="text-xs text-white/50 mb-1">Payment hash</p> <p class="text-xs text-white/50 mb-1">Payment hash</p>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<p class="flex-1 text-xs font-mono text-white/80 break-all">{{ successInfo.hash }}</p> <p class="flex-1 text-xs font-mono text-white/80 break-all">{{ successInfo.hash }}</p>
<button <CopyButton class="shrink-0" :value="successInfo.hash" />
class="shrink-0 px-2.5 py-1.5 rounded-lg text-xs glass-button"
@click="copyDetail(successInfo.hash)"
>{{ copiedDetail === successInfo.hash ? 'Copied!' : 'Copy' }}</button>
</div> </div>
</div> </div>
<div v-if="successInfo.txid"> <div v-if="successInfo.txid">
<p class="text-xs text-white/50 mb-1">Transaction ID</p> <p class="text-xs text-white/50 mb-1">Transaction ID</p>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<p class="flex-1 text-xs font-mono text-white/80 break-all">{{ successInfo.txid }}</p> <p class="flex-1 text-xs font-mono text-white/80 break-all">{{ successInfo.txid }}</p>
<button <CopyButton class="shrink-0" :value="successInfo.txid" />
class="shrink-0 px-2.5 py-1.5 rounded-lg text-xs glass-button"
@click="copyDetail(successInfo.txid)"
>{{ copiedDetail === successInfo.txid ? 'Copied!' : 'Copy' }}</button>
</div> </div>
</div> </div>
<p v-if="successInfo.note" class="text-xs text-white/60">{{ successInfo.note }}</p> <p v-if="successInfo.note" class="text-xs text-white/60">{{ successInfo.note }}</p>
@@ -258,10 +252,13 @@
import { ref, computed, watch, nextTick } from 'vue' import { ref, computed, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import { useLightningRequired } from '@/composables/useLightningRequired'
import BaseModal from '@/components/BaseModal.vue' import BaseModal from '@/components/BaseModal.vue'
import CopyButton from '@/components/CopyButton.vue'
import ScreensaverRing from '@/components/ScreensaverRing.vue' import ScreensaverRing from '@/components/ScreensaverRing.vue'
const { t } = useI18n() const { t } = useI18n()
const lightning = useLightningRequired()
const props = defineProps<{ show: boolean }>() const props = defineProps<{ show: boolean }>()
const emit = defineEmits<{ close: []; sent: []; scan: [] }>() const emit = defineEmits<{ close: []; sent: []; scan: [] }>()
@@ -321,15 +318,6 @@ const successInfo = ref<{
txid?: string txid?: string
note?: string note?: string
} | null>(null) } | null>(null)
const copiedDetail = ref('')
function copyDetail(text: string) {
navigator.clipboard.writeText(text).catch(() => {})
copiedDetail.value = text
setTimeout(() => {
if (copiedDetail.value === text) copiedDetail.value = ''
}, 1500)
}
const ecashToken = ref('') const ecashToken = ref('')
// "Send all funds" — sweeps the whole on-chain balance (explicit on-chain tab only) // "Send all funds" — sweeps the whole on-chain balance (explicit on-chain tab only)
@@ -530,6 +518,9 @@ function review() {
const method = effectiveMethod.value const method = effectiveMethod.value
const d = dest.value.trim() const d = dest.value.trim()
if (method === 'lightning') { if (method === 'lightning') {
// Gate BEFORE the confirm step, not at submit: walking a user through
// review-and-confirm only to fail on a missing node is the defect.
if (!lightning.requireLightningNode()) return
if (!d) { error.value = t('web5.pasteInvoice'); return } if (!d) { error.value = t('web5.pasteInvoice'); return }
invoiceAmountSats.value = parseBolt11AmountSats(d) invoiceAmountSats.value = parseBolt11AmountSats(d)
} else { } else {
@@ -23,24 +23,39 @@ describe('useLightningRequired', () => {
useLightningRequired().close() useLightningRequired().close()
}) })
it('lets the action through when a Lightning node is installed', () => { it('lets the action through when a Lightning node is running', () => {
packages.value = { lnd: {}, 'bitcoin-knots': {} } packages.value = { lnd: { state: 'running' }, 'bitcoin-knots': { state: 'running' } }
const lightning = useLightningRequired() const lightning = useLightningRequired()
expect(lightning.hasLightningNode()).toBe(true) expect(lightning.lightningStatus()).toBe('running')
expect(lightning.requireLightningNode()).toBe(true) expect(lightning.requireLightningNode()).toBe(true)
expect(lightning.show.value).toBe(false) expect(lightning.show.value).toBe(false)
}) })
it('blocks and raises the install modal when no Lightning node is installed', () => { it('blocks when the node is present but NOT running, and says so', () => {
packages.value = { 'bitcoin-knots': {}, immich: {} } // The bug this closes: `id in packages` is not "usable". A node with an
// lnd entry in a non-running state produced a raw connection-refused
// error ("Operation failed. Check server logs for details.").
packages.value = { lnd: { state: 'stopped' } }
const lightning = useLightningRequired() const lightning = useLightningRequired()
expect(lightning.lightningStatus()).toBe('stopped')
expect(lightning.requireLightningNode()).toBe(false)
expect(lightning.show.value).toBe(true)
expect(lightning.status.value).toBe('stopped')
})
it('blocks and raises the install modal when no Lightning node is installed', () => {
packages.value = { 'bitcoin-knots': { state: 'running' }, immich: { state: 'running' } }
const lightning = useLightningRequired()
expect(lightning.lightningStatus()).toBe('absent')
expect(lightning.hasLightningNode()).toBe(false) expect(lightning.hasLightningNode()).toBe(false)
// Returns false so the caller bails WITHOUT surfacing an error string — // Returns false so the caller bails WITHOUT surfacing an error string —
// that was the whole defect: a missing prerequisite rendered as a failure. // that was the whole defect: a missing prerequisite rendered as a failure.
expect(lightning.requireLightningNode()).toBe(false) expect(lightning.requireLightningNode()).toBe(false)
expect(lightning.show.value).toBe(true) expect(lightning.show.value).toBe(true)
expect(lightning.status.value).toBe('absent')
}) })
it('shares one modal state across call sites', () => { it('shares one modal state across call sites', () => {
@@ -54,8 +69,8 @@ describe('useLightningRequired', () => {
expect(a.show.value).toBe(false) expect(a.show.value).toBe(false)
}) })
it('treats an empty package list as no Lightning node', () => { it('treats an empty package list as absent', () => {
packages.value = {} packages.value = {}
expect(useLightningRequired().hasLightningNode()).toBe(false) expect(useLightningRequired().lightningStatus()).toBe('absent')
}) })
}) })
@@ -1,51 +1,70 @@
// Shared "this action needs a Lightning node" gate (2026-08-02). // Shared "this action needs a working Lightning node" gate (2026-08-02).
// //
// Creating a Lightning invoice with no Lightning node installed used to fail // Creating a Lightning invoice used to fail at the RPC layer whenever this
// at the RPC layer — `lnd.createinvoice` returns a connection-refused error // node had no usable Lightning implementation — `lnd.createinvoice` returned
// and the Receive screen showed it as a red failure string. That reads as the // connection-refused and the Receive screen rendered "Operation failed. Check
// wallet being broken, when in fact the node simply has no Lightning // server logs for details." That reads as the wallet being broken, when the
// implementation installed yet. // truth is a missing (or stopped) prerequisite the user can act on.
// //
// Callers ask `requireLightningNode()` BEFORE attempting the call. When no // Callers ask `requireLightningNode()` BEFORE attempting the call. When there
// node is installed it opens the global LightningRequiredModal (which offers // is no usable node it opens the global LightningRequiredModal and returns
// to install one) and returns false, so the caller bails without surfacing an // false, so the caller bails without surfacing an error at all.
// error at all.
// //
// Detection is install-state, not reachability, on purpose: an installed node // Keyed on package STATE, not mere presence: `package-data` carries an entry
// that is merely stopped or still starting is a different situation (wait or // for a Lightning app that is known to this node but not actually running, so
// start it) and must NOT be answered with "install a Lightning node". // `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 { ref } from 'vue'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { PackageState } from '@/types/api'
/** Package ids that provide a Lightning node. /** Package ids that provide a Lightning node.
* *
* `lnd` ships today. Core Lightning is the next implementation the modal * `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 * 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 * `available` flag in LightningRequiredModal; nothing else changes. */
* with no other change. */
export const LIGHTNING_NODE_APP_IDS = ['lnd'] as const 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.
* `running` — good to go. */
export type LightningStatus = 'absent' | 'stopped' | 'running'
// Module-scope: one source of truth shared by every caller and the single // Module-scope: one source of truth shared by every caller and the single
// global modal mounted in App.vue. // global modal mounted in App.vue.
const show = ref(false) const show = ref(false)
const status = ref<LightningStatus>('absent')
export function useLightningRequired() { export function useLightningRequired() {
const appStore = useAppStore() const appStore = useAppStore()
/** True when some Lightning implementation is installed on this node. */ /** 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 { function hasLightningNode(): boolean {
const installed = Object.keys(appStore.packages ?? {}) return lightningStatus() === 'running'
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 * Gate a Lightning-only action. Returns true to proceed; returns false and
* opens the install modal when this node has no Lightning implementation. * opens the modal (in the mode matching why) when there is no usable node.
*/ */
function requireLightningNode(): boolean { function requireLightningNode(): boolean {
if (hasLightningNode()) return true const s = lightningStatus()
if (s === 'running') return true
status.value = s
show.value = true show.value = true
return false return false
} }
@@ -54,5 +73,5 @@ export function useLightningRequired() {
show.value = false show.value = false
} }
return { show, hasLightningNode, requireLightningNode, close } return { show, status, lightningStatus, hasLightningNode, requireLightningNode, close }
} }