// Bitcoin Wallet — UI controller (vanilla DOM, no framework). // // State lives in `ui` + the singleton `wallet`. Mutating handlers call render(), // which rebuilds the active screen. Text inputs write back into `ui` on `input` // (without re-rendering) so their values survive structural re-renders. import { Wallet, newMnemonic, isValidMnemonic, accountXpubFor, cacheKeyFor, utxoId, parseExtendedKey, xpubToZpub, encryptVault, decryptVault } from './wallet.js'; import { qrSvg } from './qr.js'; import { makeSearcher, resultRows, searchable, punkUrl } from './recipient-search.js'; import { npubOf } from './nostr.js'; import { NOSTR_MARK } from './features/nostrlogin.js'; import { scanQr } from './scan.js'; import { dataSources, getSource, setSource, getNetwork, setNetwork, NETWORKS } from './api.js'; import { buildFeatures } from './features/index.js'; import { t, LANGS, getLang, setLang, isRTL, loadLocale } from './i18n.js'; import { fmtBtc, fmtSats, parseAmount, shortAddr, shortTxid, timeAgo, SATS, BITCOIN_ICON, } from './format.js'; const wallet = new Wallet(); // ---- optional features ------------------------------------------------ // Swaps, Ark, and silent payments plug into fixed seams (receive modes, send // matchers, history entries, balance lines, settings cards, lifecycle hooks). // FEATURES/ctx are assembled near the bottom of this file, once every helper // they capture exists; the hooks below are only ever called at runtime. const featureHook = (name, ...args) => { for (const f of FEATURES) { if (!f[name]) continue; const v = f[name](...args); if (v) return v; } return null; }; const featureAll = (name, ...args) => FEATURES.flatMap((f) => (f[name] ? f[name](...args) : [])); // `typed` distinguishes keystroke input from paste/scan: some matchers (a // lightning address) match mid-typing and must not yank the form away. const featureMatchSend = (text, typed) => FEATURES.some((f) => f.matchSendText && f.matchSendText(text, typed)); const ui = { screen: 'unlock', // 'unlock' | 'wallet' | 'claim' | 'howItWorks' claimStep: null, // 'welcome' | 'backup' when opening a gift link claimChecking: false, // verifying the gift's funding coin is still unspent claimTaken: null, // { txid } if the gift was already claimed (coin spent) returnScreen: 'unlock', // where 'howItWorks' returns to (Back / logo) unlockTab: 'create', // 'create' | 'import' | 'watch' watchXpub: '', // watch-only xpub/zpub input watchLabel: '', // watch-only account label input fromWallet: false, // unlock screen reached as "add wallet" (show a back button) pw: null, // { purpose, accId, mode, v1, v2, error } — vault password prompt vaultPw: '', // on-open vault unlock input vaultError: '', confirmClear: false, // "Clear all" confirmation shown editId: null, // account being renamed editLabel: '', createStep: 'gen', // 'gen' | 'confirm' draftMnemonic: '', confirm: [], // [{ index, value }] confirmPass: '', // re-entered passphrase on the verify step importText: '', passphrase: '', showPass: false, revealShown: false, // recovery phrase: false | 'masked' (grid, dots) | 'words' pubkeyShown: false, // account public key revealed in Settings giftMode: false, // gift sub-view active on the Send page giftAmount: '', // gift-create amount input viewGift: null, // re-viewing a previously created gift's link/QR { code, locked, amount, claimCode } claimChoose: null, // opening a gift with existing wallets present: { code } — pick a target giftCode: null, // last-created gift PSBT code giftError: '', giftMax: false, // gift the whole spendable balance (no-change sweep) giftSplitOffer: null, // { amt, lock, freed, fee } when offering to split a coin first revokeId: null, // outpoint of a gift being revoked (confirm state) claimCode: null, // gift code being claimed (opened from a #gift= link) claimedAmount: 0, claimError: '', offlineFallback: false, // auto-entered offline because the network was unreachable unlockError: '', tab: 'receive', // receive | send | history | settings receiveSeenIndex: null, // fresh receive index the user has acknowledged txDetail: null, // txid being viewed in the history detail view txPage: 0, // History: current page of transactions (10 per page) addrScan: false, // Settings: showing the per-address rescan list addrScanPage: 0, // Settings: current page within the address list rescanning: new Set(), // 'chain/index' ids queued/in-flight for rescan send: blankSend(), draft: null, // built tx summary awaiting review broadcastTx: null, // scanned signed tx awaiting broadcast confirmation bump: null, // RBF bump in progress: { prep, feeChoice, customFee } sendError: '', sendResult: null, // { txid } | { signedHex, txid } busy: false, }; function blankSend() { return { recipients: [{ address: '', amount: '' }], unit: 'btc', max: false, feeChoice: 'halfHourFee', customFee: '', manual: false, coins: new Set(), }; } // ---------------------------------------------------------------- DOM helper function h(tag, attrs = {}, ...children) { const e = document.createElement(tag); for (const [k, v] of Object.entries(attrs || {})) { if (v == null || v === false) continue; if (k === 'class') e.className = v; else if (k === 'html') e.innerHTML = v; else if (k === 'value') e.value = v; else if (k === 'checked' || k === 'disabled' || k === 'selected') e[k] = !!v; else if (k.startsWith('on') && typeof v === 'function') e.addEventListener(k.slice(2).toLowerCase(), v); else e.setAttribute(k, v); } for (const c of children.flat()) { if (c == null || c === false || c === true) continue; e.append(c.nodeType ? c : document.createTextNode(String(c))); } return e; } const root = document.getElementById('app'); function footer() { return h( 'div', { class: 'footer small muted center' }, h( 'div', {}, t('footerMadeBy') + ' ', h('a', { href: 'https://adamsoltys.com', target: '_blank', rel: 'noopener' }, 'Adam Soltys'), h('span', { class: 'faint' }, ' · '), t('footerSourceOn') + ' ', h('a', { href: 'https://github.com/coinos/coinosv3', target: '_blank', rel: 'noopener' }, 'GitHub') ), h( 'div', { style: 'margin-top:4px' }, h('button', { class: 'linklike', style: 'font-weight:400', onClick: openHowItWorks }, t('howItWorks')), // Chrome no longer prompts to install on its own — surface our own link // once it reports the app is installable. We render from a persisted flag // (not the live event) so the link is present on the first paint after a // refresh, avoiding a layout shift when beforeinstallprompt fires late. installable() ? h('span', {}, h('span', { class: 'faint' }, ' · '), h('button', { class: 'linklike', style: 'font-weight:400', onClick: triggerInstall }, t('installApp'))) : null, h('span', { class: 'faint' }, ' · '), h('button', { class: 'linklike', style: 'font-weight:400', onClick: toggleTheme }, resolvedTheme() === 'dark' ? t('lightMode') : t('darkMode')) ), h('div', { style: 'margin-top:8px' }, languagePicker()) ); } const THEME_KEY = 'btc-wallet-theme'; function resolvedTheme() { try { const s = localStorage.getItem(THEME_KEY); if (s === 'dark' || s === 'light') return s; } catch {} try { return matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; } catch {} return 'light'; } function applyTheme() { try { document.documentElement.dataset.theme = resolvedTheme(); } catch {} } function toggleTheme() { try { localStorage.setItem(THEME_KEY, resolvedTheme() === 'dark' ? 'light' : 'dark'); } catch {} applyTheme(); render(); } // PWA install. Chrome fires beforeinstallprompt when the app qualifies; we stash // the event and reveal an "Install app" link, then replay it on a user tap (the // browser requires a gesture). The link's visibility is driven by a persisted // flag rather than the live event so it's present on the first paint after a // refresh (no layout shift); the event only supplies the prompt to replay. // We deliberately do NOT call e.preventDefault(): modern Chrome shows no banner // of its own to suppress, and preventDefault-without-prompt() logs a console // warning. The event stays usable for our own e.prompt() on tap. const INSTALLABLE_KEY = 'btc-wallet-installable'; let installPrompt = null; function isStandalone() { try { return matchMedia('(display-mode: standalone)').matches || navigator.standalone === true; } catch { return false; } } function installable() { if (isStandalone()) return false; try { return localStorage.getItem(INSTALLABLE_KEY) === '1'; } catch { return false; } } function setInstallable(v) { try { if (v) localStorage.setItem(INSTALLABLE_KEY, '1'); else localStorage.removeItem(INSTALLABLE_KEY); } catch {} } if (typeof window !== 'undefined') { window.addEventListener('beforeinstallprompt', (e) => { installPrompt = e; // Only re-render if this changes what's on screen; on a refresh the link is // already shown from the persisted flag, so nothing moves. const wasShown = installable(); setInstallable(true); if (!wasShown) render(); }); window.addEventListener('appinstalled', () => { installPrompt = null; setInstallable(false); render(); }); } async function triggerInstall() { const e = installPrompt; // The prompt event may not have fired yet this load even though the link is // shown from the persisted flag; bail quietly if so. if (!e) return; installPrompt = null; e.prompt(); try { await e.userChoice; } catch {} } // Open the How it works page, remembering where to return to. function openHowItWorks() { if (ui.screen === 'howItWorks') return; ui.returnScreen = ui.screen; ui.screen = 'howItWorks'; render(); } // Per-tab session nav (tab + open tx), so a refresh keeps the user's place. const NAV_KEY = 'btc-wallet-nav'; // Index path from #app down to a node, and back — used to re-find the focused // field after a full rebuild (a background re-render produces the same structure). function focusPath(el) { const path = []; for (let n = el; n && n !== root; n = n.parentNode) { const p = n.parentNode; if (!p) return null; path.unshift(Array.prototype.indexOf.call(p.children, n)); } return path; } function nodeAtPath(path) { let n = root; for (const i of path) n = n && n.children[i]; return n || null; } // ---- one-shot animation gating ------------------------------------------- // The renderer rebuilds the whole DOM on every render, so a CSS mount // animation would replay on every background repaint (balance ticks, chat // bursts). These helpers answer "did this change since the last render?" — // things animate only in the render where they actually happened. const _uiSeen = new Map(); function uiChanged(key, val) { const prev = _uiSeen.get(key); _uiSeen.set(key, val); return prev !== val; } // A background render can land mid-animation and rebuild the node without // its one-shot class, killing the motion. animWindow answers "how far into // the animation started by this change are we?" — callers re-apply the class // with a negative animation-delay so a rebuild RESUMES the animation. let _navAt = -1e9; let _swipeDir = null; // set by the swipe handler just before render // tab switches carousel just the content pane, not the whole page let _tabAt = -1e9; let _tabCls = 'anim-tab-left'; let _prevTabIdx = 0; const _animAt = new Map(); function animWindow(key, val, durMs) { if (uiChanged(key, val)) _animAt.set(key, performance.now()); const dt = performance.now() - (_animAt.get(key) ?? -1e9); return dt < durMs ? dt : -1; } function applyAnim(el, cls, dt) { if (dt < 0 || !el || !el.classList) return; el.classList.add(cls); el.style.animationDelay = -Math.round(dt) + 'ms'; } // A balance that counts to its new value instead of teleporting, with a green // breath when money arrived. Keyed so background renders don't re-animate. const _amtLast = new Map(); function animatedAmount(key, sat) { const prev = _amtLast.get(key); _amtLast.set(key, sat); const el = h('span', {}, fmtAmount(sat)); if (prev !== undefined && prev !== sat) { const from = prev, to = sat, t0 = performance.now(), dur = 550; const step = (t) => { if (!el.isConnected) return; // a newer render owns the number now const p = Math.min(1, (t - t0) / dur); const e = 1 - Math.pow(1 - p, 3); el.textContent = fmtAmount(Math.round(from + (to - from) * e)); if (p < 1) requestAnimationFrame(step); }; requestAnimationFrame(step); if (to > from) el.classList.add('amt-flash'); } return el; } function render() { // A direct render subsumes any coalesced one queued by a background emit, so // drop the pending frame (a user action must not trigger a second rebuild). if (_renderRaf) { cancelAnimationFrame(_renderRaf); _renderRaf = 0; } // Preserve focus + caret across the rebuild, so a background update (poll, a // payment push, an SP scan) can't kick the user out of a field they're editing. const a = document.activeElement; let fpath = null, selStart = null, selEnd = null; if (a && root.contains(a) && /^(INPUT|SELECT|TEXTAREA)$/.test(a.tagName)) { fpath = focusPath(a); try { selStart = a.selectionStart; selEnd = a.selectionEnd; } catch {} } const screen = featureHook('screenView') || (ui.screen === 'wallet' ? walletScreen() : ui.screen === 'accounts' ? accountsScreen() : ui.screen === 'accountSettings' ? accountSettingsScreen() : ui.screen === 'vault' ? vaultScreen() : ui.screen === 'howItWorks' ? howItWorksScreen() : shouldOnboard() ? onboardScreen() : unlockScreen()); // Navigation animates; background repaints must not. The key is every // ui field that decides which page is on screen. const navKey = [ui.screen, ui.tab === 'settings', ui.chatOpen, ui.msgView, ui.msgPeer, ui.msgCommunity, ui.profilePk, ui.settingsPage, ui.addrScan, ui.arkExitPage, ui.txDetail, ui.giftMode, ui.claimStep].join('|'); if (uiChanged('nav', navKey)) _navAt = performance.now(); applyAnim(screen, 'anim-page', (performance.now() - _navAt) < 340 ? performance.now() - _navAt : -1); root.replaceChildren(screen, footer()); if (fpath) { const el = nodeAtPath(fpath); if (el && el !== a && /^(INPUT|SELECT|TEXTAREA)$/.test(el.tagName)) { try { el.focus({ preventScroll: true }); if (selStart != null && el.setSelectionRange) el.setSelectionRange(selStart, selEnd); } catch {} } } // Fast-poll the receive address only while the user is actually watching for a // payment (wallet screen, Receive tab, online). Idempotent — safe each render. wallet.setWatchReceive(ui.screen === 'wallet' && ui.tab === 'receive' && !wallet.offline); if (ui.screen === 'wallet') { commitAccount(); // entering the wallet (any path) keeps a provisional gift account // Remember where we are so a refresh restores it (only meaningful on the wallet). try { sessionStorage.setItem(NAV_KEY, JSON.stringify({ tab: ui.tab, txDetail: ui.txDetail })); } catch {} } syncHistory(); // mirror the current screen into browser history (Back/Forward) } // Background state changes (a wake scan, cross-device sync merges, WS payment // pushes) can fire a burst of emits — especially when the PWA is refocused // after being backgrounded. Rendering synchronously on each one rebuilds the // whole screen dozens of times in a row and starves user input, so the app // feels frozen for a second or two. Coalesce those into at most one render per // animation frame. Direct user actions still call render() synchronously, and // the browser dispatches input events ahead of the queued frame, so a tap or // navigation always takes priority over background repaints. (rAF also skips // rendering entirely while the tab is hidden, and fires once on refocus.) let _renderRaf = 0; function scheduleRender() { if (_renderRaf) return; _renderRaf = requestAnimationFrame(() => { _renderRaf = 0; render(); }); } wallet.subscribe(scheduleRender); // ---- browser-history navigation ------------------------------------------ // Mirror the app's screen position into the browser history so the Back/Forward // buttons (and Android/system back) move between screens we've actually viewed. // We snapshot only the navigation-relevant `ui` fields, so incidental re-renders // (typing, polling, balance updates) don't create history entries. const NAV_FIELDS = ['screen', 'tab', 'txDetail', 'bump', 'giftMode', 'claimStep', 'chatOpen', 'msgView', 'msgCommunity', 'msgPeer', 'profilePk', 'settingsPage']; function navSnapshot() { const s = {}; for (const f of NAV_FIELDS) s[f] = ui[f] ?? null; return s; } const navSig = (s) => JSON.stringify(s); let navStack = []; // in-memory mirror of the history entries (to detect an in-app Back) let navIndex = -1; let restoringHistory = false; // true while applying a popstate (suppresses pushing) function syncHistory() { if (restoringHistory) return; try { const snap = navSnapshot(); const sig = navSig(snap); if (navIndex >= 0 && sig === navSig(navStack[navIndex])) return; // no navigation change // Every screen change is a new history entry. (We deliberately don't try to // detect in-app "back" navigations — an A→B→A pattern is indistinguishable // from a genuine back, so guessing corrupts the stack. An in-app back just // adds an entry; Back/Forward still walk the screens correctly.) navStack = navStack.slice(0, navIndex + 1); // drop any forward entries navStack.push(snap); navIndex++; const entry = { nav: snap, i: navIndex }; if (navIndex === 0) history.replaceState(entry, ''); else history.pushState(entry, ''); } catch {} // history API failures must never break a render } window.addEventListener('popstate', (e) => { const st = e.state; const snap = (st && st.nav) || navSnapshot(); restoringHistory = true; try { for (const f of NAV_FIELDS) ui[f] = f in snap ? snap[f] : null; if (st && typeof st.i === 'number') navIndex = st.i; else { const i = navStack.findIndex((s) => navSig(s) === navSig(snap)); if (i >= 0) navIndex = i; } render(); } finally { restoringHistory = false; } }); // In-app "back" buttons call this instead of mutating ui + render() directly, so // they POP the history entry they're leaving rather than pushing a duplicate. // When there's no in-app entry to pop (e.g. the page was reloaded straight into a // sub-screen), fall back to navigating to the explicit parent. function goBack(toParent) { if (navIndex > 0) history.back(); else { toParent(); render(); } } // ---------------------------------------------------------------- utilities let toastTimer; function toast(msg, ms = 1600) { let t = document.querySelector('.toast'); if (!t) { t = h('div', { class: 'toast' }); document.body.append(t); } t.textContent = msg; requestAnimationFrame(() => t.classList.add('show')); clearTimeout(toastTimer); toastTimer = setTimeout(() => t.classList.remove('show'), ms); } async function copy(text) { try { await navigator.clipboard.writeText(text); } catch { const ta = h('textarea', { value: text }); document.body.append(ta); ta.select(); try { document.execCommand('copy'); } catch {} ta.remove(); } toast(t('copied')); } // Open a URL in a new window/tab. window.open is more reliable than an // inside an installed PWA (standalone mode), where the link // can otherwise navigate the app away instead of opening externally. function openExternal(url) { try { window.open(url, '_blank', 'noopener,noreferrer'); } catch {} } function download(filename, text, mime = 'application/json') { const url = URL.createObjectURL(new Blob([text], { type: mime })); const a = h('a', { href: url, download: filename }); document.body.append(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); } function copyBtn(text, label = t('copy')) { return h('button', { class: 'btn-sm', onClick: () => copy(text) }, label); } // A small "paste from clipboard" button; apply(text) receives the trimmed text. // Returns null where the Clipboard read API isn't available (the catch keeps it // silent if a browser blocks the read). function pasteBtn(apply) { if (typeof navigator === 'undefined' || !navigator.clipboard || !navigator.clipboard.readText) return null; return h('button', { type: 'button', class: 'btn-sm', title: t('paste'), onClick: async () => { try { const txt = await navigator.clipboard.readText(); if (txt) apply(txt.trim()); } catch {} }, html: '', }); } // ---------------------------------------------------------------- display unit // Global BTC/sats preference, persisted in localStorage across refreshes and // logouts. Every unit label on the site is clickable to toggle it. const UNIT_KEY = 'btc-wallet-unit'; let unit = (() => { // Default to sats for first-time users; only an explicit 'btc' choice sticks. try { return localStorage.getItem(UNIT_KEY) === 'btc' ? 'btc' : 'sats'; } catch { return 'sats'; } })(); function toggleUnit() { unit = unit === 'btc' ? 'sats' : 'btc'; try { localStorage.setItem(UNIT_KEY, unit); } catch {} render(); } const unitLabel = () => (unit === 'sats' ? 'sats' : 'BTC'); const fmtAmount = (sats) => (unit === 'sats' ? fmtSats(sats) : fmtBtc(sats)); // A clickable unit label. cls lets callers inherit surrounding sizing. function unitTag(cls = '') { return h('button', { type: 'button', class: 'unit-tag ' + cls, title: t('switchUnit'), onClick: toggleUnit }, unitLabel()); } // ================================================================ UNLOCK function unlockScreen() { return h( 'div', { class: 'col', style: 'gap:16px' }, brandHeader(false), h( 'div', { class: 'card col' }, h( 'div', { class: 'tabs' }, tabBtn(t('createNew'), ui.unlockTab === 'create', () => { ui.unlockTab = 'create'; ui.unlockError = ''; render(); }), tabBtn(t('importExisting'), ui.unlockTab === 'import', () => { ui.unlockTab = 'import'; ui.unlockError = ''; render(); }) ), ui.unlockTab === 'create' ? createPane() : importPane(), ui.unlockError && h('div', { class: 'notice err' }, ui.unlockError) ), featureHook('unlockExtra'), ui.fromWallet && accounts.length ? h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.fromWallet = false; ui.screen = 'wallet'; ui.unlockError = ''; render(); } }, t('back')) : null ); } // ================================================================ HOW IT WORKS function howItWorksScreen() { const back = () => { ui.screen = ui.returnScreen === 'wallet' ? 'wallet' : 'unlock'; render(); }; const para = (key) => h('p', { class: 'muted', style: 'margin:0' }, ...linkify(t(key))); return h( 'div', { class: 'col', style: 'gap:16px' }, brandHeader(false), h( 'div', { class: 'card col', style: 'gap:14px' }, h('h3', {}, t('hiwBasicsTitle')), para('hiwBasics1'), para('hiwBasics2'), para('hiwBasics3') ), h('div', { class: 'card col', style: 'gap:14px' }, h('h3', {}, '⚡ ' + t('hiwLnTitle')), para('hiwLn1'), para('hiwLn2')), h('div', { class: 'card col', style: 'gap:14px' }, h('h3', {}, '🎁 ' + t('hiwGiftsTitle')), para('hiwGifts1')), h('button', { class: 'btn-block', onClick: back }, t('back')) ); } // Turn known tokens (e.g. mempool.space) into links within a plain string, // returning an array of text + anchor nodes. Keeps i18n strings link-free. const HIW_LINKS = [ ['mempool.space', 'https://mempool.space'], ]; function linkify(text) { const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const re = new RegExp('(' + HIW_LINKS.map(([tok]) => esc(tok)).join('|') + ')', 'g'); const out = []; let last = 0; let m; while ((m = re.exec(text))) { if (m.index > last) out.push(text.slice(last, m.index)); const href = HIW_LINKS.find(([tok]) => tok === m[0])[1]; out.push(h('a', { href, target: '_blank', rel: 'noopener' }, m[0])); last = m.index + m[0].length; } if (last < text.length) out.push(text.slice(last)); return out; } function tabBtn(label, active, onClick) { return h('button', { class: active ? 'active' : '', onClick }, label); } function createPane() { if (ui.createStep === 'gen') { if (!ui.draftMnemonic) { return h( 'div', { class: 'col' }, h('p', { class: 'muted' }, t('genIntro')), h( 'button', { class: 'btn-primary btn-block', onClick: () => { ui.draftMnemonic = newMnemonic(); render(); }, }, t('generateSeed') ) ); } const words = ui.draftMnemonic.split(' '); return h( 'div', { class: 'col' }, h('div', { class: 'warn-box' }, t('writeDownWarn')), h( 'div', { class: 'words' }, words.map((w, i) => h('div', { class: 'w' }, h('span', { class: 'n' }, i + 1), h('span', { class: 't' }, w)) ) ), h( 'div', { class: 'row gap6' }, copyBtn(ui.draftMnemonic, t('copyPhrase')), h( 'button', { class: 'btn-ghost btn-sm', onClick: () => { ui.draftMnemonic = newMnemonic(); render(); }, }, t('regenerate') ) ), optionsPanel(), h( 'button', { class: 'btn-primary btn-block', onClick: () => { ui.confirm = pickConfirm(words); ui.confirmPass = ''; ui.unlockError = ''; ui.createStep = 'confirm'; render(); }, }, t('verifyBackup') ), h( 'button', { class: 'btn-block', onClick: () => openWallet(ui.draftMnemonic, { generated: true }) }, t('skipVerification') ) ); } // confirm step (optional — reachable via "Verify backup") const hasPass = !!ui.passphrase; return h( 'div', { class: 'col' }, h('p', { class: 'muted' }, t('confirmBackupIntro')), ...ui.confirm.map((c, i) => h( 'label', { class: 'field' }, h('span', { class: 'lab' }, t('wordN', { n: c.index + 1 })), h('input', { type: 'text', class: 'mono-input', autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: c.value, onInput: (e) => (ui.confirm[i].value = e.target.value.trim()), }) ) ), // Only verify the passphrase if one was actually entered. hasPass && h( 'label', { class: 'field' }, h('span', { class: 'lab' }, t('reenterPassphrase')), h('input', { type: 'password', class: 'mono-input', autocomplete: 'off', value: ui.confirmPass, onInput: (e) => (ui.confirmPass = e.target.value), }) ), h('div', { class: 'row gap6' }, h('button', { class: 'btn-ghost', onClick: () => { ui.createStep = 'gen'; render(); } }, t('back')), h('button', { class: 'btn-primary grow', onClick: () => { const words = ui.draftMnemonic.split(' '); const ok = ui.confirm.every((c) => c.value.toLowerCase() === words[c.index]); if (!ok) { ui.unlockError = t('wordsMismatch'); render(); return; } if (hasPass && ui.confirmPass !== ui.passphrase) { ui.unlockError = t('passphraseMismatch'); render(); return; } openWallet(ui.draftMnemonic, { generated: true }); }, }, t('openWallet')) ), h('button', { class: 'btn-block', onClick: () => openWallet(ui.draftMnemonic, { generated: true }) }, t('skipVerification')) ); } function pickConfirm(words) { const idx = new Set(); while (idx.size < 3) idx.add(Math.floor(Math.random() * words.length)); return [...idx].sort((a, b) => a - b).map((index) => ({ index, value: '' })); } function importPane() { const ta = h('textarea', { placeholder: t('importPlaceholder'), autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: ui.importText, onInput: (e) => (ui.importText = e.target.value), }); return h( 'div', { class: 'col' }, h( 'label', { class: 'field' }, h('div', { class: 'row between' }, h('span', { class: 'lab' }, t('importLabel')), pasteBtn((text) => { ta.value = text; ui.importText = text; }) ), ta ), optionsPanel(), h('button', { class: 'btn-primary btn-block', onClick: () => openWallet(ui.importText) }, t('openWallet')) ); } function optionsPanel() { return h( 'label', { class: 'field' }, h('span', { class: 'lab' }, t('passphrase')), h( 'div', { class: 'input-group' }, h('input', { type: ui.showPass ? 'text' : 'password', class: 'mono-input', autocomplete: 'off', value: ui.passphrase, onInput: (e) => (ui.passphrase = e.target.value), }), h('button', { class: 'btn-sm', type: 'button', onClick: () => { ui.showPass = !ui.showPass; render(); } }, ui.showPass ? t('hide') : t('show')) ) ); } // Import accepts a recovery phrase, an xpub/zpub (watch-only), or an xprv/zprv // (full spending). Classify the pasted text and open the right kind of wallet. async function openWallet(input, opts = {}) { ui.unlockError = ''; const raw = (input || '').trim(); const m = raw.replace(/\s+/g, ' '); if (isValidMnemonic(m)) { await enterWallet(m, ui.passphrase, { generated: opts.generated }); ui.draftMnemonic = ''; return; } // discard the used draft so the next "Add wallet" generates a fresh seed let pk; try { pk = parseExtendedKey(raw); } catch { ui.unlockError = t('invalidImport'); render(); return; } const acc = pk.kind === 'xpub' ? addOrGetAccount({ type: 'watch', label: defaultLabel('watch'), xpub: pk.key }) : addOrGetAccount({ type: 'full', label: defaultLabel('full'), xprv: pk.key }); ui.fromWallet = false; await activateAccount(acc, { fresh: true }); } // Register a full (seed) wallet as an account and open it. async function enterWallet(mnemonic, passphrase, opts = {}) { const acc = addOrGetAccount({ type: 'full', label: defaultLabel('full'), mnemonic: (mnemonic || '').trim().replace(/\s+/g, ' '), passphrase: passphrase || '', }); await activateAccount(acc, { ...opts, fresh: true }); } // Load an account into the wallet and start scanning. Full-account seeds are // kept in sessionStorage (ephemeral); a refresh restores the open account. async function activateAccount(acc, opts = {}) { activeId = acc.id; // A gift link generates this wallet only to claim into. Keep it provisional // until the user commits (claims, or chooses to keep it / enters the wallet), // so bailing from an already-claimed gift doesn't leave an empty account. if (opts.gift && !opts.existingClaim) acc.provisional = true; // The network is a per-wallet property: activating a wallet activates its // network. Accounts created before this carried none — they adopt the // current global choice once, then keep it. The global setting remains the // default for the NEXT new wallet. if (!acc.network) acc.network = getNetwork(); const netName = acc.network; if (netName !== getNetwork()) { setNetwork(netName); for (const f of FEATURES) { try { f.networkChanged && f.networkChanged(netName); } catch {} } } if (acc.type === 'watch') wallet.load({ xpub: acc.xpub, netName, offline: false }); else if (acc.xprv) wallet.load({ xprv: acc.xprv, netName, offline: false }); else wallet.load({ mnemonic: acc.mnemonic, passphrase: acc.passphrase || '', netName, offline: false, spFresh: !!opts.generated }); // Record the account-level xpub so this wallet survives a session wipe as a // watch-only entry (see the durable account directory) — you keep seeing your // balance/history and re-enter the seed to spend again. if (acc.type !== 'watch' && !acc.provisional) acc.xpub = wallet.accountXpub(); persistAccounts(); autoSave(acc); for (const f of FEATURES) { try { f.init && f.init(); } catch {} } // per-feature wallet lifecycle const hadCache = wallet.restoreCache(); // show last-known balance/history instantly // An opened gift link starts on a claim/back-up screen instead of the wallet. ui.screen = opts.gift ? 'claim' : 'wallet'; ui.claimStep = 'welcome'; ui.claimCode = opts.gift || null; ui.claimError = ''; ui.claimTaken = null; ui.claimNotVisible = false; ui.claimChecking = !!opts.gift; // gate the Claim button until we've checked // Restore the tab / open tx from the last session so a refresh keeps the // user's place. A gift link always opens the claim screen instead. const nav = (() => { try { return JSON.parse(sessionStorage.getItem(NAV_KEY) || 'null'); } catch { return null; } })(); ui.tab = (!opts.gift && nav && nav.tab) || 'receive'; ui.txDetail = (!opts.gift && nav && nav.txDetail) || null; // Not baselined yet — stays null until the scan + ack logic below sets it, // so the celebration never fires for payments that were already there at // import (the index only looks "advanced" because the scan hadn't run yet). ui.receiveSeenIndex = null; ui.send = blankSend(); ui.draft = null; ui.sendResult = null; ui.giftMode = false; ui.offlineFallback = false; render(); if (ui.txDetail) openTx(ui.txDetail); // restored a tx detail — fill in fee/details if missing // No manual offline switch: try to scan, and if the network is unreachable, // fall back to offline mode automatically. if (typeof navigator !== 'undefined' && navigator.onLine === false) { enterOfflineFallback(); return; } try { // Celebration baseline. Opening/importing/switching a wallet (opts.fresh) // baselines to the current frontier, so payments already received before // opening never trigger the "payment received" screen. On a same-session // refresh we restore the persisted ack — which is advanced the moment the // celebration is shown, so a payment celebrates once and never reappears. let ack; if (opts.fresh) { ack = wallet.nextReceiveIndex; wallet.setReceiveAck(ack); } else { ack = wallet.getReceiveAck(); if (ack == null) { ack = wallet.nextReceiveIndex; wallet.setReceiveAck(ack); } } // For a fresh open, leave receiveSeenIndex null until the post-scan baseline // below. Setting it now (to the pre-scan frontier) lets the socket's reconcile // credit a deposit that predates this open and briefly flash the "payment // received" screen before the baseline catches up. A same-session refresh // uses the persisted ack right away. if (!opts.fresh) ui.receiveSeenIndex = ack; // Go Live immediately: the socket must not wait on Nostr (up to 6s) or any // discovery scan. The cache/Nostr state is already on screen. wallet.startRealtime(); // Opened a gift link? Let the gifts feature verify its funding coin is // still unspent before the claim proceeds. if (opts.gift) featureHook('giftOpened', opts.gift); // Cross-device state in the background. State comes from the local cache or // Nostr — both restore the full balance, coins, and history — so a full API // scan runs ONLY when we have neither (e.g. a seed imported on a fresh device // with no synced state); that's what discovers the used addresses. Otherwise // the socket + frontier poll keep us current with no refresh-time burst. const hadNostr = wallet.watchOnly || !wallet.syncFromNostr ? false : await wallet.syncFromNostr(); if (!hadCache && !hadNostr) { await wallet.scan({ silent: false }); } // Re-baseline a fresh open against the final frontier (Nostr or the discovery // scan may have advanced it) so payments that predate the open don't celebrate. if (opts.fresh) { ack = wallet.nextReceiveIndex; wallet.setReceiveAck(ack); ui.receiveSeenIndex = ack; } wallet.retrack(); // re-subscribe to the latest frontier (Nostr/scan may have moved it) } catch { enterOfflineFallback(); } } // --- accounts ------------------------------------------------------------- // The working set of wallets you can switch between. Full (seed-bearing) // accounts live only in sessionStorage — ephemeral, wiped when the browser // closes (no seed on disk by default). Watch-only accounts hold just an xpub, // so they're additionally persisted in localStorage and reload across restarts. const ACCOUNTS_KEY = 'btc-wallet-accounts'; // sessionStorage: session list + active const WATCH_KEY = 'btc-wallet-watch'; // localStorage: persisted watch-only accounts let accounts = []; let activeId = null; const genId = () => 'a' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7); const credId = (a) => (a.type === 'watch' ? 'w:' + a.xpub : a.xprv ? 'x:' + a.xprv : 'f:' + a.mnemonic + '|' + (a.passphrase || '')); const activeAccount = () => accounts.find((a) => a.id === activeId) || null; function defaultLabel(type) { const n = accounts.filter((a) => a.type === type).length + 1; return t(type === 'watch' ? 'watchLabelN' : 'walletLabelN', { n }); } // The durable account directory (localStorage): a view-only mirror of every // account — id, label, and account xpub, never a seed. So a wallet survives a // session wipe (browser closed without "Save to device") as a watch-only entry: // you keep seeing balance/history and re-enter the seed to spend again. function loadWatchAccounts() { try { const dir = JSON.parse(localStorage.getItem(WATCH_KEY) || '[]'); return dir.filter((d) => d.xpub).map((d) => ({ id: d.id, label: d.label, type: 'watch', xpub: d.xpub, autoLock: d.autoLock || 0, network: d.network })); } catch { return []; } } function saveDirectory() { try { const dir = accounts.filter((a) => a.xpub && !a.provisional).map((a) => ({ id: a.id, label: a.label, xpub: a.xpub, autoLock: a.autoLock || 0, network: a.network })); localStorage.setItem(WATCH_KEY, JSON.stringify(dir)); } catch {} } function persistAccounts() { try { sessionStorage.setItem(ACCOUNTS_KEY, JSON.stringify({ accounts, activeId })); } catch {} saveDirectory(); } function clearAccounts() { accounts = []; activeId = null; try { sessionStorage.removeItem(ACCOUNTS_KEY); } catch {} } // Add an account (deduped by credential), returning the stored object. function addOrGetAccount(partial) { const cid = credId(partial); let acc = accounts.find((a) => credId(a) === cid); if (!acc) { // every new wallet is born on the currently-selected network acc = { id: genId(), network: getNetwork(), ...partial }; accounts.push(acc); persistAccounts(); } return acc; } // Commit the active account — clear the "provisional" gift flag so it's no // longer discarded on bail. Called once the user claims, keeps, or enters it. function commitAccount() { const a = accounts.find((x) => x.id === activeId); if (a && a.provisional) { delete a.provisional; if (a.type !== 'watch') a.xpub = wallet.accountXpub(); // now eligible for the durable directory persistAccounts(); } autoSave(a); } function removeAccount(id) { const acc = accounts.find((a) => a.id === id); if (!acc) return; accounts = accounts.filter((a) => a.id !== id); persistAccounts(); if (activeId === id) { if (accounts.length) activateAccount(accounts[0], { fresh: true }); else lock(); } else { render(); } } function switchAccount(id) { const acc = accounts.find((a) => a.id === id); if (acc) activateAccount(acc, { fresh: true }); } // Restore accounts after a refresh (sessionStorage); on a fresh session, prompt // to unlock the encrypted vault if there is one, else seed from watch-only // accounts. Returns true if it handled the entry (opened or showed a prompt). function restoreAccountsState() { let sess = null; try { sess = JSON.parse(sessionStorage.getItem(ACCOUNTS_KEY) || 'null'); } catch {} if (sess && Array.isArray(sess.accounts) && sess.accounts.length) { accounts = sess.accounts; const active = accounts.find((a) => a.id === sess.activeId) || accounts[0]; activateAccount(active); return true; } if (hasVault()) { // A blank (optional) vault password unlocks seamlessly with no prompt. if (attemptVaultUnlock('')) { if (accounts.length) activateAccount(accounts[0], { fresh: true }); else { ui.screen = 'unlock'; render(); } return true; } ui.screen = 'vault'; render(); return true; } const watch = loadWatchAccounts(); if (watch.length) { accounts = watch.slice(); activateAccount(accounts[0], { fresh: true }); return true; } return false; } // --- encrypted vault (optional password-persisted full accounts) ---------- const VAULT_KEY = 'btc-wallet-vault'; let vaultPassword = null; // in memory once unlocked/set this session; cleared on lock function loadVaultBlob() { try { return JSON.parse(localStorage.getItem(VAULT_KEY) || 'null'); } catch { return null; } } function hasVault() { return !!loadVaultBlob(); } // Re-encrypt the vault from the currently-persisted full accounts (needs the // in-memory password). Removes the blob when nothing is persisted. function writeVault() { if (vaultPassword == null) return; const list = accounts.filter((a) => a.type === 'full' && a.persisted) .map((a) => (a.xprv ? { label: a.label, xprv: a.xprv, network: a.network } : { label: a.label, mnemonic: a.mnemonic, passphrase: a.passphrase || '', network: a.network })); try { if (!list.length) localStorage.removeItem(VAULT_KEY); else localStorage.setItem(VAULT_KEY, JSON.stringify(encryptVault(list, vaultPassword))); } catch {} } // Bring vault-saved seeds into the session. Each upgrades the matching watch-only // directory entry (by xpub) to a spendable full account so wallets aren't // duplicated; one with no directory entry is added fresh. function mergeVaultList(list) { for (const v of list) { const xpub = v.xprv ? accountXpubFor({ xprv: v.xprv }) : accountXpubFor({ mnemonic: v.mnemonic, passphrase: v.passphrase || '' }); const existing = accounts.find((a) => a.xpub === xpub); if (existing) { existing.type = 'full'; if (v.xprv) { existing.xprv = v.xprv; delete existing.mnemonic; delete existing.passphrase; } else { existing.mnemonic = v.mnemonic; existing.passphrase = v.passphrase || ''; delete existing.xprv; } if (v.label) existing.label = v.label; if (v.network && !existing.network) existing.network = v.network; existing.persisted = true; } else { const acc = addOrGetAccount( v.xprv ? { type: 'full', label: v.label || defaultLabel('full'), xprv: v.xprv, xpub } : { type: 'full', label: v.label || defaultLabel('full'), mnemonic: v.mnemonic, passphrase: v.passphrase || '', xpub } ); if (v.network) acc.network = v.network; acc.persisted = true; } } } // Keeping a wallet is the default: a seed that only lives in this tab is one // refresh away from being gone, and that surprise costs people money. So a // wallet saves itself to the device unless we can't write the vault — when // one exists but is locked, the manual Save button still covers it. No // password is set here; Settings can add one later. function autoSave(acc) { if (!acc || acc.type !== 'full' || acc.persisted || acc.provisional) return; if (vaultPassword == null) { if (hasVault()) return; // locked vault: needs their password, leave it manual vaultPassword = ''; } acc.persisted = true; writeVault(); persistAccounts(); } // Toggle persistence on a full account. Prompts for the vault password when it // isn't unlocked this session ('set' the first time, 'enter' if a vault exists). function startSave(id) { const acc = accounts.find((a) => a.id === id); if (!acc || acc.type !== 'full') return; if (vaultPassword != null) { acc.persisted = true; writeVault(); persistAccounts(); render(); return; } ui.pw = { purpose: 'save', accId: id, mode: hasVault() ? 'enter' : 'set', v1: '', v2: '', error: '' }; render(); } // --- load a seed into a watch-only account (make it spendable) -------------- function startLoadSeed(opts = {}) { // accId lets the per-wallet settings page import a seed for a specific // (possibly non-active) watch-only wallet; defaults to the active one. ui.loadSeed = { value: '', passphrase: '', save: !!opts.save, error: '', accId: opts.accId || activeId }; render(); } function cancelLoadSeed() { ui.loadSeed = null; render(); } async function doLoadSeed() { const ls = ui.loadSeed; const acc = (ls && accounts.find((a) => a.id === ls.accId)) || activeAccount(); if (!ls || !acc || !acc.xpub) return; const raw = (ls.value || '').trim().replace(/\s+/g, ' '); if (!raw) { ls.error = t('enterSeedToSpend'); render(); return; } let next = null; try { if (isValidMnemonic(raw)) { if (accountXpubFor({ mnemonic: raw, passphrase: ls.passphrase || '' }) !== acc.xpub) { ls.error = t('seedMismatch'); render(); return; } next = { mnemonic: raw, passphrase: ls.passphrase || '' }; } else { const pk = parseExtendedKey(raw); // throws if not an extended key if (pk.kind !== 'xprv') { ls.error = t('seedNeedsPrivate'); render(); return; } if (accountXpubFor({ xprv: pk.key }) !== acc.xpub) { ls.error = t('seedMismatch'); render(); return; } next = { xprv: pk.key }; } } catch { ls.error = t('seedInvalid'); render(); return; } // Upgrade in place — keep id/label/xpub so the cache, directory entry and // history all carry over; the account is now spendable. acc.type = 'full'; if (next.mnemonic) { acc.mnemonic = next.mnemonic; acc.passphrase = next.passphrase; delete acc.xprv; } else { acc.xprv = next.xprv; delete acc.mnemonic; delete acc.passphrase; } const save = ls.save; ui.loadSeed = null; await activateAccount(acc, { fresh: false }); if (save) startSave(acc.id); // opens the Save-to-device (vault) flow else render(); } function startForget(id) { if (vaultPassword != null) { const acc = accounts.find((a) => a.id === id); if (acc) acc.persisted = false; writeVault(); persistAccounts(); render(); return; } ui.pw = { purpose: 'forget', accId: id, mode: 'enter', v1: '', v2: '', error: '' }; render(); } function cancelPw() { ui.pw = null; render(); } function startChangePw() { ui.pw = { purpose: 'change', mode: 'change', v0: '', v1: '', v2: '', error: '' }; render(); } function submitPw() { const p = ui.pw; if (p.purpose === 'change') { // Re-encrypt the actual vault (decrypt with the current password, encrypt // with the new one) so we never drop wallets that aren't in this session. let list; try { list = decryptVault(loadVaultBlob(), p.v0 || ''); } catch { p.error = t('pwWrong'); render(); return; } if (p.v1 !== p.v2) { p.error = t('pwMismatch'); render(); return; } try { localStorage.setItem(VAULT_KEY, JSON.stringify(encryptVault(list, p.v1 || ''))); } catch {} if (vaultPassword != null) vaultPassword = p.v1 || ''; // keep the unlocked session in sync ui.pw = null; render(); toast(t('pwChanged')); return; } if (p.mode === 'set') { // Password is optional — a blank one just persists without protection. if (p.v1 !== p.v2) { p.error = t('pwMismatch'); render(); return; } vaultPassword = p.v1 || ''; } else { let list; try { list = decryptVault(loadVaultBlob(), p.v1); } catch { p.error = t('pwWrong'); render(); return; } vaultPassword = p.v1; mergeVaultList(list); // bring existing persisted accounts into the session } const acc = accounts.find((a) => a.id === p.accId); if (acc) acc.persisted = p.purpose === 'save'; writeVault(); persistAccounts(); ui.pw = null; render(); } // Try to decrypt the vault with the given password; on success, seed the // accounts (watch-only directory + the unlocked full wallets). Returns success. function attemptVaultUnlock(password) { let list; try { list = decryptVault(loadVaultBlob(), password); } catch { return false; } vaultPassword = password; // Start from the durable directory (watch-only views of every known wallet), // then upgrade the vault-saved ones to spendable — so non-saved wallets still // appear (watch-only) instead of vanishing. accounts = loadWatchAccounts(); mergeVaultList(list); // Now that we have the password, finish signing out any saved wallets whose // timer expired while we were away (couldn't re-encrypt the vault until now). if (_bootAwayMs) { const due = accounts.filter((a) => accAutoLock(a) > 0 && _bootAwayMs >= accAutoLock(a)); for (const a of due) { wipeAccountCache(a); accounts = accounts.filter((x) => x.id !== a.id); } if (due.some((a) => a.persisted)) writeVault(); _bootAwayMs = 0; } return true; } // On-open vault unlock (password prompt). function unlockVault() { if (!attemptVaultUnlock(ui.vaultPw)) { ui.vaultError = t('pwWrong'); render(); return; } ui.vaultPw = ''; ui.vaultError = ''; if (accounts.length) activateAccount(accounts[0], { fresh: true }); else lock(); // everything got signed out by the timer } function skipVault() { ui.vaultPw = ''; ui.vaultError = ''; const watch = loadWatchAccounts(); if (watch.length) { accounts = watch.slice(); activateAccount(accounts[0], { fresh: true }); } else { ui.screen = 'unlock'; render(); } } function enterOfflineFallback() { wallet.setOffline(true); wallet.deriveWindow(40); ui.offlineFallback = true; ui.claimChecking = false; // can't verify a gift offline; don't hang on the loader ui.tab = 'settings'; render(); } async function retryOnline() { ui.offlineFallback = false; wallet.setOffline(false); ui.tab = 'receive'; render(); try { await wallet.scan(); wallet.startRealtime(); } catch { enterOfflineFallback(); } } // --- auto log-out, per wallet ---------------------------------------------- // Each account can choose how long the app may sit in the background before that // wallet is signed out (removed from this device — session, directory, cache, // and its vault entry). Other wallets are untouched. The countdown only runs // while the app is hidden/unfocused, never while you're looking at it. 0 = never. const AUTOLOCK_OPTIONS = [ { ms: 0, label: 'autolockNever' }, { ms: 60_000, label: 'autolock1m' }, { ms: 300_000, label: 'autolock5m' }, { ms: 3_600_000, label: 'autolock1h' }, { ms: 86_400_000, label: 'autolock1d' }, ]; const accAutoLock = (a) => (a && Number(a.autoLock)) || 0; const AWAY_AT_KEY = 'btc-wallet-bg-at'; let _awayTimer = null; let _bootAwayMs = 0; // away duration carried into the vault-unlock check this session const _awayAt = () => { try { return Number(localStorage.getItem(AWAY_AT_KEY)) || 0; } catch { return 0; } }; const _clearAwayAt = () => { try { localStorage.removeItem(AWAY_AT_KEY); } catch {} }; // Remove one wallet's cached state — the xpub mirror plus its seed-keyed cache // (when the seed is at hand), including the :ack/:gift suffixes. function wipeAccountCache(acc) { if (!acc) return; const ids = []; if (acc.xpub) ids.push(acc.xpub); if (acc.xprv) ids.push(acc.xprv); else if (acc.mnemonic) ids.push(`${acc.mnemonic}\n${acc.passphrase || ''}`); const bases = ids.map(cacheKeyFor); try { const keys = []; for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); if (k && bases.some((b) => k.startsWith(b))) keys.push(k); } keys.forEach((k) => localStorage.removeItem(k)); } catch {} } // Sign out a single wallet: remove it from the device entirely (session, // directory, cache, and the vault if it was saved). Others are left alone. function wipeAccount(id) { const acc = accounts.find((a) => a.id === id); if (!acc) return; const wasActive = activeId === id; wipeAccountCache(acc); const wasPersisted = acc.persisted; accounts = accounts.filter((a) => a.id !== id); if (wasPersisted && vaultPassword != null) writeVault(); // re-encrypt the vault without it persistAccounts(); // refresh the durable directory if (wasActive) { if (accounts.length) activateAccount(accounts[0], { fresh: true }); else lock(); } } // Sign out every loaded wallet whose timer has elapsed for the given away time. function evaluateOverdue(away) { const due = accounts.filter((a) => accAutoLock(a) > 0 && away >= accAutoLock(a)).map((a) => a.id); for (const id of due) wipeAccount(id); } // Arm a timer for the soonest-due wallet so a blurred-but-visible window still // signs out on time (the timestamp covers throttled/backgrounded tabs). function armAwayTimer() { clearTimeout(_awayTimer); _awayTimer = null; const awayAt = _awayAt(); if (!awayAt) return; const locks = accounts.map(accAutoLock).filter((ms) => ms > 0); if (!locks.length) return; const elapsed = Date.now() - awayAt; if (locks.some((ms) => elapsed >= ms)) { evaluateOverdue(elapsed); armAwayTimer(); return; } const next = Math.min(...locks.map((ms) => ms - elapsed)); _awayTimer = setTimeout(() => { evaluateOverdue(Date.now() - awayAt); armAwayTimer(); }, Math.max(0, next)); } function onAppHidden() { try { localStorage.setItem(AWAY_AT_KEY, String(Date.now())); } catch {} armAwayTimer(); } function onAppVisible() { clearTimeout(_awayTimer); _awayTimer = null; const awayAt = _awayAt(); _clearAwayAt(); if (awayAt) evaluateOverdue(Date.now() - awayAt); } // At boot the in-memory timer is gone (reload / discarded tab). Drop overdue // wallets from the persisted session and directory before anything is restored; // overdue saved wallets are removed from the vault when it's unlocked. function applyBootAutoLogout() { const awayAt = _awayAt(); _clearAwayAt(); if (!awayAt) return; const away = Date.now() - awayAt; _bootAwayMs = away; const overdue = (a) => { const ms = Number(a.autoLock) || 0; return ms > 0 && away >= ms; }; try { const sess = JSON.parse(sessionStorage.getItem(ACCOUNTS_KEY) || 'null'); if (sess && Array.isArray(sess.accounts)) { const keep = sess.accounts.filter((a) => { if (overdue(a)) { wipeAccountCache(a); return false; } return true; }); sess.accounts = keep; if (!keep.find((a) => a.id === sess.activeId)) sess.activeId = keep[0] ? keep[0].id : null; sessionStorage.setItem(ACCOUNTS_KEY, JSON.stringify(sess)); } } catch {} try { const dir = JSON.parse(localStorage.getItem(WATCH_KEY) || '[]'); const keep = dir.filter((d) => { if (overdue(d)) { wipeAccountCache(d); return false; } return true; }); localStorage.setItem(WATCH_KEY, JSON.stringify(keep)); } catch {} } function lock() { wallet.stopRealtime(); clearAccounts(); vaultPassword = null; wallet.load({ mnemonic: '', passphrase: '', netName: getNetwork(), offline: false }); wallet.mnemonic = ''; ui.screen = hasVault() ? 'vault' : 'unlock'; ui.unlockTab = 'create'; ui.fromWallet = false; ui.watchXpub = ''; ui.watchLabel = ''; ui.pw = null; ui.vaultPw = ''; ui.vaultError = ''; ui.confirmClear = false; ui.confirmRemove = null; ui.editId = null; ui.editLabel = ''; ui.createStep = 'gen'; ui.draftMnemonic = ''; ui.importText = ''; ui.passphrase = ''; ui.confirm = []; ui.revealShown = false; ui.pubkeyShown = false; ui.giftMode = false; ui.giftAmount = ''; ui.giftCode = null; ui.giftError = ''; ui.giftMax = false; ui.giftSplitOffer = null; ui.revokeId = null; ui.receiveSeenIndex = null; ui.txDetail = null; ui.broadcastTx = null; ui.bump = null; render(); } // ================================================================ WALLET // The avatar anchors the identity menu: Edit profile, Settings, Messages, // Logout. The avatar node itself comes from the messages feature (profile // cache + punk fallback); without that feature there's no menu. function avatarMenu() { const me = (featureHook('nostrLoginIdentity') || {}).pubkey || (wallet.nostrPubkey && wallet.nostrPubkey()); // No identity (watch-only wallet): the menu still exists — Settings and // Logout don't need keys — behind a neutral face. const node = (me && featureHook('headerAvatar', me)) || h('span', { class: 'chat-avatar header-ava fallback', html: '', }); const item = (label, fn) => h('button', { class: 'icon-select-item', onClick: () => { ui.avatarMenu = false; fn(); render(); }, }, label); return h('span', { class: 'avatar-menu-wrap' }, h('button', { class: 'header-avatar', onClick: () => { ui.avatarMenu = !ui.avatarMenu; render(); } }, node), (() => { const dt = animWindow('avmenu', !!ui.avatarMenu, 220); return ui.avatarMenu ? h('span', {}, h('div', { class: 'menu-backdrop', onClick: () => { ui.avatarMenu = false; render(); } }), (() => { const m = h('div', { class: 'icon-select-menu avatar-menu' }, me ? item(t('profEdit'), () => featureHook('showProfile', me)) : null, item(t('tabSettings'), () => { ui.chatOpen = false; ui.profilePk = null; ui.screen = 'wallet'; ui.tab = 'settings'; ui.settingsPage = null; }), me ? item(t('msgDmsTitle'), () => { ui.profilePk = null; ui.chatOpen = true; }) : null, item(t('logout'), () => lock())); applyAnim(m, 'anim-menu', dt); return m; })()) : null; })()); } // Messages sit one tap away, left of the wallet selector. The dot is presence, // not a count — it says "someone's waiting", and the chat list says who. function messagesBtn() { const me = (featureHook('nostrLoginIdentity') || {}).pubkey || (wallet.nostrPubkey && wallet.nostrPubkey()); if (!me) return null; const unread = featureHook('unreadMessages') || 0; return h('button', { class: 'header-msgs' + (unread ? ' unread' : ''), title: t('msgDmsTitle'), 'aria-label': t('msgDmsTitle'), onClick: () => { ui.profilePk = null; ui.chatOpen = true; ui.msgView = 'home'; render(); }, }, h('span', { class: 'hm-ico', html: '', })); } function brandHeader(withLock) { const acc = activeAccount(); return h( 'div', { class: 'row between' }, h( 'div', { class: 'brand', style: 'cursor:pointer', title: t('home'), onClick: goHome }, h('div', { class: 'logo-full', 'aria-label': 'coinos', role: 'img', html: '' }) ), withLock ? h('div', { class: 'row gap6', style: 'align-items:center' }, messagesBtn(), h('button', { class: 'btn-sm', onClick: () => { ui.screen = 'accounts'; render(); } }, acc ? acc.label : t('accounts')), avatarMenu()) : null ); } // Settings tab — view the recovery phrase (+ passphrase) again (important for // users who skipped backup verification) and the offline snapshot transfer. // The phrase is gated: the real words are never put in the DOM until "Reveal", // so the warning is read first. // Rescan a single address on demand — recovers a deposit to a reused old // address without a full wallet rescan. Multiple can be queued at once; the // API layer's global scheduler serializes and spaces the underlying requests // (and backs off on 429), so a flurry of clicks stays within the rate limit. async function doRescanAddress(chain, index) { if (wallet.offline) { toast(t('scanOffline')); return; } const id = chain + '/' + index; if (ui.rescanning.has(id)) return; // already queued ui.rescanning.add(id); render(); try { await wallet.rescanAddress(chain, index); } catch (e) { toast(e.message || t('rescanFailed')); } ui.rescanning.delete(id); render(); } // Paginated list of every known address, each with its own rescan button. function addressScanView() { const addrs = wallet.knownAddresses(); const pages = Math.ceil(addrs.length / PAGE_SIZE) || 1; const page = Math.min(ui.addrScanPage, pages - 1); const slice = addrs.slice(page * PAGE_SIZE, page * PAGE_SIZE + PAGE_SIZE); return h( 'div', { class: 'col', style: 'gap:16px' }, h('div', { class: 'card col', style: 'gap:12px' }, h('div', { class: 'row between' }, h('h3', { style: 'margin:0' }, t('rescanAddresses')), h('button', { class: 'btn-sm', onClick: () => { ui.addrScan = false; render(); } }, t('back')) ), h('p', { class: 'small muted', style: 'margin:0' }, t('rescanAddrDesc')), h('div', { class: 'list' }, ...slice.map((a) => { const id = a.chain + '/' + a.index; const busy = ui.rescanning.has(id); return h('div', { class: 'item' }, h('div', { class: 'grow' }, h('div', { class: 'mono small break' }, shortAddr(a.address, 16, 10), a.used ? null : h('span', { class: 'badge off dot', style: 'font-size:10px;margin-left:6px;padding:1px 7px' }, t('unusedTag'))), h('div', { class: 'path' }, `${a.chain}/${a.index}` + (a.balance ? ' · ' + fmtAmount(a.balance) + ' ' + unitLabel() : '')) ), busy ? h('button', { class: 'btn-sm', disabled: true }, h('span', { class: 'spinner sm' })) : h('button', { class: 'btn-sm', onClick: () => doRescanAddress(a.chain, a.index) }, t('rescanOne')) ); }) ), pager(page, addrs.length, (p) => { ui.addrScanPage = p; render(); }) ) ); } // Settings is a hub: the main page is nothing but doors, each subpage owns // one concern — Wallet (name/pubkey/seed), Payments (address + Spending), // Network (chain + data source), Nostr, Advanced (rarities). function settingsTab() { if (ui.addrScan && !wallet.offline) return addressScanView(); const a = activeAccount(); const page = (kids) => h('div', { class: 'col', style: 'gap:16px' }, ...kids, h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.settingsPage = null; render(); } }, t('back'))); switch (ui.settingsPage) { case 'wallet': return page(a ? [walletNameCard(a), pubkeyCard(a), recoveryCard(a)] : []); case 'payments': return page(featureAll('settingsCards').reverse()); case 'network': return page([networkCard(), explorerCard()]); case 'nostr': return nostrSettingsView(); case 'advanced': return advancedSettingsView(); } const nav = (id, label) => h('button', { class: 'btn-block settings-nav', onClick: () => { ui.settingsPage = id; render(); } }, label); return h( 'div', { class: 'col', style: 'gap:10px' }, h('div', { class: 'row gap6', style: 'align-items:center;margin:2px 0 6px 4px' }, h('span', { style: 'display:flex;color:var(--muted)', html: '' }), h('h3', { style: 'margin:0' }, t('tabSettings'))), nav('wallet', t('settingsWallet')), nav('payments', t('settingsPayments')), nav('network', t('settingsNetwork')), nav('nostr', t('nostrSettings')), nav('advanced', t('advancedSettings')), h('button', { class: 'btn-ghost btn-block', style: 'margin-top:6px', onClick: () => { ui.tab = wallet.offline ? 'settings' : 'receive'; render(); } }, t('back')) ); } // Everything nostr — identity, sync, wallet connect — on its own page. function nostrSettingsView() { return h( 'div', { class: 'col', style: 'gap:16px' }, ...featureAll('nostrSettingsCards'), h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.settingsPage = null; render(); } }, t('back')) ); } // Rarely used, easy to fat-finger: offline transfer, address rescan, auto-logout. function advancedSettingsView() { const a = activeAccount(); return h( 'div', { class: 'col', style: 'gap:16px' }, wallet.watchOnly ? null : h( 'div', { class: 'card col' }, h('h3', {}, t('offlineTransfer')), snapshotActions() ), h( 'div', { class: 'card col' }, h('h3', {}, t('rescan')), h('p', { class: 'small muted', style: 'margin:0' }, t('rescanDesc')), wallet.offline ? null : h('button', { onClick: () => { ui.addrScan = true; ui.addrScanPage = 0; render(); } }, t('rescanAddresses')) ), a ? autolockCard(a) : null, // replay the first-run coach marks (the wizard itself only runs once) h('div', { class: 'card col' }, h('h3', {}, t('tourReplayTitle')), h('p', { class: 'small muted', style: 'margin:0' }, t('tourReplayDesc')), h('button', { onClick: () => { ui.settingsPage = null; ui.tab = 'receive'; ui.tour = 0; render(); } }, t('tourReplay'))), h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.settingsPage = null; render(); } }, t('back')) ); } // Move the ACTIVE wallet to another network (and make that the default for // new wallets). Changes addresses/balances entirely, so the account is // restamped and reloaded under it. function changeNetwork(net) { if (net === getNetwork()) return; setNetwork(net); for (const f of FEATURES) { try { f.networkChanged && f.networkChanged(net); } catch {} } const acc = accounts.find((a) => a.id === activeId); if (acc) { acc.network = net; persistAccounts(); activateAccount(acc); } else render(); } // Network selector. Per-network endpoints (Electrum server / block explorer) are // configured in the Data source card below — no duplicate URL fields here. function networkCard() { const net = getNetwork(); return h( 'div', { class: 'card col' }, h('h3', {}, 'Network'), h('p', { class: 'small muted', style: 'margin:0' }, 'Bitcoin network this wallet operates on. Pick its servers under Data source.'), h('select', { onChange: (e) => changeNetwork(e.target.value) }, NETWORKS.map((n) => h('option', { value: n.id, selected: net === n.id }, n.label))) ); } function explorerCard() { const net = getNetwork(); const src = getSource(); const sources = dataSources(net); const pick = (id) => { setSource({ id, url: '' }); render(); // Switching source can swap the whole backend (electrum⇄esplora), so rebuild // + rescan — except an empty custom (wait for the URL). if (id !== 'custom' && !wallet.offline) wallet.reloadExplorer(); }; const typeDesc = t(src.type === 'electrum' ? 'detectedElectrum' : 'detectedEsplora'); return h( 'div', { class: 'card col' }, h('h3', {}, t('dataSource')), h('p', { class: 'small muted', style: 'margin:0' }, t('dataSourceDesc')), h('select', { onChange: (e) => pick(e.target.value) }, sources.map((o) => h('option', { value: o.id, selected: o.id === src.id }, o.id === 'custom' ? o.label : (o.url || o.base).replace(/^\w+:\/\//, '')))), src.id === 'custom' ? h('label', { class: 'field' }, h('span', { class: 'lab' }, t('sourceUrl')), h('input', { type: 'text', class: 'mono-input', placeholder: 'wss://your-node:50004 · https://your-esplora/api', autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: src.url || '', onChange: (e) => { const url = e.target.value.trim(); setSource({ id: 'custom', url }); render(); if (!wallet.offline) wallet.reloadExplorer(); }, }), h('div', { class: 'small faint' }, src.url ? typeDesc : t('sourceUrlHint')) ) : h('div', { class: 'small faint' }, typeDesc) ); } // Language selector. Changing it persists the choice, flips text direction for // RTL languages, and re-renders the whole app in the new language. function languagePicker() { return h( 'select', { value: getLang(), onChange: async (e) => { const code = e.target.value; setLang(code); await loadLocale(code); // fetch the locale's strings before re-rendering applyDir(); render(); }, }, LANGS.map(([code, name]) => h('option', { value: code, selected: code === getLang() }, name)) ); } // Reflect the active language's writing direction on (rtl for ar/ur). function applyDir() { try { document.documentElement.dir = isRTL() ? 'rtl' : 'ltr'; document.documentElement.lang = getLang(); } catch {} } function goHome() { // Bailing from an unclaimed gift — discard the provisional wallet it generated. const active = accounts.find((a) => a.id === activeId); if (active && active.provisional) { accounts = accounts.filter((a) => a.id !== active.id); persistAccounts(); activeId = accounts[0] ? accounts[0].id : null; } // A wallet is open → home is its Receive page (not the create/import screen), // so the logo is always a way back to your wallet. if (activeAccount()) { ui.screen = 'wallet'; // Home means home: clear every takeover a feature or subpage may hold — // chat, profile, ark exit, settings subpages, the address rescan list. ui.chatOpen = false; ui.profilePk = null; ui.profEdit = null; ui.arkExitPage = null; ui.settingsPage = null; ui.addrScan = false; ui.arkMoveOpen = false; ui.tab = wallet.offline ? 'settings' : 'receive'; ui.draft = null; ui.sendResult = null; ui.sendError = ''; ui.fromWallet = false; } else { ui.screen = 'unlock'; ui.unlockTab = 'create'; ui.createStep = 'gen'; ui.draftMnemonic = ''; ui.confirm = []; ui.unlockError = ''; } render(); } // Wallets a gift could be claimed into: full accounts open this session plus the // durable directory (every wallet's xpub, watch-only), deduped by xpub. Claiming // sends the gift to the wallet's address; a watch-only one then needs the seed // re-entered to spend. Empty for a first-time recipient. function claimTargets() { let sess = null; try { sess = JSON.parse(sessionStorage.getItem(ACCOUNTS_KEY) || 'null'); } catch {} const open = ((sess && Array.isArray(sess.accounts)) ? sess.accounts : []).filter((a) => !a.provisional); const seen = new Set(open.map((a) => a.xpub).filter(Boolean)); return [...open, ...loadWatchAccounts().filter((w) => !seen.has(w.xpub))]; } // Account switcher: pick a wallet, add another, or lock the session. function accountsScreen() { if (ui.pw) return h('div', { class: 'col', style: 'gap:16px' }, brandHeader(false), pwPromptCard()); if (ui.confirmClear) { return h('div', { class: 'col', style: 'gap:16px' }, brandHeader(false), h('div', { class: 'card col' }, h('h3', {}, t('clearAll')), h('div', { class: 'warn-box' }, t('clearAllWarn')), h('div', { class: 'row gap6' }, h('button', { class: 'btn-ghost grow', onClick: () => { ui.confirmClear = false; render(); } }, t('back')), h('button', { class: 'btn-primary grow', onClick: clearAll }, t('clearAll')) ) ) ); } if (ui.confirmRemove) { const acc = accounts.find((a) => a.id === ui.confirmRemove); return h('div', { class: 'col', style: 'gap:16px' }, brandHeader(false), h('div', { class: 'card col' }, h('h3', {}, t('removeWalletTitle')), h('div', { class: 'warn-box' }, t('removeWalletWarn', { name: acc ? acc.label : '' })), h('div', { class: 'row gap6' }, h('button', { class: 'btn-ghost grow', onClick: () => { ui.confirmRemove = null; render(); } }, t('back')), h('button', { class: 'btn-primary grow', onClick: () => { const id = ui.confirmRemove; ui.confirmRemove = null; removeAccount(id); } }, t('remove')) ) ) ); } return h( 'div', { class: 'col', style: 'gap:16px' }, brandHeader(false), h('div', { class: 'card col' }, h('h3', {}, t('accounts')), h('div', { class: 'col', style: 'gap:0' }, accounts.map((a) => { const isActive = a.id === activeId; const netTag = a.network && a.network !== 'mainnet' ? ' · ' + a.network : ''; const tag = (a.type === 'watch' ? ' · ' + t('watchOnlyTag') : '') + netTag; // "saved" shown in the link below, not the title if (ui.editId === a.id) { return h('div', { class: 'row gap6', style: 'padding:10px 0; border-bottom:1px solid var(--line)' }, h('input', { type: 'text', style: 'flex:1', value: ui.editLabel, autofocus: true, onInput: (e) => (ui.editLabel = e.target.value), onKeyDown: (e) => { if (e.key === 'Enter') renameAccount(a.id); } }), h('button', { class: 'btn-sm', onClick: () => renameAccount(a.id) }, t('save')), h('button', { class: 'btn-sm', onClick: () => { ui.editId = null; render(); } }, t('back')) ); } return h('div', { class: 'col', style: 'gap:4px; padding:10px 0; border-bottom:1px solid var(--line)' }, h('div', { class: 'row between' }, h('button', { class: 'linklike', style: 'text-align:left;flex:1;font-size:15px;' + (isActive ? 'font-weight:600' : ''), onClick: () => { if (isActive) { ui.screen = 'wallet'; render(); } else switchAccount(a.id); }, }, (isActive ? '● ' : '○ ') + a.label + tag), h('button', { class: 'btn-sm wallet-row-btn', title: t('walletSettings'), onClick: () => openAccountSettings(a.id), html: '' }), h('button', { class: 'btn-sm wallet-row-btn', title: t('remove'), onClick: () => { ui.confirmRemove = a.id; render(); } }, '✕') ), a.type === 'full' ? h('button', { class: 'linklike small', style: 'align-self:flex-start', onClick: () => (a.persisted ? startForget(a.id) : startSave(a.id)) }, a.persisted ? t('forgetDevice') : t('saveDevice')) : null ); }) ), h('button', { class: 'btn-block', onClick: () => { ui.draftMnemonic = ''; ui.createStep = 'gen'; ui.confirm = []; ui.screen = 'unlock'; ui.unlockTab = 'create'; ui.fromWallet = true; ui.unlockError = ''; render(); } }, t('addWallet')), hasVault() ? h('button', { class: 'btn-ghost btn-block', onClick: startChangePw }, t('changePassword')) : null, h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.confirmClear = true; render(); } }, t('clearAll')) ), h('button', { class: 'btn-ghost btn-block', onClick: () => goBack(() => { ui.screen = 'wallet'; }) }, t('back')) ); } function renameAccount(id) { const acc = accounts.find((a) => a.id === id); if (acc) { const v = (ui.editLabel || '').trim(); if (v) acc.label = v; persistAccounts(); if (acc.persisted) writeVault(); } ui.editId = null; render(); } // Open the per-wallet settings page for an account. function openAccountSettings(id) { ui.settingsId = id; ui.editLabel = null; ui.revealShown = false; ui.pubkeyShown = false; ui.loadSeed = null; ui.screen = 'accountSettings'; render(); } // Per-wallet settings cards, shared by the per-account screen (Accounts → ⚙) // and the main Settings tab (which inlines them for the active wallet). function recoveryCard(a) { const isWatch = a.type === 'watch' || (!a.mnemonic && !a.xprv); const shown = ui.revealShown; const words = a.mnemonic ? a.mnemonic.split(' ') : []; if (isWatch) { return ui.loadSeed && ui.loadSeed.accId === a.id ? loadSeedCard() : h('div', { class: 'card col' }, h('h3', {}, t('recoveryPhrase')), h('p', { class: 'small muted', style: 'margin:0' }, t('watchOnlyNote')), a.xpub ? h('button', { class: 'btn-primary btn-block', style: 'margin-top:4px', onClick: () => startLoadSeed({ accId: a.id }) }, t('loadSeedBtn')) : null ); } if (!a.mnemonic) return h('div', { class: 'card col' }, h('h3', {}, t('importedKey')), h('p', { class: 'small muted', style: 'margin:0' }, t('importedKeyNote'))); // Two steps on purpose. The first opens the card, with the words masked — // so the grid is on screen and you can see how much of it there is (and get // a warning) before anything readable is over your shoulder. The second // actually shows them. if (!shown) return h('div', { class: 'card col' }, h('h3', {}, t('recoveryPhrase')), h('p', { class: 'small muted', style: 'margin:0' }, t('recoveryDesc')), h('button', { class: 'btn-block', onClick: () => { ui.revealShown = 'masked'; render(); } }, t('revealRecovery'))); const unmasked = shown === 'words'; return h('div', { class: 'card col' }, h('h3', {}, t('recoveryPhrase')), h('div', { class: 'warn-box' }, t('recoveryWarn')), h('div', { class: 'words' }, words.map((w, i) => h('div', { class: 'w' + (unmasked ? '' : ' masked') }, h('span', { class: 'n' }, i + 1), h('span', { class: 't' }, unmasked ? w : '••••••')))), unmasked && a.passphrase ? h('div', { class: 'col gap6' }, h('span', { class: 'lab' }, t('bip39Passphrase')), h('div', { class: 'addr-box' }, a.passphrase)) : null, unmasked ? h('div', { class: 'row gap6 wrap' }, copyBtn(a.mnemonic, t('copyPhrase')), a.passphrase ? copyBtn(a.passphrase, t('copyPassphrase')) : null, h('button', { class: 'btn-sm grow', onClick: () => { ui.revealShown = false; render(); } }, t('hide'))) : h('div', { class: 'row gap6' }, h('button', { class: 'btn-primary grow', onClick: () => { ui.revealShown = 'words'; render(); } }, t('revealWords')), h('button', { class: 'btn-sm', onClick: () => { ui.revealShown = false; render(); } }, t('hide'))) ); } function walletNameCard(a) { const saveName = () => { const v = (ui.editLabel || '').trim(); if (v && v !== a.label) { a.label = v; persistAccounts(); if (a.persisted) writeVault(); } ui.editLabel = null; render(); }; return h('div', { class: 'card col' }, h('h3', {}, t('walletName')), h('div', { class: 'row gap6' }, h('input', { type: 'text', style: 'flex:1', value: ui.editLabel != null ? ui.editLabel : a.label, onInput: (e) => { ui.editLabel = e.target.value; }, onKeyDown: (e) => { if (e.key === 'Enter') saveName(); } }), h('button', { class: 'btn-sm', onClick: saveName }, t('save')) ) ); } function pubkeyCard(a) { let zpub = ''; try { if (a.xpub) zpub = xpubToZpub(a.xpub); } catch {} return h('div', { class: 'card col' }, h('h3', {}, t('publicKey')), h('p', { class: 'small muted', style: 'margin:0' }, t('publicKeyDesc')), ui.pubkeyShown ? h('div', { class: 'col gap6' }, h('div', { class: 'addr-box break', style: 'font-size:12px' }, zpub), h('div', { class: 'row gap6 wrap' }, copyBtn(zpub, t('copyKey')), h('button', { class: 'btn-sm grow', onClick: () => { ui.pubkeyShown = false; render(); } }, t('hide')))) : h('button', { class: 'btn-block', onClick: () => { ui.pubkeyShown = true; render(); } }, t('showPublicKey')) ); } function autolockCard(a) { return h('div', { class: 'card col' }, h('h3', {}, t('autolockTitle')), h('p', { class: 'small muted', style: 'margin:0' }, t('autolockDesc')), h('select', { onChange: (e) => { a.autoLock = Number(e.target.value) || 0; persistAccounts(); if (a.persisted) writeVault(); render(); } }, AUTOLOCK_OPTIONS.map((o) => h('option', { value: String(o.ms), selected: o.ms === accAutoLock(a) }, t(o.label)))) ); } // Per-wallet settings screen — reached from Accounts → ⚙ for any account // (not just the active one; full accounts keep their seed in memory). function accountSettingsScreen() { const a = accounts.find((x) => x.id === ui.settingsId) || activeAccount(); if (!a) { ui.screen = 'accounts'; return accountsScreen(); } return h( 'div', { class: 'col', style: 'gap:16px' }, brandHeader(false), walletNameCard(a), pubkeyCard(a), recoveryCard(a), autolockCard(a), h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.editLabel = null; ui.revealShown = false; ui.pubkeyShown = false; ui.loadSeed = null; goBack(() => { ui.screen = 'accounts'; }); } }, t('back')) ); } // Wipe every wallet from this device: session accounts, the encrypted vault, // and saved watch-only accounts. Unbacked-up seeds are unrecoverable after this. function clearAll() { try { localStorage.removeItem(VAULT_KEY); } catch {} try { localStorage.removeItem(WATCH_KEY); } catch {} ui.confirmClear = false; lock(); } function pwPromptCard() { const p = ui.pw; const change = p.purpose === 'change'; const newish = change || p.mode === 'set'; // entering a (new) password with confirm return h('div', { class: 'card col' }, h('h3', {}, change ? t('changePassword') : p.mode === 'set' ? t('setPassword') : t('enterPassword')), h('p', { class: 'small muted', style: 'margin:0' }, change ? t('changePasswordDesc') : p.mode === 'set' ? t('setPasswordDesc') : t('enterPasswordDesc')), change ? h('input', { type: 'password', placeholder: t('currentPassword'), value: p.v0, onInput: (e) => (p.v0 = e.target.value) }) : null, h('input', { type: 'password', placeholder: newish ? t('passwordOptional') : t('password'), value: p.v1, onInput: (e) => (p.v1 = e.target.value) }), newish ? h('input', { type: 'password', placeholder: t('confirmPassword'), value: p.v2, onInput: (e) => (p.v2 = e.target.value) }) : null, p.error && h('div', { class: 'notice err' }, p.error), h('div', { class: 'row gap6' }, h('button', { class: 'btn-ghost grow', onClick: cancelPw }, t('back')), h('button', { class: 'btn-primary grow', onClick: submitPw }, newish ? t('save') : t('unlock')) ) ); } function vaultScreen() { return h( 'div', { class: 'col', style: 'gap:16px' }, brandHeader(false), h('div', { class: 'card col' }, h('h3', {}, t('unlockSaved')), h('p', { class: 'small muted', style: 'margin:0' }, t('unlockSavedDesc')), h('input', { type: 'password', placeholder: t('password'), value: ui.vaultPw, onInput: (e) => (ui.vaultPw = e.target.value), onKeyDown: (e) => { if (e.key === 'Enter') unlockVault(); } }), ui.vaultError && h('div', { class: 'notice err' }, ui.vaultError), h('button', { class: 'btn-primary btn-block', onClick: unlockVault }, t('unlock')), h('button', { class: 'btn-ghost btn-block', onClick: skipVault }, t('useAnotherWallet')) ) ); } // ================================================================ onboarding // First visit on a fresh device: a full-screen wizard — welcome, nostr // yes/no (sign in or silently mint a wallet), username, avatar, confetti — // then a three-stop coach-mark tour of the home screen. One time only. const ONBOARDED_KEY = 'btc-wallet-onboarded'; function shouldOnboard() { try { if (localStorage.getItem(ONBOARDED_KEY)) return false; } catch {} return !ui.onbSkip && accounts.length === 0 && !hasVault(); } const PUNK_PICKS = [7, 14, 21, 3, 33, 40, 47, 36, 61, 26, 12, 50]; const punkImg = (n) => `punks/${n}.webp`; async function onbUpload(file) { const fd = new FormData(); fd.append('file', file); const r = await fetch('https://nostr.build/api/v2/upload/files', { method: 'POST', body: fd }); const j = await r.json(); const url = j?.data?.[0]?.url; if (!url) throw new Error(t('onbUploadFailed')); return url; } function onboardScreen() { ui.onb ||= { step: 'welcome' }; const o = ui.onb; // steps that were waiting on something advance the moment it exists if ((o.step === 'nostr' || o.step === 'signin') && ui.screen === 'wallet' && activeAccount()) { o.step = 'legacy'; o.enterAddr = featureHook('namesAddress') || null; } if (o.step === 'username') { const addr = featureHook('namesAddress'); if (o.enterAddr === undefined) o.enterAddr = addr || null; // Only a real (custom) name advances by itself — the background // auto-claim of the npub-shaped default just makes the Keep button // appear. A restored login whose custom name recovers skips ahead too. if (addr && addr !== o.enterAddr && !/^npub1/.test(addr)) o.step = 'avatar'; } const page = (kids) => h('div', { class: 'col onb', style: 'gap:20px' }, ...kids); const title = (txt) => h('h2', { class: 'onb-title' }, txt); if (o.step === 'legacy') { // The migrate link carries this wallet's payment address, which the // background claim may still be minting. Don't make the user watch that: // show the finished page and wait (on the button) only if they tap // before it lands. const goLegacy = async () => { let addr = featureHook('namesAddress'); if (!addr) { o.legacyBusy = true; render(); for (let i = 0; i < 40 && !addr; i++) { await new Promise((r) => setTimeout(r, 500)); addr = featureHook('namesAddress'); } o.legacyBusy = false; if (!addr) { ui.onbError = t('onbLegacyNoAddr'); render(); return; } } o.wentToLegacy = true; location.href = `https://coinos.io/migrate?to=${encodeURIComponent(addr)}&back=${encodeURIComponent(location.origin + '/')}`; }; return page([ title(t('onbLegacyTitle')), h('p', { class: 'muted', style: 'margin:0' }, t('onbLegacyBody')), h('button', { class: 'btn-primary btn-block', style: 'padding:14px', disabled: !!o.legacyBusy, onClick: goLegacy, }, o.legacyBusy ? h('span', { class: 'spinner sm' }) : t('onbLegacyGo')), h('button', { class: 'btn-block', style: 'padding:14px', onClick: () => { o.step = 'username'; render(); } }, o.wentToLegacy ? t('onbLegacyDone') : t('onbLegacyNo')), ui.onbError ? h('div', { class: 'notice err' }, ui.onbError) : null, // No Back: the wallet exists by now, so the nostr question behind us // would just bounce straight back here. Both choices lead forward. ]); } if (o.step === 'welcome') { return page([ h('div', { class: 'onb-hero' }, brandHeader(false)), title(t('onbWelcomeTitle')), h('p', { class: 'muted', style: 'margin:0;font-size:16px' }, t('onbWelcomeBody')), h('button', { class: 'btn-primary btn-block', style: 'font-size:17px;padding:14px', onClick: () => { o.step = 'nostr'; render(); } }, t('onbStart')), h('button', { class: 'linklike small', onClick: () => { ui.onbSkip = true; ui.onb = null; render(); } }, t('onbHaveWallet')), ]); } if (o.step === 'nostr') { return page([ h('div', { class: 'onb-mark', html: NOSTR_MARK }), title(t('onbNostrTitle')), h('p', { class: 'muted', style: 'margin:0' }, t('onbNostrBody')), h('button', { class: 'btn-primary btn-block', style: 'padding:14px', onClick: () => { o.step = 'signin'; ui.nostrLoginOpen = true; render(); } }, t('onbNostrYes')), h('button', { class: 'btn-block', style: 'padding:14px', disabled: ui.onbBusy, onClick: async () => { ui.onbBusy = true; render(); try { await enterWallet(newMnemonic(), '', { generated: true }); } catch (e) { ui.onbError = e.message; } ui.onbBusy = false; render(); } }, ui.onbBusy ? h('span', { class: 'spinner sm' }) : t('onbNostrNo')), ui.onbError ? h('div', { class: 'notice err' }, ui.onbError) : null, h('button', { class: 'linklike small', onClick: () => { o.step = 'welcome'; render(); } }, t('back')), ]); } if (o.step === 'signin') { return page([ h('div', { class: 'onb-mark', html: NOSTR_MARK }), title(t('onbSigninTitle')), featureHook('unlockExtra') || h('div', { class: 'notice err' }, 'nostr login unavailable'), h('button', { class: 'linklike small', onClick: () => { o.step = 'nostr'; render(); } }, t('back')), ]); } if (o.step === 'username') { const addr = featureHook('namesAddress'); return page([ title(t('onbNameTitle')), h('p', { class: 'muted', style: 'margin:0' }, t('onbNameBody')), featureHook('namesClaimForm') || h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('onbNameWait'))), addr ? h('button', { class: 'btn-block', onClick: () => { o.step = 'avatar'; render(); } }, t('onbKeepPre'), h('span', { class: 'onb-addr' }, addr), t('onbKeepPost')) : null, ]); } if (o.step === 'avatar') { const me = (featureHook('nostrLoginIdentity') || {}).pubkey || (wallet.nostrPubkey && wallet.nostrPubkey()); const sel = o.avatar; return page([ title(t('onbAvatarTitle')), h('p', { class: 'muted', style: 'margin:0' }, t('onbAvatarBody')), h('div', { class: 'onb-punks' }, ...PUNK_PICKS.map((n) => h('img', { class: 'onb-punk' + (sel === punkImg(n) ? ' sel' : ''), src: punkImg(n), alt: '', onClick: () => { o.avatar = punkImg(n); render(); }, onError: (e) => { e.target.remove(); }, // a 502'd punk removes itself }))), h('div', { class: 'row gap6' }, h('button', { class: 'btn-block grow', disabled: ui.onbBusy, onClick: () => document.getElementById('onb-file')?.click() }, t('onbUpload')), h('input', { type: 'file', id: 'onb-file', accept: 'image/*', style: 'display:none', onChange: async (e) => { const f = e.target.files && e.target.files[0]; if (!f) return; ui.onbBusy = true; ui.onbError = ''; render(); try { o.avatar = await onbUpload(f); } catch (err) { ui.onbError = err.message; } ui.onbBusy = false; render(); } })), o.avatar ? h('img', { class: 'onb-preview', src: o.avatar, alt: '' }) : null, ui.onbError ? h('div', { class: 'notice err' }, ui.onbError) : null, h('button', { class: 'btn-primary btn-block', style: 'padding:14px', disabled: ui.onbBusy, onClick: async () => { ui.onbBusy = true; ui.onbError = ''; render(); try { const addr = featureHook('namesAddress'); // keeping the default still publishes it — their punk should be // their face on every nostr client, not just here const picture = o.avatar || (me ? 'https://v3.coinos.io/' + punkUrl(me) : undefined); const fields = { name: addr ? addr.split('@')[0] : undefined, picture }; if (fields.name || fields.picture) await featureHook('publishProfile', fields); o.step = 'success'; } catch (e) { ui.onbError = e.message; } ui.onbBusy = false; render(); } }, ui.onbBusy ? h('span', { class: 'spinner sm' }) : (o.avatar ? t('onbContinue') : t('onbSkipAvatar'))), ]); } // success return page([ h('div', { class: 'onb-confetti' }, ...Array.from({ length: 18 }, () => h('i'))), h('h2', { class: 'onb-title', style: 'text-align:center' }, t('onbDoneTitle')), h('div', { class: 'check-badge onb-check' }, '✓'), h('p', { class: 'muted', style: 'margin:0;text-align:center' }, t('onbDoneBody')), h('button', { class: 'btn-primary btn-block', style: 'padding:14px', onClick: () => { try { localStorage.setItem(ONBOARDED_KEY, '1'); } catch {} ui.onb = null; ui.tour = 0; ui.tab = 'receive'; render(); } }, t('onbEnter')), ]); } // Returning from the legacy migration: coinos.io sends ?migrated= // once it has swept the balance and released the name. Claim it here — the // registrar's "is this a legacy user?" guard now passes. function claimMigratedName() { let name = null; try { const u = new URL(location.href); name = u.searchParams.get('migrated'); if (name) { u.searchParams.delete('migrated'); history.replaceState(null, '', u.pathname + u.search); } } catch {} if (!name) return; ui.migrating = name; render(); let tries = 0; const attempt = () => { featureHook('namesClaimName', name) .then(() => { ui.migrating = null; toast(t('migrateClaimed', { name })); render(); }) .catch(() => { // DNS/registrar caches the legacy lookup briefly; give it a minute if (++tries < 12) return setTimeout(attempt, 5000); ui.migrating = null; toast(t('migrateClaimFailed', { name })); render(); }); }; attempt(); } // ---- the coach-mark tour: three stops, measured against the live DOM ---- const TOUR = [ { sel: '.header-avatar', key: 'tourAvatar' }, { sel: '.balance-seg', key: 'tourBalance' }, { sel: '.tabs', key: 'tourTabs' }, ]; function tourOverlay() { const step = TOUR[ui.tour]; if (!step) return null; const card = h('div', { class: 'tour-card' }, h('div', {}, t(step.key)), h('div', { class: 'row gap6', style: 'justify-content:flex-end;margin-top:10px' }, h('button', { class: 'btn-sm', onClick: () => { ui.tour = null; render(); } }, t('tourSkip')), h('button', { class: 'btn-sm btn-primary', onClick: () => { ui.tour = ui.tour + 1 < TOUR.length ? ui.tour + 1 : null; render(); } }, ui.tour + 1 < TOUR.length ? t('tourNext') : t('done')))); const ring = h('div', { class: 'tour-ring' }); const wrap = h('div', { class: 'tour-overlay' }, ring, card); requestAnimationFrame(() => { const el = document.querySelector(step.sel); if (!el || !wrap.isConnected) return; const r = el.getBoundingClientRect(); ring.style.cssText = `top:${r.top - 6}px;left:${r.left - 6}px;width:${r.width + 12}px;height:${r.height + 12}px`; const below = r.bottom + 12; card.style.top = (below + 180 < innerHeight ? below : Math.max(12, r.top - 130)) + 'px'; }); return wrap; } function walletScreen() { if (ui.onb) return onboardScreen(); // A feature can hold the wallet behind a required onboarding step (picking // a username). Imported wallets skip it once their name is recovered. const ob = featureHook('onboardingView'); if (ob) { return h('div', { class: 'col', style: 'gap:0' }, brandHeader(false), h('div', { class: 'mt16' }, ob)); } // Settings is a full page of its own — no balance, no tab strip; the hub's // back button (or the logo / avatar menu) leads out. Offline keeps the // classic layout: settings is the forced tab there, and hiding the balance // and tab strip would wall off the cached balance, history, and receive. if (ui.tab === 'settings' && !wallet.offline) { return h( 'div', { class: 'col', style: 'gap:0' }, brandHeader(true), ui.offlineFallback && wallet.offline ? offlineBanner() : null, h('div', { class: 'mt16' }, tabContent()) ); } // Tab changes slide the content pane sideways like a carousel — swipe // direction when a gesture caused it, index order for taps. The balance // card and tab strip stay planted. const TAB_ORDER = ['receive', 'send', 'history']; if (uiChanged('tabnav', ui.tab)) { const idx = TAB_ORDER.indexOf(ui.tab); _tabCls = 'anim-tab-' + (_swipeDir || (idx >= 0 && idx < _prevTabIdx ? 'right' : 'left')); _tabAt = performance.now(); if (idx >= 0) _prevTabIdx = idx; } _swipeDir = null; const pane = h('div', { class: 'tab-pane' }, tabContent()); const tabDt = performance.now() - _tabAt; // skip the pane slide while a full-page entrance is already running if (performance.now() - _navAt > 400) applyAnim(pane, _tabCls, tabDt < 300 ? tabDt : -1); return h( 'div', { class: 'col', style: 'gap:0' }, brandHeader(true), h('div', { class: 'mt16' }, balanceCard()), ui.offlineFallback && wallet.offline ? offlineBanner() : null, tabsBar(), pane, ui.tour != null ? tourOverlay() : null ); } function offlineBanner() { return h( 'div', { class: 'notice info row between', style: 'margin:12px 0 0' }, h('span', {}, t('offlineBanner')), h('button', { class: 'btn-sm', onClick: retryOnline }, t('retry')) ); } // ---- the global account selector ------------------------------------- // The wallet is always "in" one of its two balances: Spending (Ark + // Lightning) or Savings (on-chain). The balance card headline, the Receive // pane, and the gift source all follow it. const ACCOUNT_KEY = 'btc-wallet-account'; function accountSel() { if (!featureHook('arkReady')) return 'savings'; // no ark, no choice if (!ui.account) { let saved = null; try { saved = localStorage.getItem(ACCOUNT_KEY); } catch {} ui.account = saved === 'savings' ? 'savings' : 'spending'; } return ui.account; } let _accDir = null; // which way the balance face should slide in function setAccountSel(a, dir) { if (ui.account === a) return; _accDir = dir || (a === 'savings' ? 'left' : 'right'); ui.account = a; try { localStorage.setItem(ACCOUNT_KEY, a); } catch {} render(); } const SWAP_ICON = ''; function balanceCard() { // Dim + spin until this network has data (cache, Nostr state, or a scan has // populated balances once). `scanning` alone misses the window between // wallet.load() and the first scan — the cache check and Nostr sync on a // network switch — where a bare 0 reads as a final balance. Background // updates after that happen silently. const firstLoad = !wallet.loaded && !wallet.offline; const pending = wallet.pendingIncoming; const featLines = featureAll('balanceLines'); // Money lives in two places and we name them for what they're FOR, not for // the rails underneath: Spending is the instant off-chain balance, Saving // is the on-chain one. The headline is simply everything you have. const spending = featureHook('spendingSat') || 0; const saving = wallet.spendable; const hasArk = !!featureHook('arkReady'); const sel = hasArk ? accountSel() : 'savings'; const isSpending = sel === 'spending'; // One balance at a time, full size: the card IS the account you're in, and // everything (receive, gifts) follows it. Swipe or tap the dots to flip. const dtAcc = animWindow('acct', sel, 300); const face = h('div', { class: 'balance-face' }, h('div', { class: 'row between', style: 'align-items:center' }, h('div', { class: 'small faint', style: 'text-transform:uppercase;letter-spacing:.06em' }, hasArk ? (isSpending ? t('spendingLabel') : t('savingLabel')) : t('balance')), // Swiping only helps on touch, and dots are an indicator rather than a // target — so the switch is a real button that names where it goes. hasArk ? h('button', { class: 'balance-switch', onClick: () => setAccountSel(isSpending ? 'savings' : 'spending', isSpending ? 'left' : 'right'), }, h('span', { html: SWAP_ICON }), h('span', { class: 'bsw-label' }, isSpending ? t('savingLabel') : t('spendingLabel'))) : null), h('div', { class: 'amt', style: firstLoad ? 'opacity:.3' : '' }, firstLoad ? h('span', { class: 'spinner sm', style: 'margin-right:8px' }) : null, animatedAmount('bal:' + sel, isSpending ? spending : saving), ' ', unitTag('unit')), pending > 0 || featLines.length ? h('div', { class: 'split' }, pending > 0 ? h('div', {}, h('div', { class: 'k' }, t('pending')), h('div', { class: 'v pending' }, fmtAmount(pending), ' ', unitTag())) : null, ...featLines.map((l) => h('div', {}, h('div', { class: 'k' }, l.label), h('div', { class: 'v' }, fmtAmount(l.sat), ' ', unitTag())))) : null); applyAnim(face, 'anim-tab-' + (_accDir || 'left'), dtAcc); _accDir = null; return h( 'div', { class: 'card balance' }, face, // "Move money" and friends: the only door between the two balances. ...featureAll('balanceActions').map((a2) => h('button', { class: 'btn-sm', style: 'margin-top:10px', onClick: a2.onClick }, a2.label)), // A feature can unfold UI right on the card (ark's inline move panel). (() => { const dt = animWindow('unfold', !!ui.arkMoveOpen, 300); const extra = featureHook('balanceExtra'); if (!extra) return null; const w = h('div', {}, extra); applyAnim(w, 'anim-unfold', dt); return w; })() ); } function tabsBar() { // Settings lives in the avatar menu, not the tab strip. const tabs = [ ['receive', t('tabReceive')], // Watch-only wallets show Send too — it prompts to load the seed to spend. ['send', t('tabSend')], ['history', t('tabHistory')], ]; return h( 'div', { class: 'tabs' }, tabs.map(([id, label]) => tabBtn(label, ui.tab === id, () => { ui.tab = id; ui.sendError = ''; // a stale send error never survives a tab change ui.revealShown = false; // re-mask the recovery phrase whenever tabs change ui.txDetail = null; // back to the history list when leaving/returning ui.arkMoveDetail = null; ui.giftDetail = null; ui.addrScan = false; // and back to the main Settings, not the address list ui.settingsPage = null; ui.bump = null; ui.giftMode = false; render(); }) ) ); } function tabContent() { switch (ui.tab) { case 'receive': return receiveTab(); case 'send': return sendTab(); case 'history': return historyTab(); case 'settings': return settingsTab(); } } // A payment is "recent" enough to celebrate if it's still pending or confirmed // within the last couple hours. This is a hard guard so an old payment can never // trigger the celebration on import, regardless of receive-index bookkeeping. function hasRecentIncoming() { const now = Date.now() / 1000; return wallet.txs.some((tx) => tx.net > 0 && (!tx.confirmed || (tx.blockTime && now - tx.blockTime < 2 * 3600))); } // ---------------------------------------------------------------- Receive // A dropdown that shows an icon beside each option (a native