feat(setup): Zeus lightning journey — fund-wallet step, IBD timer + finish-setup toast, channel suggestions

Every Lightning setup (Open a Shop, Accept Payments, Run a Lightning Node)
gains a guided path to a working channel:

- New "Fund Your Bitcoin Wallet" step, gated on the blockchain finishing
  its initial sync: while syncing it shows a live progress bar with a
  counting-down time-remaining estimate; once synced it shows the on-chain
  balance and a "Fund Wallet" button that opens the receive modal with a
  fresh address, QR, and the Zeus channel limits (min 150,000 / max
  1,500,000 sats) noted.
- When the sync finishes while a Lightning setup is mid-flight, a toast
  pops with a "Finish setup" link straight back to the wizard (toasts now
  support action links). If several setups are in flight, one is chosen —
  the shared steps complete the lightning part of any of them.
- Channel steps are Zeus-branded (logo + copy) and land on the Lightning
  Channels screen, which now carries an "Open a channel with Zeus" card
  that prefills the open-channel modal with the Olympus peer URI, 150k
  sats, and private-channel checked. "Get Zeus" links to zeusln.com.
- Setup completion now actually requires walking the manual steps (fund,
  open channel, configure) — previously any step whose app was installed
  was silently auto-ticked and running apps marked the whole goal done.
