// 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 './rich-text.js'; // , the composers' field import { makeSearcher, resultRows, searchable, punkUrl, warmSearch } from './recipient-search.js'; import { assetUrl } from './asset-url.js'; import { npubOf, seedPubkey, neventOf, parseNostrPubkey } from './nostr.js'; import { nip98Header } from './nip98.js'; import { uploadPublicMedia, MediaUploadError } from './media-upload.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, }; let sendPageOpen = false, sendPageRendered = false; function clearSend() { ui.send = blankSend(); ui.draft = null; ui.broadcastTx = null; ui.sendResult = null; ui.sendError = ''; featureAll('resetSend'); sendSearcher.clear(false); sendSearch.rows = null; sendSearch.sync = null; sendSearch.pick = null; sendSearch.el = null; clearTimeout(sendRevealTimer); } // 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. // A new recipient also clears previous payment reviews and results. function showSend({ fresh = false } = {}) { ui.txDetail = null; ui.arkMoveDetail = null; ui.arkReconDetail = null; ui.arkExitDetail = null; ui.giftDetail = null; ui.bump = null; ui.sendError = ''; if (fresh) clearSend(); // the Send tab lives on the wallet screen: from a page like Spending's // settings (its co-operative exit) the tab flipped out of sight ui.screen = 'wallet'; 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); const lazy = lazySrcTag(e.nodeName); for (const [k, v] of Object.entries(attrs || {})) { if (v == null || v === false) continue; if (k === 'src' && lazy) { e.setAttribute('data-lazy-src', v); 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; } // A media element starts downloading the moment its src is set — even off // the document. Every render builds a fresh tree to diff against the live // one, so an or