fix: BUG-1 CSRF, TASK-8 H2/H3/H4, BUG-20/37/40/41 — 7 bugs fixed

BUG-1 (P0): CSRF tokens now HMAC-derived from session token instead of
random — survives backend restarts, eliminates cookie/header race conditions.
Frontend retries 403s as belt-and-suspenders.

TASK-8 H2: federation.peer-joined verifies ed25519 signature on join messages.
TASK-8 H3: federation.peer-address-changed requires signed proof from known peer.
TASK-8 H4: Rust backend default bind 0.0.0.0 → 127.0.0.1 (nginx proxies all).

BUG-20: ElectrumX index estimate string fixed from ~55GB to ~130GB.
BUG-37: App card Start/Stop buttons split into loading vs interactive states
        to prevent WebSocket state flicker during container scans.
BUG-40: Uninstall modal uses Teleport to body with z-[3000] for full overlay.
BUG-41: Uninstalling overlay on card + optimistic store removal.

Updated MASTER_PLAN.md and BETA-PROGRESS.md to reflect all completed work.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-18 22:05:21 +00:00
parent db2ad27340
commit 1a31c33ae8
12 changed files with 404 additions and 271 deletions

View File

@@ -68,6 +68,12 @@ class RPCClient {
}
throw new Error('Session expired')
}
// CSRF 403: retry once after short delay (cookie may have been
// updated by a concurrent Set-Cookie response not yet visible to JS)
if (response.status === 403 && attempt < maxRetries - 1) {
await new Promise((r) => setTimeout(r, 300))
continue
}
const err = new Error(`HTTP ${response.status}: ${response.statusText}`)
const isRetryable = response.status === 502 || response.status === 503
if (isRetryable && attempt < maxRetries - 1) {

View File

@@ -410,50 +410,52 @@
<p class="text-white/70">{{ t('appDetails.notFoundMessage') }}</p>
</div>
<!-- Uninstall Confirmation Modal -->
<Transition name="modal">
<div
v-if="uninstallModal.show"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
@click="closeUninstallModal()"
>
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
<!-- Uninstall Confirmation Modal Teleport to body to escape sidebar stacking context -->
<Teleport to="body">
<Transition name="modal">
<div
ref="uninstallModalRef"
@click.stop
class="glass-card p-6 md:p-8 max-w-md w-full relative z-10"
v-if="uninstallModal.show"
class="fixed inset-0 z-[3000] flex items-center justify-center p-4"
@click="closeUninstallModal()"
>
<div class="flex items-start gap-4 mb-6">
<div class="p-3 bg-red-500/20 rounded-lg flex-shrink-0">
<svg class="w-6 h-6 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div class="absolute inset-0 bg-black/60 backdrop-blur-md"></div>
<div
ref="uninstallModalRef"
@click.stop
class="glass-card p-6 md:p-8 max-w-md w-full relative z-10"
>
<div class="flex items-start gap-4 mb-6">
<div class="p-3 bg-red-500/20 rounded-lg flex-shrink-0">
<svg class="w-6 h-6 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-xl font-semibold text-white mb-2">{{ t('appDetails.uninstallTitle') }}</h3>
<p class="text-white/70 text-sm">
{{ t('appDetails.uninstallConfirm', { name: uninstallModal.appTitle }) }}
</p>
</div>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-xl font-semibold text-white mb-2">{{ t('appDetails.uninstallTitle') }}</h3>
<p class="text-white/70 text-sm">
{{ t('appDetails.uninstallConfirm', { name: uninstallModal.appTitle }) }}
</p>
</div>
</div>
<div class="flex flex-col-reverse md:flex-row gap-3 md:justify-end">
<button
@click="closeUninstallModal()"
class="w-full md:w-auto px-6 py-3 glass-button rounded-lg text-sm font-medium"
>
{{ t('common.cancel') }}
</button>
<button
@click="confirmUninstall"
class="w-full md:w-auto px-6 py-3 bg-red-600/80 hover:bg-red-600 rounded-lg text-white text-sm font-medium transition-colors"
>
{{ t('common.uninstall') }}
</button>
<div class="flex flex-col-reverse md:flex-row gap-3 md:justify-end">
<button
@click="closeUninstallModal()"
class="w-full md:w-auto px-6 py-3 glass-button rounded-lg text-sm font-medium"
>
{{ t('common.cancel') }}
</button>
<button
@click="confirmUninstall"
class="w-full md:w-auto px-6 py-3 bg-red-600/80 hover:bg-red-600 rounded-lg text-white text-sm font-medium transition-colors"
>
{{ t('common.uninstall') }}
</button>
</div>
</div>
</div>
</div>
</Transition>
</Transition>
</Teleport>
<!-- Action error toast -->
<Transition name="fade">

View File

@@ -89,9 +89,23 @@
@click="goToApp(id as string)"
@keydown.enter="goToApp(id as string)"
>
<!-- Uninstalling overlay -->
<div
v-if="uninstallingApps.has(id as string)"
class="absolute inset-0 z-20 flex items-center justify-center bg-black/70 backdrop-blur-sm rounded-xl"
>
<div class="flex items-center gap-3 text-white/90">
<svg class="animate-spin h-5 w-5" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span class="text-sm font-medium">{{ t('common.uninstalling') }}...</span>
</div>
</div>
<!-- Uninstall Icon (not for web-only apps) -->
<button
v-if="!isWebOnlyApp(id as string)"
v-if="!isWebOnlyApp(id as string) && !uninstallingApps.has(id as string)"
@click.stop="showUninstallModal(id as string, pkg)"
class="absolute top-4 right-4 p-2 rounded-lg text-white/60 hover:text-red-400 hover:bg-red-500/20 transition-colors z-10"
:aria-label="`${t('common.uninstall')} ${pkg.manifest?.title || id}`"
@@ -140,8 +154,8 @@
</div>
</div>
<!-- Quick Actions -->
<div class="mt-4 flex gap-2">
<!-- Quick Actions hide during uninstall, freeze during loading actions to prevent flicker -->
<div v-if="!uninstallingApps.has(id as string)" class="mt-4 flex gap-2">
<button
v-if="canLaunch(pkg)"
data-controller-launch-btn
@@ -152,105 +166,105 @@
<svg v-if="opensInTab(id as string)" class="w-3.5 h-3.5 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>
</button>
<button
v-if="!isWebOnlyApp(id as string) && (pkg.state === 'stopped' || pkg.state === 'exited')"
v-if="!isWebOnlyApp(id as string) && !loadingActions[id as string] && (pkg.state === 'stopped' || pkg.state === 'exited')"
@click.stop="startApp(id as string)"
:disabled="loadingActions[id as string]"
class="flex-1 px-4 py-2 bg-green-500/20 border border-green-500/40 rounded-lg text-green-200 text-sm font-medium hover:bg-green-500/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
class="flex-1 px-4 py-2 bg-green-500/20 border border-green-500/40 rounded-lg text-green-200 text-sm font-medium hover:bg-green-500/30 transition-colors flex items-center justify-center gap-2"
>
<svg
v-if="loadingActions[id as string]"
class="animate-spin h-4 w-4"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>{{ loadingActions[id as string] ? t('common.starting') : t('common.start') }}</span>
<span>{{ t('common.start') }}</span>
</button>
<button
v-if="!isWebOnlyApp(id as string) && (pkg.state === 'running' || pkg.state === 'starting')"
@click.stop="stopApp(id as string)"
:disabled="loadingActions[id as string]"
class="flex-1 px-4 py-2 bg-yellow-500/20 border border-yellow-500/40 rounded-lg text-yellow-200 text-sm font-medium hover:bg-yellow-500/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
v-if="!isWebOnlyApp(id as string) && loadingActions[id as string] && (pkg.state === 'stopped' || pkg.state === 'exited' || pkg.state === 'starting')"
disabled
class="flex-1 px-4 py-2 bg-green-500/20 border border-green-500/40 rounded-lg text-green-200 text-sm font-medium opacity-50 cursor-not-allowed flex items-center justify-center gap-2"
>
<svg
v-if="loadingActions[id as string]"
class="animate-spin h-4 w-4"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<svg class="animate-spin h-4 w-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>{{ loadingActions[id as string] ? t('common.stopping') : t('common.stop') }}</span>
<span>{{ t('common.starting') }}</span>
</button>
<button
v-if="!isWebOnlyApp(id as string) && !loadingActions[id as string] && (pkg.state === 'running' || pkg.state === 'starting')"
@click.stop="stopApp(id as string)"
class="flex-1 px-4 py-2 bg-yellow-500/20 border border-yellow-500/40 rounded-lg text-yellow-200 text-sm font-medium hover:bg-yellow-500/30 transition-colors flex items-center justify-center gap-2"
>
<span>{{ t('common.stop') }}</span>
</button>
<button
v-if="!isWebOnlyApp(id as string) && loadingActions[id as string] && (pkg.state === 'running' || pkg.state === 'starting' || pkg.state === 'stopping')"
disabled
class="flex-1 px-4 py-2 bg-yellow-500/20 border border-yellow-500/40 rounded-lg text-yellow-200 text-sm font-medium opacity-50 cursor-not-allowed flex items-center justify-center gap-2"
>
<svg class="animate-spin h-4 w-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>{{ t('common.stopping') }}</span>
</button>
</div>
</div>
</div>
<!-- Uninstall Confirmation Modal -->
<Transition name="modal">
<div
v-if="uninstallModal.show"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
@click="closeUninstallModal()"
>
<div class="absolute inset-0 bg-black/60 backdrop-blur-md"></div>
<!-- Uninstall Confirmation Modal Teleport to body to escape sidebar stacking context -->
<Teleport to="body">
<Transition name="modal">
<div
ref="uninstallModalRef"
@click.stop
role="dialog"
aria-modal="true"
aria-labelledby="uninstall-dialog-title"
class="glass-card p-6 max-w-2xl w-full relative z-10"
v-if="uninstallModal.show"
class="fixed inset-0 z-[3000] flex items-center justify-center p-4"
@click="closeUninstallModal()"
>
<div class="flex items-start gap-4 mb-4">
<div class="p-3 bg-red-500/20 rounded-lg">
<svg class="w-6 h-6 text-red-400" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div class="absolute inset-0 bg-black/60 backdrop-blur-md"></div>
<div
ref="uninstallModalRef"
@click.stop
role="dialog"
aria-modal="true"
aria-labelledby="uninstall-dialog-title"
class="glass-card p-6 max-w-2xl w-full relative z-10"
>
<div class="flex items-start gap-4 mb-4">
<div class="p-3 bg-red-500/20 rounded-lg">
<svg class="w-6 h-6 text-red-400" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div class="flex-1">
<h3 id="uninstall-dialog-title" class="text-xl font-semibold text-white mb-2">{{ t('apps.uninstallTitle') }}</h3>
<p class="text-white/70">
{{ t('apps.uninstallConfirm', { name: uninstallModal.appTitle }) }}
</p>
</div>
</div>
<div class="flex-1">
<h3 id="uninstall-dialog-title" class="text-xl font-semibold text-white mb-2">{{ t('apps.uninstallTitle') }}</h3>
<p class="text-white/70">
{{ t('apps.uninstallConfirm', { name: uninstallModal.appTitle }) }}
</p>
</div>
</div>
<div class="flex gap-3 justify-end">
<button
@click="closeUninstallModal()"
class="px-4 py-2 glass-button rounded-lg text-sm font-medium"
>
{{ t('common.cancel') }}
</button>
<button
@click="confirmUninstall"
:disabled="uninstalling"
class="px-4 py-2 bg-red-600/80 hover:bg-red-600 rounded-lg text-white text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
<svg
v-if="uninstalling"
class="animate-spin h-4 w-4"
aria-hidden="true"
fill="none"
viewBox="0 0 24 24"
<div class="flex gap-3 justify-end">
<button
@click="closeUninstallModal()"
class="px-4 py-2 glass-button rounded-lg text-sm font-medium"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>{{ uninstalling ? t('common.uninstalling') : t('common.uninstall') }}</span>
</button>
{{ t('common.cancel') }}
</button>
<button
@click="confirmUninstall"
:disabled="uninstalling"
class="px-4 py-2 bg-red-600/80 hover:bg-red-600 rounded-lg text-white text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
<svg
v-if="uninstalling"
class="animate-spin h-4 w-4"
aria-hidden="true"
fill="none"
viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>{{ uninstalling ? t('common.uninstalling') : t('common.uninstall') }}</span>
</button>
</div>
</div>
</div>
</div>
</Transition>
</Transition>
</Teleport>
@@ -576,19 +590,25 @@ function showUninstallModal(id: string, pkg: PackageDataEntry) {
}
const uninstalling = ref(false)
const uninstallingApps = ref<Set<string>>(new Set())
async function confirmUninstall() {
const { appId } = uninstallModal.value
uninstalling.value = true
try {
await store.uninstallPackage(appId)
uninstallModal.value.show = false
uninstallingApps.value.add(appId)
await store.uninstallPackage(appId)
// Optimistically remove from store so card disappears immediately
if (store.packages && store.packages[appId]) {
delete store.packages[appId]
}
} catch (err) {
if (import.meta.env.DEV) console.error('Failed to uninstall app:', err)
showActionError(`Failed to uninstall app: ${err instanceof Error ? err.message : 'Unknown error'}`)
uninstallModal.value.show = false
} finally {
uninstallingApps.value.delete(appId)
uninstalling.value = false
}
}

View File

@@ -249,17 +249,25 @@
</div>
</div>
<div class="flex items-center gap-2">
<!-- Incoming Transactions Badge -->
<!-- Transactions button switches to incoming state when pending txs exist -->
<button
v-if="incomingTxCount > 0"
@click="showIncomingTxPanel = !showIncomingTxPanel"
class="incoming-tx-badge shrink-0"
@click="incomingTxCount > 0 ? showIncomingTxPanel = !showIncomingTxPanel : showTransactionsModal = true"
:class="incomingTxCount > 0 ? 'incoming-tx-badge' : 'text-white/50 hover:text-white/80 text-xs px-2 py-1 rounded-lg bg-white/5 hover:bg-white/10 transition-colors'"
class="shrink-0"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
<span>Incoming {{ incomingTxCount }}</span>
<span class="incoming-tx-ping"></span>
<template v-if="incomingTxCount > 0">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
<span>Incoming {{ incomingTxCount }}</span>
<span class="incoming-tx-ping"></span>
</template>
<template v-else>
<svg class="w-3.5 h-3.5 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
</svg>
<span>Transactions</span>
</template>
</button>
<RouterLink to="/dashboard/web5" :aria-label="t('home.goToWeb5')" class="text-white/60 hover:text-white transition-colors">
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -345,19 +353,19 @@
<span class="text-purple-400 text-sm font-medium">{{ walletEcash.toLocaleString() }} sats</span>
</div>
</div>
<div class="home-card-buttons grid grid-cols-4 gap-2 mt-auto pt-4 shrink-0">
<div class="home-card-buttons grid gap-2 mt-auto pt-4 shrink-0" :class="isDev ? 'grid-cols-4' : 'grid-cols-3'">
<button @click="showSendModal = true" class="home-card-btn px-3 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
{{ t('common.send') }}
</button>
<button @click="showReceiveModal = true" class="home-card-btn px-3 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
{{ t('web5.receiveBitcoin') }}
</button>
<button @click="devFaucet" class="home-card-btn px-3 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors text-green-400">
<button @click="showTransactionsModal = true" class="home-card-btn px-3 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
Transactions
</button>
<button v-if="isDev" @click="devFaucet" class="home-card-btn px-3 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors text-green-400">
Faucet
</button>
<RouterLink to="/dashboard/web5" class="home-card-btn px-3 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
Web5
</RouterLink>
</div>
</div>
</div>
@@ -397,7 +405,7 @@
<div class="p-4 bg-white/5 rounded-lg">
<div class="flex items-center justify-between mb-2">
<p class="text-xs text-white/60">{{ t('home.cpu') }}</p>
<p class="text-sm font-medium" :class="gaugeTextColor(systemStats.cpuPercent)">{{ systemStats.cpuPercent.toFixed(0) }}%</p>
<p class="text-sm font-medium" :class="gaugeTextColor(systemStats.cpuPercent)">{{ (systemStats.cpuPercent ?? 0).toFixed(0) }}%</p>
</div>
<div class="w-full h-2 bg-white/10 rounded-full overflow-hidden">
<div class="h-full rounded-full transition-all duration-500" :class="gaugeBarColor(systemStats.cpuPercent)" :style="{ width: systemStats.cpuPercent + '%' }"></div>
@@ -477,9 +485,10 @@
</div>
</div>
<!-- Send/Receive Bitcoin Modals -->
<!-- Wallet Modals -->
<SendBitcoinModal :show="showSendModal" @close="showSendModal = false" @sent="loadWeb5Status()" />
<ReceiveBitcoinModal :show="showReceiveModal" @close="showReceiveModal = false" @received="loadWeb5Status()" />
<TransactionsModal :show="showTransactionsModal" :transactions="walletTransactions" @close="showTransactionsModal = false" />
</template>
<script setup lang="ts">
@@ -488,6 +497,7 @@ import { RouterLink, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import SendBitcoinModal from '@/components/SendBitcoinModal.vue'
import ReceiveBitcoinModal from '@/components/ReceiveBitcoinModal.vue'
import TransactionsModal from '@/components/TransactionsModal.vue'
import { useAppStore } from '../stores/app'
const { t } = useI18n()
@@ -503,6 +513,7 @@ import { rpcClient } from '@/api/rpc-client'
const router = useRouter()
const uiMode = useUIModeStore()
const isDev = import.meta.env.DEV
const homeTab = ref<'dashboard' | 'setup'>('dashboard')
const topGoals = GOALS.slice(0, 3)
@@ -718,18 +729,19 @@ onMounted(async () => {
loadWeb5Status()
})
// Send/Receive modals
// Wallet modals
const showSendModal = ref(false)
const showReceiveModal = ref(false)
const showTransactionsModal = ref(false)
// Dev faucet — adds mock funds to all wallet types
// Dev faucet — adds mock funds to all wallet types (dev mode only)
async function devFaucet() {
try {
const res = await rpcClient.call<{ message: string }>({ method: 'dev.faucet', params: { amount_sats: 1_000_000 } })
console.log('[Faucet]', res.message)
if (import.meta.env.DEV) console.log('[Faucet]', res.message)
await loadWeb5Status()
} catch (err) {
console.error('[Faucet] Error:', err)
if (import.meta.env.DEV) console.error('[Faucet] Error:', err)
}
}