- Completion CTAs now go to the app you just set up ("Go to my shop
  (BTCPay)" etc.) instead of the generic services list; iframe apps open
  in the on-top app overlay.
- On-chain send modal gains a "Send all funds" sweep toggle, backed by
  LND's send_all on the backend (amount no longer required when sweeping).
- Demo/mock: bitcoin.getinfo now returns the block_height/sync_progress
  contract the UI reads and simulates a ~90s IBD ramp per visitor so the
  timer, toast, and fund flow can all be demoed live; channel list data
  fixed (status/channel_point/liquidity totals — the panel previously
  crashed on the missing status field); sendcoins supports send_all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-16 21:49:09 -04:00
parent 90bedc2a25
commit 3aebbcbbb8
16 changed files with 658 additions and 63 deletions

View File

@ -95,33 +95,54 @@ impl RpcHandler {
.get("addr")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'addr' parameter"))?;
let amount = params
.get("amount")
.and_then(|v| v.as_i64())
.ok_or_else(|| anyhow::anyhow!("Missing 'amount' parameter (sats)"))?;
if amount < 546 {
return Err(anyhow::anyhow!(
"Amount must be at least 546 sats (dust limit)"
));
}
if amount > 21_000_000 * 100_000_000 {
return Err(anyhow::anyhow!("Amount exceeds maximum Bitcoin supply"));
}
// send_all sweeps the entire confirmed on-chain balance (LND computes
// the amount after fees); amount is required otherwise.
let send_all = params
.get("send_all")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let amount = if send_all {
None
} else {
let amount = params
.get("amount")
.and_then(|v| v.as_i64())
.ok_or_else(|| anyhow::anyhow!("Missing 'amount' parameter (sats)"))?;
if amount < 546 {
return Err(anyhow::anyhow!(
"Amount must be at least 546 sats (dust limit)"
));
}
if amount > 21_000_000 * 100_000_000 {
return Err(anyhow::anyhow!("Amount exceeds maximum Bitcoin supply"));
}
Some(amount)
};
// Validate Bitcoin address format (basic: length and allowed chars)
if addr.len() < 14 || addr.len() > 90 || !addr.chars().all(|c| c.is_ascii_alphanumeric()) {
return Err(anyhow::anyhow!("Invalid Bitcoin address format"));
}
info!(addr = addr, amount = amount, "Sending on-chain Bitcoin");
info!(
addr = addr,
amount = amount,
send_all = send_all,
"Sending on-chain Bitcoin"
);
let (client, macaroon_hex) = self.lnd_client().await?;
let send_body = serde_json::json!({
"addr": addr,
"amount": amount.to_string(),
});
let send_body = match amount {
Some(amount) => serde_json::json!({
"addr": addr,
"amount": amount.to_string(),
}),
None => serde_json::json!({
"addr": addr,
"send_all": true,
}),
};
let resp = client
.post(format!("{LND_REST_BASE_URL}/v1/transactions"))

View File

@ -3394,14 +3394,19 @@ app.post('/rpc/v1', (req, res) => {
}
case 'lnd.listchannels': {
// Shape matches the real backend: status + channel_point are required
// by the channels panel; totals feed the liquidity summary tiles.
const channels = [
{ chan_id: '840921088114688', remote_pubkey: '031b301307574bbe9b9ac7b79cbe1700e31e544513eae0b5d7497483083f99e581', capacity: 1500000, local_balance: 950000, remote_balance: 550000, active: true, status: 'active', channel_point: randomHex(32) + ':0', peer_alias: 'Olympus by ZEUS' },
{ chan_id: '840921088114689', remote_pubkey: '03abcdef12345678901234567890123456789012345678901234567890abcdef12', capacity: 2000000, local_balance: 1200000, remote_balance: 800000, active: true, status: 'active', channel_point: randomHex(32) + ':1', peer_alias: 'WalletOfSatoshi' },
{ chan_id: '840921088114690', remote_pubkey: '02fedcba98765432109876543210987654321098765432109876543210fedcba98', capacity: 10000000, local_balance: 4500000, remote_balance: 5500000, active: true, status: 'active', channel_point: randomHex(32) + ':0', peer_alias: 'Voltage' },
{ chan_id: '840921088114691', remote_pubkey: '03456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123', capacity: 3000000, local_balance: 100000, remote_balance: 2900000, active: false, status: 'inactive', channel_point: randomHex(32) + ':0', peer_alias: 'Kraken' },
]
return res.json({
result: {
channels: [
{ chan_id: '840921088114688', remote_pubkey: '02778f4a', capacity: 5000000, local_balance: 2450000, remote_balance: 2550000, active: true, peer_alias: 'ACINQ Signet' },
{ chan_id: '840921088114689', remote_pubkey: '03abcdef', capacity: 2000000, local_balance: 1200000, remote_balance: 800000, active: true, peer_alias: 'WalletOfSatoshi' },
{ chan_id: '840921088114690', remote_pubkey: '02fedcba', capacity: 10000000, local_balance: 4500000, remote_balance: 5500000, active: true, peer_alias: 'Voltage' },
{ chan_id: '840921088114691', remote_pubkey: '03456789', capacity: 3000000, local_balance: 100000, remote_balance: 2900000, active: true, peer_alias: 'Kraken' },
],
channels,
total_outbound: channels.reduce((s, c) => s + c.local_balance, 0),
total_inbound: channels.reduce((s, c) => s + c.remote_balance, 0),
},
})
}
@ -3445,7 +3450,10 @@ app.post('/rpc/v1', (req, res) => {
}
case 'lnd.sendcoins': {
const amt = params?.amount || params?.amt || 50000
// send_all sweeps the entire on-chain balance (minus a mock fee)
const amt = params?.send_all
? Math.max(0, walletState.onchain_sats - 250)
: (params?.amount || params?.amt || 50000)
walletState.onchain_sats = Math.max(0, walletState.onchain_sats - amt)
const txid = randomHex(32)
walletState.transactions.unshift({
@ -3607,15 +3615,29 @@ app.post('/rpc/v1', (req, res) => {
}
case 'bitcoin.getinfo': {
// Demo IBD simulation: the first call of a session arms a ~90s ramp
// from 98.2% → 100% so the setup wizard can demo the live sync timer
// and the "finish setup" toast that fires when IBD completes.
// (The real backend returns { block_height, sync_progress } — a 01
// fraction — which is what the frontend reads; the bitcoin-core-style
// fields are kept for any legacy consumers.)
if (!walletState.ibd_started_at) walletState.ibd_started_at = Date.now()
const IBD_RAMP_MS = 90_000
const elapsed = Date.now() - walletState.ibd_started_at
const syncProgress = Math.min(1, 0.982 + 0.018 * (elapsed / IBD_RAMP_MS))
const tipHeight = 892451
const height = Math.round(tipHeight * syncProgress)
return res.json({
result: {
chain: 'signet',
blocks: 892451,
headers: 892451,
block_height: height,
sync_progress: syncProgress,
blocks: height,
headers: tipHeight,
bestblockhash: 'a1b2c3d4e5f6' + '0'.repeat(58),
difficulty: 0.001126515290698186,
mediantime: Math.floor(Date.now() / 1000) - 300,
verificationprogress: 1.0,
verificationprogress: syncProgress,
chainwork: '000000000000000000000000000000000000000000000000000000000001a2b3',
size_on_disk: 210_000_000,
pruned: false,

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

View File

@ -16,6 +16,39 @@
</div>
</div>
<!-- Zeus channel suggestion -->
<div class="glass-card p-4 mb-4 border border-orange-500/25">
<div class="flex flex-col sm:flex-row sm:items-center gap-4">
<img
src="/assets/img/app-icons/zeus.webp"
alt="Zeus"
class="w-12 h-12 rounded-xl shrink-0 border border-white/10"
/>
<div class="flex-1 min-w-0">
<p class="text-white/90 text-sm font-semibold mb-0.5">Open a channel with Zeus</p>
<p class="text-white/55 text-xs leading-relaxed">
Pair your node with the Zeus mobile wallet open a channel to their Olympus node and
start sending and receiving Lightning payments from your phone.
Minimum 150,000 · maximum 1,500,000 sats.
</p>
</div>
<div class="flex sm:flex-col items-center gap-2 shrink-0">
<button
@click="openZeusChannel"
class="glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap"
>
Open Channel
</button>
<a
href="https://zeusln.com"
target="_blank"
rel="noopener noreferrer"
class="text-xs text-orange-400/80 hover:text-orange-300 whitespace-nowrap"
>Get Zeus </a>
</div>
</div>
</div>
<!-- Open Channel Button -->
<div class="flex justify-end mb-4">
<button @click="showOpenModal = true" class="glass-button px-4 py-2 rounded-lg text-sm font-medium flex items-center gap-2">
@ -74,15 +107,15 @@
<span
class="w-2 h-2 rounded-full"
:class="{
'bg-green-400': ch.status === 'active',
'bg-yellow-400': ch.status === 'pending_open',
'bg-red-400': ch.status === 'inactive',
'bg-green-400': channelStatus(ch) === 'active',
'bg-yellow-400': channelStatus(ch) === 'pending_open',
'bg-red-400': channelStatus(ch) === 'inactive',
}"
></span>
<span class="text-white/80 text-sm font-medium capitalize">{{ ch.status.replace('_', ' ') }}</span>
<span class="text-white/80 text-sm font-medium capitalize">{{ channelStatus(ch).replace('_', ' ') }}</span>
</div>
<button
v-if="ch.status !== 'pending_open'"
v-if="channelStatus(ch) !== 'pending_open'"
@click="confirmClose(ch)"
class="text-red-400/70 hover:text-red-400 text-xs transition-colors"
>
@ -270,8 +303,13 @@ interface Channel {
local_balance: number
remote_balance: number
active: boolean
status: string
channel_point: string
status?: string
channel_point?: string
}
/** Status with a fallback derived from `active` for backends that omit it */
function channelStatus(ch: Channel): string {
return ch.status ?? (ch.active ? 'active' : 'inactive')
}
type FeePreset = 'standard' | 'medium' | 'fast' | 'custom'
@ -288,6 +326,11 @@ const error = ref<string | null>(null)
const channels = ref<Channel[]>([])
const summary = ref({ total_inbound: 0, total_outbound: 0 })
// Olympus by ZEUS the LSP node behind the Zeus mobile wallet.
// Channel limits: min 150,000 / max 1,500,000 sats.
const OLYMPUS_PEER_URI =
'031b301307574bbe9b9ac7b79cbe1700e31e544513eae0b5d7497483083f99e581@45.79.192.236:9735'
const showOpenModal = ref(false)
const defaultOpenForm = () => ({
peerUri: '',
@ -297,6 +340,19 @@ const defaultOpenForm = () => ({
customConfTarget: null as number | null,
customSatPerVbyte: null as number | null,
})
/** Prefill the open-channel modal for a Zeus (Olympus) channel */
function openZeusChannel() {
openForm.value = {
...defaultOpenForm(),
peerUri: OLYMPUS_PEER_URI,
amount: 150000,
// Olympus only accepts unannounced channels
private: true,
}
openError.value = null
showOpenModal.value = true
}
const openForm = ref(defaultOpenForm())
const openingChannel = ref(false)
const openError = ref<string | null>(null)
@ -313,7 +369,7 @@ function formatSats(sats: number): string {
}
function fundingTxid(ch: Channel): string {
const txid = ch.channel_point.split(':')[0] || ''
const txid = ch.channel_point?.split(':')[0] || ''
return /^[0-9a-fA-F]{64}$/.test(txid) ? txid : ''
}

View File

@ -31,6 +31,9 @@
<!-- On-chain -->
<div v-if="receiveMethod === 'onchain'">
<div v-if="note" class="mb-3 p-3 rounded-lg bg-orange-500/10 border border-orange-500/20 text-sm text-white/80 leading-relaxed">
{{ note }}
</div>
<div v-if="onchainAddress" class="mb-3 p-3 bg-white/5 rounded-lg text-center">
<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>
@ -77,7 +80,7 @@
</template>
<script setup lang="ts">
import { ref, nextTick } from 'vue'
import { ref, nextTick, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import BaseModal from '@/components/BaseModal.vue'
@ -85,9 +88,21 @@ import { explainReceiveAddressFailure } from '@/utils/bitcoinReceive'
const { t } = useI18n()
defineProps<{ show: boolean }>()
const props = defineProps<{
show: boolean
/** Optional info banner shown on the on-chain tab (e.g. Zeus channel limits) */
note?: string
/** Generate an on-chain address immediately when the modal opens */
autoGenerate?: boolean
}>()
const emit = defineEmits<{ close: []; received: [] }>()
watch(() => props.show, (open) => {
if (open && props.autoGenerate && receiveMethod.value === 'onchain' && !onchainAddress.value) {
void receive()
}
})
const receiveMethod = ref<'lightning' | 'onchain' | 'ecash' | 'ark'>('onchain')
const invoiceAmount = ref<number>(0)
const invoiceMemo = ref('')

View File

@ -16,8 +16,30 @@
</div>
<div class="mb-3">
<label class="text-white/60 text-sm block mb-1">{{ t('sendBitcoin.amountSats') }}</label>
<input v-model.number="amount" type="number" min="1" placeholder="1000" class="w-full input-glass" />
<div class="flex items-center justify-between mb-1">
<label class="text-white/60 text-sm">{{ t('sendBitcoin.amountSats') }}</label>
<button
v-if="sendMethod === 'onchain'"
@click="toggleSendAll"
class="text-xs px-2 py-0.5 rounded border transition-colors"
:class="sendAll
? 'bg-orange-500/20 border-orange-500/40 text-orange-300'
: 'bg-white/5 border-white/15 text-white/60 hover:text-white/90'"
>
Send all funds
</button>
</div>
<input
v-model.number="amount"
type="number"
min="1"
:placeholder="sendAll ? '' : '1000'"
:disabled="sendAll"
class="w-full input-glass disabled:opacity-50"
/>
<p v-if="sendAll" class="text-xs text-white/50 mt-1">
Sweeps your entire on-chain balance{{ onchainBalance !== null ? ` (~${onchainBalance.toLocaleString()} sats)` : '' }} minus network fees.
</p>
</div>
<div v-if="effectiveMethod !== 'ecash'" class="mb-3">
@ -47,7 +69,7 @@
<div class="flex gap-3">
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
<button @click="send" :disabled="processing || !amount" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
<button @click="send" :disabled="processing || (!amount && !isSweep)" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
{{ processing ? t('common.sending') : t('common.send') }}
</button>
</div>
@ -55,7 +77,7 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import BaseModal from '@/components/BaseModal.vue'
@ -75,6 +97,23 @@ const resultHash = ref('')
const resultArk = ref('')
const ecashToken = ref('')
// "Send all funds" sweeps the whole on-chain balance (explicit on-chain tab only)
const sendAll = ref(false)
const onchainBalance = ref<number | null>(null)
const isSweep = computed(() => sendMethod.value === 'onchain' && sendAll.value)
function toggleSendAll() {
sendAll.value = !sendAll.value
if (sendAll.value && onchainBalance.value === null) {
rpcClient.call<{ balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000 })
.then((res) => { onchainBalance.value = res.balance_sats || 0 })
.catch(() => { /* balance hint is best-effort */ })
}
}
// Leaving the on-chain tab disarms the sweep so it can never apply elsewhere
watch(sendMethod, (m) => { if (m !== 'onchain') sendAll.value = false })
const effectiveMethod = computed(() => {
if (sendMethod.value !== 'auto') return sendMethod.value
const amt = amount.value || 0
@ -98,7 +137,8 @@ function copyText(text: string) {
}
async function send() {
if (!amount.value || processing.value) return
if (processing.value) return
if (!amount.value && !isSweep.value) return
processing.value = true
error.value = ''
ecashToken.value = ''
@ -134,7 +174,9 @@ async function send() {
if (!dest.value.trim()) { error.value = t('web5.enterBitcoinAddress'); return }
const res = await rpcClient.call<{ txid: string }>({
method: 'lnd.sendcoins',
params: { addr: dest.value.trim(), amount: amount.value },
params: isSweep.value
? { addr: dest.value.trim(), send_all: true }
: { addr: dest.value.trim(), amount: amount.value },
})
resultTxid.value = res.txid
}

View File

@ -23,7 +23,14 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<span class="text-sm text-white/90 flex-1">{{ toast.message }}</span>
<div class="flex-1 min-w-0">
<span class="text-sm text-white/90">{{ toast.message }}</span>
<button
v-if="toast.action"
@click.stop="runAction(toast)"
class="block mt-1 text-sm font-semibold text-orange-400 hover:text-orange-300 transition-colors"
>{{ toast.action.label }} </button>
</div>
</div>
</TransitionGroup>
</div>
@ -32,10 +39,15 @@
<script setup lang="ts">
import { useToast } from '@/composables/useToast'
import type { ToastVariant } from '@/composables/useToast'
import type { ToastItem, ToastVariant } from '@/composables/useToast'
const { toasts, dismiss } = useToast()
function runAction(toast: ToastItem | Readonly<ToastItem>) {
toast.action?.onClick()
dismiss(toast.id)
}
function variantClass(variant: ToastVariant): string {
switch (variant) {
case 'success': return 'bg-black/70 border-green-500/30 backdrop-blur-md'

View File

@ -0,0 +1,109 @@
import { ref, computed } from 'vue'
import { rpcClient } from '@/api/rpc-client'
/**
* Shared bitcoin sync (IBD) tracker with a live time-remaining estimate.
*
* Polls `bitcoin.getinfo` while at least one consumer holds an acquire()
* lease, samples the sync rate, and exposes a ticking countdown so setup
* screens can show "~2h 14m remaining" that visibly counts down between
* polls. Module-level singleton every consumer sees the same state.
*/
/** Sync fraction (as percent) at which we consider IBD done, matching the Home tile */
export const IBD_SYNCED_AT = 99.9
const POLL_MS = 15_000
const TICK_MS = 1_000
/** Ignore rate samples older than this when estimating */
const SAMPLE_WINDOW_MS = 10 * 60_000
export const bitcoinSyncPercent = ref(0)
export const bitcoinBlockHeight = ref(0)
export const bitcoinSyncAvailable = ref(false)
export const bitcoinSyncLoaded = ref(false)
export const bitcoinSynced = computed(() => bitcoinSyncLoaded.value && bitcoinSyncPercent.value >= IBD_SYNCED_AT)
const etaSeconds = ref<number | null>(null)
/** Human countdown like "2h 14m" / "5m 12s" / "less than a minute", or '' while estimating */
export const bitcoinSyncEtaText = computed(() => {
const s = etaSeconds.value
if (s === null) return ''
if (s < 60) return 'less than a minute'
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
if (h > 0) return `${h}h ${m}m`
const sec = Math.floor(s % 60)
return `${m}m ${sec}s`
})
let samples: { t: number; p: number }[] = []
let etaBase: { at: number; secs: number } | null = null
let pollTimer: ReturnType<typeof setInterval> | null = null
let tickTimer: ReturnType<typeof setInterval> | null = null
let leases = 0
async function poll() {
try {
const btc = await rpcClient.call<{ block_height: number; sync_progress: number }>({
method: 'bitcoin.getinfo',
timeout: 8000,
})
const pct = (btc.sync_progress ?? 0) * 100
bitcoinSyncPercent.value = pct
bitcoinBlockHeight.value = btc.block_height ?? 0
bitcoinSyncAvailable.value = true
bitcoinSyncLoaded.value = true
const now = Date.now()
samples.push({ t: now, p: pct })
samples = samples.filter((s) => now - s.t <= SAMPLE_WINDOW_MS).slice(-50)
if (pct >= IBD_SYNCED_AT) {
etaBase = null
etaSeconds.value = 0
return
}
const first = samples[0]
if (first && now - first.t >= 10_000 && pct > first.p) {
const ratePerSec = (pct - first.p) / ((now - first.t) / 1000)
etaBase = { at: now, secs: (IBD_SYNCED_AT - pct) / ratePerSec }
}
} catch {
bitcoinSyncAvailable.value = false
}
}
function tick() {
if (!etaBase) {
if (!bitcoinSynced.value) etaSeconds.value = null
return
}
etaSeconds.value = Math.max(0, etaBase.secs - (Date.now() - etaBase.at) / 1000)
}
/**
* Hold a polling lease. Returns a release function call it on unmount.
* Polling only runs while at least one lease is held.
*/
export function acquireBitcoinSync(): () => void {
leases++
if (leases === 1) {
void poll()
pollTimer = setInterval(() => void poll(), POLL_MS)
tickTimer = setInterval(tick, TICK_MS)
}
let released = false
return () => {
if (released) return
released = true
leases = Math.max(0, leases - 1)
if (leases === 0) {
if (pollTimer) clearInterval(pollTimer)
if (tickTimer) clearInterval(tickTimer)
pollTimer = null
tickTimer = null
}
}
}

View File

@ -0,0 +1,90 @@
import { computed, watch, watchEffect, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { GOALS } from '@/data/goals'
import { useGoalStore } from '@/stores/goals'
import { useToast } from '@/composables/useToast'
import {
acquireBitcoinSync,
bitcoinSynced,
bitcoinSyncLoaded,
} from '@/composables/useBitcoinSync'
// Session-level guard: the "finish setup" toast fires at most once per page load.
let firedThisSession = false
/**
* Watches for Bitcoin IBD completing while a Lightning setup goal is mid-flight
* and pops a "Finish setup" toast linking back to that goal's wizard (which is
* sitting on the fund-wallet / open-channel steps). Mount once in the
* dashboard layout.
*/
export function useIbdFinishWatcher() {
const goalStore = useGoalStore()
const router = useRouter()
const toast = useToast()
// A goal qualifies while it's in progress and its manual fund/channel steps
// aren't done yet. If several qualify, the first wins — finishing the shared
// fund + channel steps completes the lightning part of any of them.
const pendingLightningGoalId = computed<string | null>(() => {
if (firedThisSession) return null
for (const goal of GOALS) {
const hasFundStep = goal.steps.some((s) => s.action === 'fund')
if (!hasFundStep) continue
if (goalStore.getGoalStatus(goal.id) !== 'in-progress') continue
const done = goalStore.progress[goal.id]?.completedSteps ?? []
const manualPending = goal.steps.some(
(s) => s.action !== 'install' && !done.includes(s.id),
)
if (manualPending) return goal.id
}
return null
})
// Only poll the chain while there's actually a goal waiting on it.
let release: (() => void) | null = null
watchEffect(() => {
const shouldWatch = pendingLightningGoalId.value !== null && !bitcoinSynced.value
if (shouldWatch && !release) {
release = acquireBitcoinSync()
} else if (!shouldWatch && release) {
// Goal finished/reset or the chain synced — stop polling.
release()
release = null
}
})
// Fire only on a REAL transition: we must have observed the chain unsynced
// at least once this session, so a node that's already synced at page load
// doesn't toast.
let sawUnsynced = false
watch([bitcoinSynced, bitcoinSyncLoaded], ([synced, loaded]) => {
if (!loaded) return
if (!synced) {
sawUnsynced = true
return
}
if (!sawUnsynced || firedThisSession) return
const goalId = pendingLightningGoalId.value
if (!goalId) return
firedThisSession = true
toast.action(
'Bitcoin is fully synced — you can now fund your wallet and open your Lightning channel.',
{
label: 'Finish setup',
onClick: () => { router.push(`/dashboard/goals/${goalId}`) },
},
)
if (release) {
release()
release = null
}
})
onUnmounted(() => {
if (release) {
release()
release = null
}
})
}

View File

@ -2,19 +2,25 @@ import { ref, readonly } from 'vue'
export type ToastVariant = 'success' | 'error' | 'info'
export interface ToastAction {
label: string
onClick: () => void
}
export interface ToastItem {
id: number
message: string
variant: ToastVariant
dismissing: boolean
action?: ToastAction
}
const toasts = ref<ToastItem[]>([])
let nextId = 0
function addToast(message: string, variant: ToastVariant = 'info', duration = 3000) {
function addToast(message: string, variant: ToastVariant = 'info', duration = 3000, action?: ToastAction) {
const id = nextId++
toasts.value.push({ id, message, variant, dismissing: false })
toasts.value.push({ id, message, variant, dismissing: false, action })
// Auto-dismiss
if (duration > 0) {
@ -42,6 +48,9 @@ export function useToast() {
success: (msg: string) => addToast(msg, 'success'),
error: (msg: string) => addToast(msg, 'error'),
info: (msg: string) => addToast(msg, 'info'),
/** Toast with an action link (e.g. "Finish setup"). Sticks around longer. */
action: (msg: string, action: ToastAction, opts?: { variant?: ToastVariant; duration?: number }) =>
addToast(msg, opts?.variant ?? 'success', opts?.duration ?? 15000, action),
dismiss: dismissToast,
}
}

View File

@ -1,4 +1,25 @@
import type { GoalDefinition } from '@/types/goals'
import type { GoalDefinition, GoalStep } from '@/types/goals'
/** Zeus (Olympus LSP) channel size limits, in sats */
export const ZEUS_CHANNEL_MIN_SATS = 150_000
export const ZEUS_CHANNEL_MAX_SATS = 1_500_000
export const ZEUS_ICON = '/assets/img/app-icons/zeus.webp'
/**
* Shared "fund the bitcoin wallet" step used by every Lightning goal. Gated on
* the blockchain being fully synced (IBD) the wizard shows a live sync timer
* until then, and a "Fund Wallet" receive flow after.
*/
const FUND_WALLET_STEP: GoalStep = {
id: 'fund-wallet',
title: 'Fund Your Bitcoin Wallet',
description:
"Send bitcoin to your node's on-chain wallet so it can open a Lightning channel. Zeus channels need between 150,000 and 1,500,000 sats. Funding unlocks once your node finishes syncing the blockchain.",
action: 'fund',
isAutomatic: false,
icon: '/assets/img/app-icons/bitcoin-knots.webp',
}
export const GOALS: GoalDefinition[] = [
{
@ -25,6 +46,17 @@ export const GOALS: GoalDefinition[] = [
action: 'install',
isAutomatic: true,
},
{ ...FUND_WALLET_STEP },
{
id: 'open-zeus-channel',
title: 'Open a Channel with Zeus',
description:
'Open a Lightning channel to Zeus, the mobile wallet that pairs perfectly with your node. Fund it with 150,0001,500,000 sats and your shop can accept instant Lightning payments.',
action: 'configure',
isAutomatic: false,
icon: ZEUS_ICON,
ctaLabel: 'Open a channel',
},
{
id: 'install-btcpay',
title: 'Install BTCPay Server',
@ -69,13 +101,16 @@ export const GOALS: GoalDefinition[] = [
action: 'install',
isAutomatic: true,
},
{ ...FUND_WALLET_STEP },
{
id: 'open-channel',
title: 'Open a Lightning Channel',
description: 'Open your first payment channel to start sending and receiving Lightning payments. LND will guide you through it.',
appId: 'lnd',
title: 'Open a Channel with Zeus',
description:
'Open your first payment channel to Zeus, the mobile wallet built for nodes like yours (150,0001,500,000 sats). You can then send and receive Lightning payments from your phone.',
action: 'configure',
isAutomatic: false,
icon: ZEUS_ICON,
ctaLabel: 'Open a channel',
},
],
estimatedTime: '~30 min + sync time',
@ -168,13 +203,16 @@ export const GOALS: GoalDefinition[] = [
action: 'install',
isAutomatic: true,
},
{ ...FUND_WALLET_STEP },
{
id: 'open-channels',
title: 'Open Payment Channels',
description: 'Open channels with well-connected nodes to start routing payments. More channels means more routing opportunities.',
appId: 'lnd',
description:
'Open channels with well-connected nodes to start routing payments. A great first channel is Zeus (150,0001,500,000 sats) — it also puts your node in your pocket. More channels means more routing opportunities.',
action: 'configure',
isAutomatic: false,
icon: ZEUS_ICON,
ctaLabel: 'Open a channel',
},
{
id: 'verify-routing',

View File

@ -88,12 +88,19 @@ export const useGoalStore = defineStore('goals', () => {
([pkgId, pkg]) => matchesAppId(pkgId, appId) && pkg.state === 'running',
),
)
if (allRunning) return 'completed'
// Manual steps (fund the wallet, open a channel, configure the store…)
// must be walked through too — running apps alone don't finish a goal.
const done = progress.value[goalId]?.completedSteps ?? []
const allManualDone = goal.steps
.filter((s) => s.action !== 'install')
.every((s) => done.includes(s.id))
if (allRunning && allManualDone) return 'completed'
const anyInstalled = goal.requiredApps.some((appId) =>
Object.keys(packages).some((pkgId) => matchesAppId(pkgId, appId)),
)
if (anyInstalled || progress.value[goalId]) return 'in-progress'
if (allRunning || anyInstalled || progress.value[goalId]) return 'in-progress'
return 'not-started'
}

View File

@ -17,8 +17,16 @@ export interface GoalStep {
title: string
description: string
appId?: string
action: 'install' | 'configure' | 'verify' | 'info'
/**
* 'fund' renders the bitcoin-wallet funding UI: gated on IBD completion
* (with a live sync timer), then a "Fund Wallet" receive flow.
*/
action: 'install' | 'configure' | 'verify' | 'info' | 'fund'
isAutomatic: boolean
/** Custom step icon (e.g. the Zeus logo) — overrides the appId-derived icon */
icon?: string
/** Custom label for the step's CTA button (configure steps) */
ctaLabel?: string
}
export type GoalStatus = 'not-started' | 'in-progress' | 'completed' | 'error'

View File

@ -155,8 +155,12 @@ import ConnectionBanner from '@/views/dashboard/ConnectionBanner.vue'
import HealthNotifications from '@/views/dashboard/HealthNotifications.vue'
import CompanionIntroOverlay from '@/components/CompanionIntroOverlay.vue'
import { useRouteTransitions, isDetailRoute, ROUTE_BACKGROUNDS } from '@/views/dashboard/useRouteTransitions'
import { useIbdFinishWatcher } from '@/composables/useIbdFinishWatcher'
import '@/views/dashboard/dashboard-styles.css'
// Pops a "Finish setup" toast when Bitcoin IBD completes mid-Lightning-setup.
useIbdFinishWatcher()
const router = useRouter()
const route = useRoute()
const store = useAppStore()

View File

@ -98,12 +98,59 @@
>
{{ isInstalling ? t('common.installing') : t('goalDetail.installApp', { name: step.title.replace('Install ', '') }) }}
</button>
<!-- Fund the bitcoin wallet: IBD-gated, with live sync timer -->
<div v-else-if="step.action === 'fund'" class="space-y-3">
<div v-if="!bitcoinSynced" class="p-3 rounded-lg bg-orange-500/10 border border-orange-500/20">
<div class="flex items-center justify-between gap-3 mb-1.5">
<span class="text-xs text-white/75">Bitcoin is syncing funding unlocks when it finishes</span>
<span class="text-xs font-mono text-orange-300 shrink-0">{{ bitcoinSyncLoaded ? bitcoinSyncPercent.toFixed(1) + '%' : '…' }}</span>
</div>
<div class="h-1.5 bg-white/10 rounded-full overflow-hidden mb-1.5">
<div class="h-full bg-orange-400 rounded-full transition-all duration-700" :style="{ width: `${Math.min(100, bitcoinSyncPercent)}%` }" />
</div>
<p class="text-xs text-white/50">
<span v-if="bitcoinSyncEtaText" class="text-white/70 font-medium">~{{ bitcoinSyncEtaText }} remaining</span>
<span v-else>Estimating time remaining</span>
<span v-if="bitcoinBlockHeight"> · Block {{ bitcoinBlockHeight.toLocaleString() }}</span>
</p>
<p class="text-xs text-white/45 mt-1.5">We'll pop a notification here the moment it's done.</p>
</div>
<template v-else>
<div class="p-3 rounded-lg bg-white/5 border border-white/10">
<div class="flex items-center justify-between gap-3">
<span class="text-xs text-white/60">On-chain wallet balance</span>
<span class="text-sm font-mono" :class="walletOnchainSats >= ZEUS_CHANNEL_MIN_SATS ? 'text-green-400' : 'text-white/85'">
{{ walletOnchainSats.toLocaleString() }} sats
</span>
</div>
<p class="text-xs text-white/45 mt-1">Zeus channels need 150,0001,500,000 sats.</p>
</div>
<div class="flex flex-wrap gap-2">
<button
@click="showFundModal = true"
class="glass-button glass-button-warning glass-button-sm rounded-lg px-5 py-2 text-sm font-medium"
>
Fund Wallet
</button>
<button
@click="completeFundStep(step)"
:disabled="walletOnchainSats <= 0"
class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium disabled:opacity-40"
>
{{ walletOnchainSats > 0 ? 'Continue' : 'Waiting for funds…' }}
</button>
</div>
</template>
</div>
<button
v-else-if="step.action === 'configure'"
@click="openConfigureStep(step)"
class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium"
>
{{ t('goalDetail.openAndConfigure') }}
{{ step.ctaLabel ?? t('goalDetail.openAndConfigure') }}
</button>
<button
v-else-if="step.action === 'verify'"
@ -142,12 +189,27 @@
</div>
<h2 class="text-xl font-semibold text-white mb-2">{{ t('goalDetail.allSet') }}</h2>
<p class="text-white/60 mb-6">{{ t('goalDetail.goalReady', { title: goal.title }) }}</p>
<RouterLink to="/dashboard/apps" class="glass-button rounded-lg px-6 py-3 font-medium">
<button
v-if="completionCta"
@click="openCompletionTarget"
class="glass-button rounded-lg px-6 py-3 font-medium"
>
{{ completionCta.label }}
</button>
<RouterLink v-else to="/dashboard/apps" class="glass-button rounded-lg px-6 py-3 font-medium">
{{ t('goalDetail.viewMyServices') }}
</RouterLink>
</div>
</template>
<!-- Fund-wallet receive modal (on-chain address + QR, Zeus limits noted) -->
<ReceiveBitcoinModal
:show="showFundModal"
note="Fund your Lightning channel: Zeus channels need a minimum of 150,000 and a maximum of 1,500,000 sats."
auto-generate
@close="showFundModal = false"
/>
<!-- Action error toast -->
<Transition name="fade">
<div v-if="actionError" class="fixed bottom-20 left-1/2 -translate-x-1/2 z-50 max-w-md w-full px-4" role="alert" aria-live="assertive">
@ -161,15 +223,26 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter, RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { useGoalStore } from '@/stores/goals'
import { getGoalById } from '@/data/goals'
import { useAppLauncherStore } from '@/stores/appLauncher'
import { getGoalById, ZEUS_CHANNEL_MIN_SATS } from '@/data/goals'
import type { GoalStep } from '@/types/goals'
import { goalStepTargetPath } from './goals/goalStepActions'
import BackButton from '@/components/BackButton.vue'
import ReceiveBitcoinModal from '@/components/ReceiveBitcoinModal.vue'
import { rpcClient } from '@/api/rpc-client'
import {
acquireBitcoinSync,
bitcoinSynced,
bitcoinSyncLoaded,
bitcoinSyncPercent,
bitcoinBlockHeight,
bitcoinSyncEtaText,
} from '@/composables/useBitcoinSync'
/** Map appId to its icon file path under /assets/img/app-icons/ */
const APP_ICON_MAP: Record<string, string> = {
@ -185,10 +258,27 @@ const APP_ICON_MAP: Record<string, string> = {
}
function stepIconUrl(step: GoalStep): string | undefined {
if (step.icon) return step.icon
if (!step.appId) return undefined
return APP_ICON_MAP[step.appId]
}
/**
* Where the completion card sends the user: the app they just set up, not the
* generic services list. `launchAppId` opens via the app launcher (iframe apps
* overlay on top of the current screen; X-Frame-Options apps open a tab).
*/
const GOAL_COMPLETION_CTA: Record<string, { label: string; route?: string; launchAppId?: string }> = {
'open-a-shop': { label: 'Go to my shop (BTCPay)', launchAppId: 'btcpay-server' },
'accept-payments': { label: 'Go to Lightning (LND)', route: '/dashboard/apps/lnd' },
'run-lightning-node': { label: 'View my channels', route: '/dashboard/apps/lnd/channels' },
'setup-fedimint': { label: 'Open Fedimint', launchAppId: 'fedimint' },
'file-browser': { label: 'Open File Browser', launchAppId: 'filebrowser' },
'store-files': { label: 'Open my cloud (Nextcloud)', launchAppId: 'nextcloud' },
'create-identity': { label: 'Go to my identity', route: '/dashboard/web5' },
'back-up-everything': { label: 'Go to backups', route: '/dashboard/settings' },
}
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
@ -214,7 +304,9 @@ const completedSteps = computed(() => {
if (!goal.value) return new Set<string>()
const completed = new Set<string>()
for (const step of goal.value.steps) {
if (step.appId && isAppInstalled(step.appId)) {
// Only install steps auto-tick from package state manual steps (fund the
// wallet, open a channel, configure) must be walked through.
if (step.action === 'install' && step.appId && isAppInstalled(step.appId)) {
completed.add(step.id)
}
if (goalStore.progress[goalId.value]?.completedSteps.includes(step.id)) {
@ -331,6 +423,69 @@ function ensureGoalStarted() {
function goBack() {
router.push('/dashboard')
}
// Fund-wallet step: live sync status + on-chain balance
const showFundModal = ref(false)
const walletOnchainSats = ref(0)
const hasFundStep = computed(() => goal.value?.steps.some((s) => s.action === 'fund') ?? false)
const fundStepActive = computed(() => {
if (!goal.value || !hasFundStep.value) return false
const active = goal.value.steps[activeStepIndex.value]
return active?.action === 'fund' && overallStatus.value !== 'completed'
})
let releaseSync: (() => void) | null = null
let balanceTimer: ReturnType<typeof setInterval> | null = null
async function refreshWalletBalance() {
try {
const res = await rpcClient.call<{ balance_sats: number }>({ method: 'lnd.getinfo', timeout: 8000 })
walletOnchainSats.value = res.balance_sats || 0
} catch { /* LND not up yet — balance stays at last known value */ }
}
watch(fundStepActive, (active) => {
if (active) {
if (!releaseSync) releaseSync = acquireBitcoinSync()
void refreshWalletBalance()
if (!balanceTimer) balanceTimer = setInterval(() => void refreshWalletBalance(), 15000)
} else {
if (releaseSync) { releaseSync(); releaseSync = null }
if (balanceTimer) { clearInterval(balanceTimer); balanceTimer = null }
}
}, { immediate: true })
// Refresh the balance right after the receive modal closes the user may
// have just sent funds.
watch(showFundModal, (open) => { if (!open) void refreshWalletBalance() })
onUnmounted(() => {
if (releaseSync) { releaseSync(); releaseSync = null }
if (balanceTimer) { clearInterval(balanceTimer); balanceTimer = null }
})
function completeFundStep(step: GoalStep) {
ensureGoalStarted()
goalStore.completeStep(goalId.value, step.id)
}
// Completion CTA: go to the app you just set up
const completionCta = computed(() => (goal.value ? GOAL_COMPLETION_CTA[goal.value.id] : undefined))
function openCompletionTarget() {
const cta = completionCta.value
if (!cta) return
if (cta.launchAppId) {
// Iframe apps overlay on top of the current screen; X-Frame-Options apps
// (BTCPay, Nextcloud) open in a new tab.
useAppLauncherStore().openSession(cta.launchAppId)
} else if (cta.route) {
router.push(cta.route)
}
}
</script>
<style scoped>

View File

@ -6,9 +6,16 @@ const STEP_ROUTE_OVERRIDES: Record<string, string> = {
'create-passphrase': '/dashboard/settings',
'create-backup': '/dashboard/settings',
'save-backup': '/dashboard/settings',
// Channel steps land directly on the Lightning channels screen (which
// carries the "open a channel with Zeus" suggestion).
'open-channel': '/dashboard/apps/lnd/channels',
'open-channels': '/dashboard/apps/lnd/channels',
'open-zeus-channel': '/dashboard/apps/lnd/channels',
}
export function goalStepTargetPath(step: GoalStep): string | null {
const override = STEP_ROUTE_OVERRIDES[step.id]
if (override) return override
if (step.appId) return `/dashboard/apps/${step.appId}`
return STEP_ROUTE_OVERRIDES[step.id] ?? null
return null
}