All checks were successful
Demo images / Build & push demo images (push) Successful in 2m52s
Plain-text seed QRs didn't scan into Passport Prime — wallets that import seeds by QR (Passport, SeedSigner, Keystone, Nunchuk, Sparrow) expect the SeedQR standard: each BIP39 word as its zero-padded 4-digit wordlist index, concatenated into a numeric QR. - new utils/seedqr.ts encodes BIP39 words per the SeedSigner spec (@scure/bip39 wordlist; vector-checked abandon=0000, zoo=2047) - new shared SeedRevealPanel (Words/QR tabs, tap-to-reveal blur) now backs the LND reveal AND the Settings→Backup recovery-phrase reveal, so every current and future seed reveal behaves the same - onboarding seed + Settings reveal: QR defaults to SeedQR with a plain-text toggle - LND seed stays plain-text-only with an explicit note: aezeed is not BIP39 and only restores into LND-based wallets (Zeus/Blixt/another node) — SeedQR-encoding it would just mislead Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
263 lines
11 KiB
Vue
263 lines
11 KiB
Vue
<template>
|
|
<div class="h-[100dvh] flex items-center justify-center p-3 sm:p-4 md:p-6">
|
|
<div class="max-w-[800px] w-full max-h-full relative z-10 path-glass-container onb-scroll-container flex flex-col">
|
|
<!-- Header -->
|
|
<div class="text-center flex-shrink-0 px-3 sm:px-4 pt-4 sm:pt-6 pb-2 sm:pb-3">
|
|
<h1 class="text-xl sm:text-2xl md:text-[26px] font-semibold text-white/96 mb-1.5 drop-shadow-[0_2px_6px_rgba(0,0,0,0.4)]">
|
|
Your Recovery Seed
|
|
</h1>
|
|
<p class="text-xs sm:text-sm md:text-base text-white/75 leading-relaxed max-w-[600px] mx-auto">
|
|
Write down these 24 words in order. They are the only way to recover your node.
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Scrollable Content -->
|
|
<div class="flex-1 overflow-y-auto overflow-x-hidden px-6 sm:px-8 min-h-0">
|
|
<div class="flex flex-col items-center gap-3 sm:gap-4 py-3">
|
|
<!-- Loading State -->
|
|
<div v-if="loading" class="text-center py-8">
|
|
<div class="flex justify-center mb-4">
|
|
<div class="w-16 h-16 rounded-full bg-white/10 flex items-center justify-center onb-lock-spin">
|
|
<svg class="w-8 h-8 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
<div v-if="waitingForServer" class="flex items-center justify-center gap-3 mb-2">
|
|
<p class="text-lg text-white/80">Server starting up</p>
|
|
<span class="text-sm text-white/40 font-mono tabular-nums">{{ elapsedDisplay }}</span>
|
|
</div>
|
|
<p v-if="waitingForServer" class="text-sm text-white/50">This usually takes 1-3 minutes after first boot</p>
|
|
<p v-else class="text-lg text-white/80">Generating your seed phrase...</p>
|
|
</div>
|
|
|
|
<!-- Error (genuine failure — server-starting hiccups retry silently) -->
|
|
<div v-if="errorMessage" class="text-center">
|
|
<p class="text-red-400 text-sm mb-3">{{ errorMessage }}</p>
|
|
<button
|
|
@click="generateSeed"
|
|
class="path-action-button path-action-button--continue mx-auto"
|
|
>
|
|
Try again
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Word Grid -->
|
|
<div v-if="words.length > 0" class="w-full max-w-[600px]">
|
|
<!-- Words / QR tabs — words first; QR for wallets that import by scan -->
|
|
<div class="flex gap-1 mb-2 p-1 bg-white/5 rounded-lg">
|
|
<button
|
|
v-for="tab in ([{ key: 'words', label: 'Words' }, { key: 'qr', label: 'QR code' }] as const)"
|
|
:key="tab.key"
|
|
type="button"
|
|
@click="seedTab = tab.key"
|
|
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors"
|
|
:class="seedTab === tab.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
|
>{{ tab.label }}</button>
|
|
</div>
|
|
|
|
<div v-if="seedTab === 'words'" class="grid grid-cols-2 sm:grid-cols-4 gap-1 sm:gap-1.5">
|
|
<div
|
|
v-for="(word, i) in words"
|
|
:key="i"
|
|
class="bg-black/60 rounded-lg px-2.5 py-1 sm:py-1.5 border border-white/10"
|
|
>
|
|
<span class="text-white/40 text-sm font-mono mr-1">{{ i + 1 }}.</span>
|
|
<span class="text-white/95 text-[1.05rem] font-mono">{{ word }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else class="flex flex-col items-center gap-2 py-2">
|
|
<canvas ref="seedQrCanvas" class="rounded-lg bg-white p-2"></canvas>
|
|
<div class="flex p-0.5 bg-white/5 rounded-md">
|
|
<button
|
|
v-for="f in ([{ key: 'seedqr', label: 'SeedQR' }, { key: 'text', label: 'Plain text' }] as const)"
|
|
:key="f.key"
|
|
type="button"
|
|
@click="qrFormat = f.key"
|
|
class="px-2.5 py-1 rounded text-[11px] font-medium transition-colors"
|
|
:class="qrFormat === f.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
|
>{{ f.label }}</button>
|
|
</div>
|
|
<p class="text-xs text-white/50 text-center max-w-[420px]">
|
|
{{ qrFormat === 'seedqr'
|
|
? 'SeedQR — scans into Passport, SeedSigner, Keystone and other wallets that import seeds by QR.'
|
|
: 'Plain text words — for wallets that read the phrase as text.' }}
|
|
Treat this code exactly like the words themselves.
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Warning -->
|
|
<div class="mt-3 bg-orange-500/10 border border-orange-500/20 rounded-lg px-3 py-2.5">
|
|
<p class="text-xs sm:text-sm text-orange-300/90">
|
|
Never share these words. Anyone with them controls your node, identities, and Bitcoin wallet.
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Confirmation Checkbox -->
|
|
<label class="flex items-center justify-center gap-3 mt-3 cursor-pointer select-none">
|
|
<input
|
|
v-model="confirmed"
|
|
type="checkbox"
|
|
class="w-5 h-5 rounded border-white/20 bg-black/40 accent-orange-400"
|
|
/>
|
|
<span class="text-xs sm:text-sm text-white/80">I have written down these 24 words in a safe place</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Fixed Footer -->
|
|
<div v-if="words.length > 0" class="flex-shrink-0 flex justify-center px-3 sm:px-4 pt-3 pb-4 sm:pb-6">
|
|
<button
|
|
ref="continueButton"
|
|
@click="proceed"
|
|
:disabled="!confirmed"
|
|
class="path-action-button path-action-button--continue disabled:opacity-50"
|
|
>
|
|
Continue
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import { rpcClient } from '@/api/rpc-client'
|
|
import { playNavSound } from '@/composables/useNavSounds'
|
|
|
|
const router = useRouter()
|
|
const continueButton = ref<HTMLButtonElement | null>(null)
|
|
const words = ref<string[]>([])
|
|
|
|
// Words / QR code view of the seed — words are the default first view.
|
|
// The QR tab defaults to SeedQR (BIP39 word-index digit stream), the format
|
|
// hardware wallets like Passport Prime / SeedSigner actually import; plain
|
|
// text stays available for wallets that read the phrase as text.
|
|
const seedTab = ref<'words' | 'qr'>('words')
|
|
const qrFormat = ref<'seedqr' | 'text'>('seedqr')
|
|
const seedQrCanvas = ref<HTMLCanvasElement | null>(null)
|
|
|
|
async function renderSeedQr() {
|
|
await nextTick()
|
|
if (!seedQrCanvas.value || words.value.length === 0) return
|
|
try {
|
|
let payload = words.value.join(' ')
|
|
if (qrFormat.value === 'seedqr') {
|
|
const { toSeedQrDigits } = await import('@/utils/seedqr')
|
|
const digits = await toSeedQrDigits(words.value)
|
|
if (digits) payload = digits
|
|
else qrFormat.value = 'text' // non-BIP39 word — only text is honest
|
|
}
|
|
const QRCode = await import('qrcode')
|
|
await QRCode.toCanvas(seedQrCanvas.value, payload, { width: 240, margin: 1 })
|
|
} catch { /* QR is a convenience — the words remain authoritative */ }
|
|
}
|
|
watch(seedTab, (tab) => { if (tab === 'qr') void renderSeedQr() })
|
|
watch(qrFormat, () => { if (seedTab.value === 'qr') void renderSeedQr() })
|
|
const confirmed = ref(false)
|
|
const loading = ref(false)
|
|
const waitingForServer = ref(false)
|
|
const errorMessage = ref('')
|
|
const elapsedDisplay = ref('0:00')
|
|
let retryTimer: ReturnType<typeof setTimeout> | null = null
|
|
let elapsedTimer: ReturnType<typeof setInterval> | null = null
|
|
let startTime = 0
|
|
|
|
function startElapsedTimer() {
|
|
startTime = Date.now()
|
|
elapsedTimer = setInterval(() => {
|
|
const secs = Math.floor((Date.now() - startTime) / 1000)
|
|
const m = Math.floor(secs / 60)
|
|
const s = secs % 60
|
|
elapsedDisplay.value = `${m}:${s.toString().padStart(2, '0')}`
|
|
}, 1000)
|
|
}
|
|
|
|
function stopTimers() {
|
|
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null }
|
|
if (elapsedTimer) { clearInterval(elapsedTimer); elapsedTimer = null }
|
|
}
|
|
|
|
// Transient errors mean the backend is still booting (slow first boot) — we
|
|
// retry these silently. Anything else is a genuine failure the user should see.
|
|
function isServerStartingError(err: unknown): boolean {
|
|
const msg = err instanceof Error ? err.message : String(err)
|
|
return /502|503|504|timeout|fetch|network|Failed to fetch|Request failed/i.test(msg)
|
|
}
|
|
|
|
async function generateSeed() {
|
|
loading.value = true
|
|
errorMessage.value = ''
|
|
|
|
try {
|
|
// seed.generate is idempotent server-side, so retries are safe. Use a
|
|
// longer timeout than the default 15s — key derivation + disk writes can be
|
|
// slow on first boot, and we don't want a spurious abort to look like a
|
|
// failure.
|
|
const res = await rpcClient.call<{ words: string[] }>({ method: 'seed.generate', timeout: 30000 })
|
|
stopTimers()
|
|
words.value = res.words
|
|
loading.value = false
|
|
waitingForServer.value = false
|
|
} catch (err) {
|
|
loading.value = false
|
|
if (isServerStartingError(err)) {
|
|
// Backend not ready yet — keep waiting, retry silently.
|
|
if (!waitingForServer.value) {
|
|
waitingForServer.value = true
|
|
startElapsedTimer()
|
|
}
|
|
retryTimer = setTimeout(generateSeed, 4000)
|
|
} else {
|
|
// Genuine failure — stop the silent loop and surface it with a manual retry.
|
|
stopTimers()
|
|
waitingForServer.value = false
|
|
errorMessage.value = err instanceof Error ? err.message : 'Failed to generate seed'
|
|
}
|
|
}
|
|
}
|
|
|
|
watch(confirmed, (val) => {
|
|
if (val) {
|
|
nextTick(() => {
|
|
setTimeout(() => continueButton.value?.focus({ preventScroll: true }), 100)
|
|
})
|
|
}
|
|
})
|
|
|
|
onMounted(() => {
|
|
// Restore previously generated seed if navigating back (don't regenerate)
|
|
const saved = sessionStorage.getItem('_seed_words')
|
|
if (saved) {
|
|
try {
|
|
const parsed = JSON.parse(saved)
|
|
if (Array.isArray(parsed) && parsed.length === 24) {
|
|
words.value = parsed
|
|
return
|
|
}
|
|
} catch { /* regenerate */ }
|
|
}
|
|
generateSeed()
|
|
})
|
|
onUnmounted(() => { stopTimers() })
|
|
|
|
function proceed() {
|
|
playNavSound('action')
|
|
sessionStorage.setItem('_seed_words', JSON.stringify(words.value))
|
|
router.push('/onboarding/seed-verify').catch(() => {})
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.onb-lock-spin {
|
|
animation: onb-lock-pulse 1.2s ease-in-out infinite;
|
|
}
|
|
@keyframes onb-lock-pulse {
|
|
0%, 100% { transform: scale(1); opacity: 1; }
|
|
50% { transform: scale(1.08); opacity: 0.7; }
|
|
}
|
|
</style>
|