// 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, warmSearch } from './recipient-search.js'; import { npubOf, seedPubkey, neventOf } from './nostr.js'; import { nip98Header } from './nip98.js'; import { NOSTR_MARK } from './features/nostrlogin.js'; import { scanQr } from './scan.js'; import { dataSources, getSource, setSource, getNetwork, setNetwork, NETWORKS } from './api.js'; import { STAGING } from './build-flags.js'; import { buildFeatures, loadDeferredFeatures } from './features/index.js'; import { t, LANGS, getLang, setLang, isRTL, loadLocale } from './i18n.js'; import { rateNow, rateAt, refreshRates, haveRate, getCurrency, setCurrency, currencies, fmtFiat, fmtFiatBare } from './rates.js'; import { fmtBtc, fmtSats, parseAmount, shortAddr, shortTxid, timeAgo, SATS, BITCOIN_ICON, } from './format.js'; const wallet = new Wallet(); // ---- optional features ------------------------------------------------ // Swaps and Ark 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 (confirm field, shown once one is typed) 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) claimLocked: null, // npub-locked gift blob opened from a /lg/ link (public fields) claimCodeInput: '', // one-time claim code typed on the locked-gift screen 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, }; // Take the wallet to the Send pane: any payment detail left open (a tapped // history row — which claims the whole tab area, whichever tab is picked) // closes first. A feature that jumped straight to ui.tab = 'send' used to // land on a renewal's detail card instead of the form. function showSend() { ui.txDetail = null; ui.arkMoveDetail = null; ui.arkReconDetail = null; ui.arkExitDetail = null; ui.giftDetail = null; ui.bump = null; ui.sendError = ''; ui.tab = 'send'; } 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; e._html = 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') { // assigned as an on* PROPERTY (not addEventListener) so the morph can // transplant handlers between renders: assignment replaces, and _evs // records which slots this render claimed const n = 'on' + k.slice(2).toLowerCase(); e[n] = v; (e._evs || (e._evs = new Set())).add(n); } 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; } // ---------------------------------------------------------------- morphing // render() builds a fresh tree exactly as before, but instead of replacing // the document we PATCH the live DOM into its shape. Nodes keep their // identity across renders, so :hover, focus, text selection, CSS transitions // and scroll positions survive every background repaint — the strobing that // full replacement caused (and that focus/scroll bookkeeping only papered // over) is gone at the root. Escape hatch: a node carrying data-fresh is // swapped wholesale whenever the new render produced a different one (used // where a detached node runs its own animation frames). function morph(a, b) { if (a === b) return; // A subtree frozen for a gesture (the balance carousel mid-drag/mid-glide) // is left exactly as it stands: patching it would strip the drag's inline // snap override and yank the scroll. endCarouselDrag unfreezes and runs a // syncing render once motion stops. if (a.nodeType === 1 && a._skipMorph) return; if (a.nodeType === 3 && b.nodeType === 3) { if (a.data !== b.data) a.data = b.data; return; } if (a.nodeType !== 1 || b.nodeType !== 1 || a.nodeName !== b.nodeName || b.hasAttribute?.('data-fresh')) { a.replaceWith(b); return; } // attributes (className and style strings ride along as attributes) // A reused BUTTON whose class changes is a different control wearing the // same node (screens lining up positionally) — its `transition: background` // would visibly crossfade the old style into the new one (a dark filled // button melting into a text link read as a black flash). Repaints aren't // style animations: snap it, restore the transition after the paint. // A node with a data-key is only ever patched into a node with the SAME // key: siblings line up by position, so a conditional sibling appearing or // vanishing would otherwise hand one section's live state (an expanded //
) to its neighbour. if (a.getAttribute('data-key') !== b.getAttribute('data-key')) { a.replaceWith(b); return; } const snap = a.nodeName === 'BUTTON' && a.getAttribute('class') !== b.getAttribute('class'); //
is the user's doing (the browser toggles the attribute on // click), like a field's value: a render that doesn't set `open` leaves it // alone instead of collapsing the section on every background repaint. const userOwned = (at) => a.nodeName === 'DETAILS' && at.name === 'open'; for (const at of [...a.attributes]) if (!b.hasAttribute(at.name) && !userOwned(at)) a.removeAttribute(at.name); for (const at of [...b.attributes]) if (a.getAttribute(at.name) !== at.value) a.setAttribute(at.name, at.value); if (snap) { a.style.transition = 'none'; requestAnimationFrame(() => requestAnimationFrame(() => { a.style.transition = ''; })); } // event handler slots claimed via h() if (a._evs) for (const n of a._evs) if (!(b._evs && b._evs.has(n))) a[n] = null; if (b._evs) for (const n of b._evs) a[n] = b[n]; a._evs = b._evs; // live form state: value/checked are properties, not attributes — but never // fight the user over a field they're currently editing const tag = a.nodeName; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') { if (document.activeElement !== a) { if (a.value !== b.value) a.value = b.value; if (a.checked !== b.checked) a.checked = b.checked; } if (a.disabled !== b.disabled) a.disabled = b.disabled; } // innerHTML-authored subtrees (svg icons, QRs): compare source, not nodes if (b._html != null) { if (a._html !== b._html) a.innerHTML = b._html; a._html = b._html; return; } if (a._html != null) { // the live node was innerHTML-authored but this render built children — // clear and fall through so the child loop rebuilds it (returning here // once left a form's field permanently empty) a.textContent = ''; a._html = undefined; } // children, by position; snapshot first (appending b's children moves them) const ac = [...a.childNodes]; const bc = [...b.childNodes]; if (allKeyed(bc)) return morphKeyed(a, ac, bc); for (let i = 0; i < bc.length; i++) { if (!ac[i]) a.append(bc[i]); else if (ac[i] !== bc[i]) morph(ac[i], bc[i]); } for (let i = bc.length; i < ac.length; i++) ac[i].remove(); } function morphChildren(parent, next) { const cur = [...parent.childNodes]; if (allKeyed(next)) return morphKeyed(parent, cur, next); for (let i = 0; i < next.length; i++) { if (!cur[i]) parent.append(next[i]); else if (cur[i] !== next[i]) morph(cur[i], next[i]); } for (let i = next.length; i < cur.length; i++) cur[i].remove(); } // A list whose every child carries a data-key is reconciled BY KEY, not by // position: a post let in at the top of the feed used to be patched onto // the old first row's node, and every row below it onto its neighbour's — // so the node the new post was built as never reached the page (nothing // that animates its own arrival could run), and each row's live state slid // one post down. Keyed, the new row is inserted as itself and the old rows // keep their nodes, moved down. Partially keyed lists keep the positional // rule above, where a keyed node still refuses to patch into another key. const allKeyed = (nodes) => nodes.length > 0 && nodes.every((n) => n.nodeType === 1 && n.hasAttribute('data-key')); function morphKeyed(parent, cur, next) { const byKey = new Map(); for (const n of cur) if (n.nodeType === 1 && n.hasAttribute('data-key')) byKey.set(n.getAttribute('data-key'), n); // Drop what the new render no longer has BEFORE placing the rest: with // stale nodes still in front, every survivor's position was off by the // stale count and each one was pulled out and re-inserted — a move per row // per repaint (the feed's window evicting rows at the top did this to all // the rows below on every scroll), and a moved