// 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, utxoId } from './wallet.js'; import { qrSvg } from './qr.js'; import { scanQr } from './scan.js'; import { getSyncConfig, setSyncConfig } from './nostr.js'; import { getExplorerConfig, setExplorerConfig, EXPLORER_PRESETS } from './api.js'; import { t, LANGS, getLang, setLang, isRTL } from './i18n.js'; import { fmtBtc, fmtSats, parseAmount, shortAddr, shortTxid, timeAgo, SATS, } from './format.js'; const wallet = new Wallet(); const ui = { screen: 'unlock', // 'unlock' | 'wallet' | 'howItWorks' returnScreen: 'unlock', // where 'howItWorks' returns to (Back / logo) unlockTab: 'create', // 'create' | 'import' createStep: 'gen', // 'gen' | 'confirm' draftMnemonic: '', confirm: [], // [{ index, value }] confirmPass: '', // re-entered passphrase on the verify step importText: '', passphrase: '', showPass: false, revealShown: false, // recovery phrase unmasked on the Backup tab (after the warning) 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 send: blankSend(), draft: null, // built tx summary awaiting review broadcastTx: null, // scanned signed tx awaiting broadcast confirmation 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/asoltys/halwallet', 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 (beforeinstallprompt fired). installPrompt ? h('span', {}, h('span', { class: 'faint' }, ' · '), h('button', { class: 'linklike', style: 'font-weight:400', onClick: triggerInstall }, t('installApp'))) : null ) ); } // 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). beforeinstallprompt only fires when not already // installed, so the link naturally hides once installed. let installPrompt = null; if (typeof window !== 'undefined') { window.addEventListener('beforeinstallprompt', (e) => { e.preventDefault(); installPrompt = e; render(); }); window.addEventListener('appinstalled', () => { installPrompt = null; render(); }); } async function triggerInstall() { const e = installPrompt; if (!e) return; installPrompt = null; render(); 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(); } function render() { const screen = ui.screen === 'wallet' ? walletScreen() : ui.screen === 'howItWorks' ? howItWorksScreen() : unlockScreen(); root.replaceChildren(screen, footer()); } wallet.subscribe(render); // ---------------------------------------------------------------- utilities let toastTimer; function toast(msg) { 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'), 1600); } 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')); } 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); } // ---------------------------------------------------------------- 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'; render(); }), tabBtn(t('importExisting'), ui.unlockTab === 'import', () => { ui.unlockTab = 'import'; render(); }) ), ui.unlockTab === 'create' ? createPane() : importPane(), ui.unlockError && h('div', { class: 'notice err' }, ui.unlockError) ) ); } // ================================================================ 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'), para('hiwBasics4'), para('hiwBasics5'), h('p', { class: 'small muted hiw-tribute', style: 'margin:0' }, ...linkify(t('hiwTribute'))) ), 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'], ['Hal Finney', 'https://en.wikipedia.org/wiki/Hal_Finney_(computer_scientist)'], ]; 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) }, 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); }, }, t('openWallet')) ), h('button', { class: 'btn-block', onClick: () => openWallet(ui.draftMnemonic) }, 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() { return h( 'div', { class: 'col' }, h( 'label', { class: 'field' }, h('span', { class: 'lab' }, t('seedPhrase')), h('textarea', { placeholder: t('seedPlaceholder'), autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: ui.importText, onInput: (e) => (ui.importText = e.target.value), }) ), 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')) ) ); } async function openWallet(mnemonic) { ui.unlockError = ''; const m = (mnemonic || '').trim().replace(/\s+/g, ' '); if (!isValidMnemonic(m)) { ui.unlockError = t('invalidSeed'); render(); return; } await enterWallet(m, ui.passphrase); } // Load a wallet and start scanning. Persists to sessionStorage so a refresh // restores the open wallet (cleared on logout or when the tab closes). async function enterWallet(mnemonic, passphrase) { wallet.load({ mnemonic, passphrase, netName: 'mainnet', offline: false }); persistSession(mnemonic, passphrase); const hadCache = wallet.restoreCache(); // show last-known balance/history instantly ui.screen = 'wallet'; ui.tab = 'receive'; // 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.offlineFallback = false; render(); // 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 { // Cross-device state: pull the latest from Nostr (may supply state on a // device with no local cache, or a newer copy from another device). const hadNostr = await wallet.syncFromNostr(); // Always do a full scan to fully reconcile (balance, history confirmations, // spends, stale cache/relay state). Cache/Nostr just gave us something to // show instantly; a partial frontier-only refresh leaves the state stale and // would let the celebration baseline before a known payment is even counted. await wallet.scan({ silent: hadCache || hadNostr }); // Persisted acknowledgement: first time, baseline to the current index so we // don't celebrate historical payments. After that, any advance beyond the // acknowledged index shows the celebration (and survives refreshes). let ack = wallet.getReceiveAck(); if (ack == null) { ack = wallet.nextReceiveIndex; wallet.setReceiveAck(ack); } ui.receiveSeenIndex = ack; wallet.startRealtime(); } catch { enterOfflineFallback(); } } const SESSION_KEY = 'btc-wallet-session'; function persistSession(mnemonic, passphrase) { try { sessionStorage.setItem(SESSION_KEY, JSON.stringify({ mnemonic, passphrase })); } catch {} } function clearSession() { try { sessionStorage.removeItem(SESSION_KEY); } catch {} } // Restore an open wallet after a page refresh (same tab session only). function restoreSession() { let saved; try { saved = JSON.parse(sessionStorage.getItem(SESSION_KEY) || 'null'); } catch {} if (saved && saved.mnemonic && isValidMnemonic(saved.mnemonic)) { enterWallet(saved.mnemonic, saved.passphrase || ''); return true; } return false; } function enterOfflineFallback() { wallet.setOffline(true); wallet.deriveWindow(40); ui.offlineFallback = true; 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(); } } function lock() { wallet.stopRealtime(); clearSession(); wallet.load({ mnemonic: '', passphrase: '', netName: 'mainnet', offline: false }); wallet.mnemonic = ''; ui.screen = 'unlock'; ui.createStep = 'gen'; ui.draftMnemonic = ''; ui.importText = ''; ui.passphrase = ''; ui.confirm = []; ui.revealShown = false; ui.receiveSeenIndex = null; ui.txDetail = null; ui.broadcastTx = null; render(); } // ================================================================ WALLET function brandHeader(withLock) { return h( 'div', { class: 'row between' }, h( 'div', { class: 'brand', style: 'cursor:pointer', title: t('home'), onClick: goHome }, h('div', { class: 'logo' }, '₿'), h('h1', {}, t('appTitle')) ), withLock && h('button', { class: 'btn-sm', onClick: lock }, t('logout')) ); } // 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. function settingsTab() { const shown = ui.revealShown; const words = wallet.mnemonic.split(' '); const cells = words.map((w, i) => h('div', { class: 'w' + (shown ? '' : ' masked') }, h('span', { class: 'n' }, i + 1), h('span', { class: 't' }, shown ? w : '••••••') ) ); return h( 'div', { class: 'col', style: 'gap:16px' }, h( 'div', { class: 'card col' }, h('h3', {}, t('recoveryPhrase')), h('div', { class: 'warn-box' }, t('recoveryWarn')), h('div', { class: 'words' }, cells), shown && wallet.passphrase ? h('div', { class: 'col gap6' }, h('span', { class: 'lab' }, t('bip39Passphrase')), h('div', { class: 'addr-box' }, wallet.passphrase) ) : null, shown ? h('div', { class: 'row gap6 wrap' }, copyBtn(wallet.mnemonic, t('copyPhrase')), wallet.passphrase ? copyBtn(wallet.passphrase, t('copyPassphrase')) : null, h('button', { class: 'btn-sm grow', onClick: () => { ui.revealShown = false; render(); } }, t('hide')) ) : h('button', { class: 'btn-primary btn-block', onClick: () => { ui.revealShown = true; render(); } }, t('revealRecovery')) ), 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', { disabled: wallet.scanning, onClick: () => wallet.scan() }, wallet.scanning ? t('scanning') : t('rescanWallet')) ), explorerCard(), syncCard(), h( 'div', { class: 'card col' }, h('h3', {}, t('language')), languagePicker() ) ); } // Block explorer / server selection: a preset (mempool.space, blockstream.info) // or a custom Esplora/electrs REST URL (e.g. your own node). function explorerCard() { const cfg = getExplorerConfig(); return h( 'div', { class: 'card col' }, h('h3', {}, t('explorer')), h('p', { class: 'small muted', style: 'margin:0' }, t('explorerDesc')), h( 'select', { onChange: (e) => { const server = e.target.value; setExplorerConfig({ server, url: cfg.url }); render(); if (server !== 'custom' || cfg.url) wallet.reloadExplorer(); }, }, EXPLORER_PRESETS.map((o) => h('option', { value: o.id, selected: o.id === cfg.server }, o.label)) ), cfg.server === 'custom' ? h('label', { class: 'field' }, h('span', { class: 'lab' }, t('explorerUrl')), h('input', { type: 'text', class: 'mono-input', placeholder: 'https://mempool.space/api', autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: cfg.url, // Apply on blur/Enter, not every keystroke (each change rescans). onChange: (e) => { setExplorerConfig({ server: 'custom', url: e.target.value.trim() }); wallet.reloadExplorer(); }, }), h('div', { class: 'small faint' }, t('explorerUrlHint')) ) : null ); } // Cross-device sync settings: toggle + editable relay list (default coinos). function syncCard() { const cfg = getSyncConfig(); const setEnabled = (enabled) => { if (enabled === cfg.enabled) return; setSyncConfig({ enabled, relays: cfg.relays }); render(); // On enable, pull anything newer from the relays, then push our copy up. if (enabled && !wallet.offline) { wallet.syncFromNostr().catch(() => {}).finally(() => wallet.saveCache()); } }; return h( 'div', { class: 'card col' }, h('h3', {}, t('deviceSync')), h('p', { class: 'small muted', style: 'margin:0' }, t('deviceSyncDesc')), h('div', { class: 'row between' }, h('span', { class: 'lab', style: 'margin:0' }, t('syncAcross')), h('div', { class: 'seg' }, h('button', { type: 'button', class: cfg.enabled ? 'active' : '', onClick: () => setEnabled(true) }, t('syncOn')), h('button', { type: 'button', class: !cfg.enabled ? 'active' : '', onClick: () => setEnabled(false) }, t('syncOff')) ) ), cfg.enabled ? h('label', { class: 'field' }, h('span', { class: 'lab' }, t('relaysLabel')), h('textarea', { placeholder: 'wss://relay.example.com', autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', style: 'min-height:64px', value: cfg.relays.join('\n'), onInput: (e) => { const relays = e.target.value.split('\n').map((s) => s.trim()).filter(Boolean); setSyncConfig({ enabled: true, relays }); }, }), h('div', { class: 'small faint' }, t('relaysHint')) ) : null ); } // 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: (e) => { setLang(e.target.value); 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() { if (ui.screen === 'howItWorks') { ui.screen = ui.returnScreen === 'wallet' ? 'wallet' : 'unlock'; } if (ui.screen === 'wallet') { ui.tab = wallet.offline ? 'settings' : 'receive'; ui.draft = null; ui.sendResult = null; ui.sendError = ''; } else { ui.screen = 'unlock'; ui.unlockTab = 'create'; ui.createStep = 'gen'; ui.draftMnemonic = ''; ui.confirm = []; ui.unlockError = ''; } render(); } function walletScreen() { return h( 'div', { class: 'col', style: 'gap:0' }, brandHeader(true), h('div', { class: 'mt16' }, balanceCard()), ui.offlineFallback && wallet.offline ? offlineBanner() : null, tabsBar(), tabContent() ); } 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')) ); } function balanceCard() { // Only dim on the very first load; background updates happen silently. const firstLoad = wallet.scanning && !wallet.loaded; return h( 'div', { class: 'card balance' }, h('div', { class: 'small faint', style: 'text-transform:uppercase;letter-spacing:.05em' }, t('balance')), // Headline is the projected balance (confirmed + mempool), so a pending // spend is debited immediately rather than waiting for confirmation. h('div', { class: 'amt', style: firstLoad ? 'opacity:.3' : '' }, fmtAmount(wallet.total), ' ', unitTag('unit')), wallet.pending > 0 ? h( 'div', { class: 'split' }, h('div', {}, h('div', { class: 'k' }, t('pending')), h('div', { class: 'v pending' }, fmtAmount(wallet.pending), ' ', unitTag())) ) : null ); } function tabsBar() { const tabs = [ ['receive', t('tabReceive')], ['send', t('tabSend')], ['history', t('tabHistory')], ['settings', t('tabSettings')], ]; return h( 'div', { class: 'tabs' }, tabs.map(([id, label]) => tabBtn(label, ui.tab === id, () => { ui.tab = id; ui.revealShown = false; // re-mask the recovery phrase whenever tabs change ui.txDetail = null; // back to the history list when leaving/returning render(); }) ) ); } function tabContent() { switch (ui.tab) { case 'receive': return receiveTab(); case 'send': return sendTab(); case 'history': return historyTab(); case 'settings': return settingsTab(); } } // ---------------------------------------------------------------- Receive function receiveTab() { // A payment landed on the shown address (the fresh index advanced past what // the user last saw) — celebrate, and wait for a tap before showing the next. // Until receiveSeenIndex has been baselined (post-scan, in enterWallet) it // stays null and we never celebrate, so importing a wallet with existing // history doesn't flash "payment received" for old payments. if (ui.receiveSeenIndex != null && wallet.nextReceiveIndex > ui.receiveSeenIndex) { let amt = 0; for (let i = ui.receiveSeenIndex; i < wallet.nextReceiveIndex; i++) { const e = wallet._addrInfo(0, i); if (e) amt += (e.confirmed || 0) + (e.pending || 0); } return h( 'div', { class: 'card col', style: 'align-items:center;text-align:center;gap:14px;cursor:pointer;padding:48px 20px', onClick: () => { ui.receiveSeenIndex = wallet.nextReceiveIndex; wallet.setReceiveAck(wallet.nextReceiveIndex); render(); }, }, h('div', { class: 'check-badge' }, '✓'), h('h2', { style: 'margin:0' }, t('paymentReceived')), amt ? h('div', { class: 'amount-pos', style: 'font-size:18px' }, '+' + fmtAmount(amt) + ' ' + unitLabel()) : null, h('div', { class: 'small muted' }, t('tapToProceed')) ); } const fresh = wallet.freshReceive(); return h( 'div', { class: 'card col', style: 'align-items:center;gap:14px' }, h('div', { html: qrSvg(fresh.address) }), h('div', { class: 'addr-box', style: 'width:100%' }, fresh.address), copyBtn(fresh.address, t('copyAddress')) ); } // ---------------------------------------------------------------- Send function sendTab() { if (ui.sendResult) return sendResultView(); if (ui.broadcastTx) return broadcastConfirmView(); if (ui.draft) return reviewView(); return sendForm(); } // QR scanning is only possible in a secure context with a camera. const canScan = () => typeof navigator !== 'undefined' && !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia); // Open the camera, then route the decoded payload: a BIP21 URI or address fills // the form; a raw signed-tx hex goes to a broadcast confirmation. async function scanIntoSend() { let text; try { text = await scanQr(t); } catch (e) { ui.sendError = e.message; render(); return; } if (text) handleScanned(text); } function handleScanned(raw) { const text = raw.trim(); const s = ui.send; ui.sendError = ''; if (/^bitcoin:/i.test(text)) { const { address, amount } = parseBip21(text); if (!address) { ui.sendError = t('scanUnrecognized'); render(); return; } s.recipients[0].address = address; if (amount != null) { s.recipients[0].amount = amount; s.max = false; } render(); return; } // A raw signed transaction (hex) — confirm before broadcasting. const compact = text.replace(/\s+/g, ''); if (/^[0-9a-fA-F]+$/.test(compact) && compact.length >= 100 && compact.length % 2 === 0) { try { const info = wallet.parseRawTx(compact); ui.broadcastTx = { hex: compact, ...info }; render(); return; } catch { /* not a parseable tx — fall through and treat as an address */ } } // Otherwise treat it as an address (review will validate it). s.recipients[0].address = text; render(); } // bitcoin:
?amount=