// 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);
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 built here used to start a load per
// repaint that the morph then threw away (a feed scroll built two hundred
// videos and fourteen hundred images in sixteen seconds). The source waits
// on data-lazy-src and becomes src only once the node is in the page (see
// promoteLazySrc); the morph treats the two as the same attribute.
// (a function declaration: h() is called at module load before a const here
// would be initialized)
function lazySrcTag(name) { return name === 'IMG' || name === 'VIDEO' || name === 'AUDIO' || name === 'SOURCE'; }
function promoteLazySrc(root) {
for (const el of root.querySelectorAll('[data-lazy-src]')) {
const v = el.getAttribute('data-lazy-src');
el.removeAttribute('data-lazy-src');
if (el.getAttribute('src') !== v) el.setAttribute('src', v);
}
}
// a node appended by hand, outside render(), gets its source the same way
if (typeof MutationObserver !== 'undefined' && typeof document !== 'undefined') {
new MutationObserver((ms) => {
for (const m of ms) for (const n of m.addedNodes) {
if (n.nodeType !== 1) continue;
if (n.hasAttribute('data-lazy-src')) promoteLazySrc({ querySelectorAll: () => [n] });
promoteLazySrc(n);
}
}).observe(document.documentElement, { childList: true, subtree: true });
}
// ---------------------------------------------------------------- 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)) continue;
if (at.name === 'src' && b.hasAttribute('data-lazy-src')) continue; // the lazy source stands for it
a.removeAttribute(at.name);
}
for (const at of [...b.attributes]) {
if (at.name === 'data-lazy-src') { if (a.getAttribute('src') !== at.value) a.setAttribute('src', at.value); continue; }
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 is taken out of
// the document and back, which restarts its loading.
const wanted = new Set(next.map((b) => b.getAttribute('data-key')));
for (const n of cur) {
if (n.parentNode !== parent) continue;
const k = n.nodeType === 1 ? n.getAttribute('data-key') : null;
if (k == null || !wanted.has(k)) n.remove();
}
const kept = new Set();
for (let i = 0; i < next.length; i++) {
const b = next[i];
const have = byKey.get(b.getAttribute('data-key'));
let node = b;
if (have && !kept.has(have)) {
morph(have, b); // may itself swap the node out (data-fresh, tag change)
node = have.parentNode === parent ? have : b;
}
kept.add(node);
const at = parent.childNodes[i] || null;
if (at !== node) parent.insertBefore(node, at);
}
for (const n of cur) if (!kept.has(n) && n.parentNode === parent) n.remove();
}
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'))
),
// Display preferences live together: the language this reads in, and the
// money amounts are shown in. (The currency sat under Settings → Network
// for exactly one day, where nobody found it.)
h('div', { class: 'row gap6', style: 'margin-top:8px;justify-content:center' }, languagePicker(), currencyPicker()),
// The commit stamp settles "is my PWA fresh?" AND "what code is this?" in
// one glance — its own last line, so it never wraps the credits on mobile.
h('div', { class: 'faint', style: 'margin-top:6px' },
'Running git commit ',
h('a', { class: 'faint', href: 'https://github.com/coinos/coinosv3/commit/{{COMMIT}}', target: '_blank', rel: 'noopener' }, '{{COMMIT}}'))
);
}
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 _tabSeen = false; // a tab has rendered before (slides are for CHANGES, not arrivals)
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);
// data-fresh: this node animates itself with its own rAF loop after render,
// so the morph must install THIS node, not patch text onto last render's
const el = h('span', { 'data-fresh': '1' }, 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;
}
let _carDragging = false; // a balance-carousel drag is in progress
let _renderDeferred = false;
// /chat runs the standalone public community view: it owns #app outright,
// and every app render (feature init, emitter ticks) must stand down or the
// onboarding screen paints over the chat moments after it mounts.
let _publicChatMode = false;
// True until boot has decided whether a session is restoring. Anything that
// renders before then (a deferred feature chunk landing, an emitter tick) is
// rendering without knowing whether anyone is signed in — and the signed-out
// welcome screen is the one screen that must never be guessed at. The boot
// shell holds the frame for those few ms instead; see the dispatch below.
let _bootDeciding = true;
// Belt and braces: a boot path that returns early (or throws) must not leave
// the welcome screen held back forever. Nothing legitimate is still deciding
// four seconds in — the store wait alone caps at 800ms.
setTimeout(() => { if (_bootDeciding) { _bootDeciding = false; render(); } }, 4000);
// A picture, full screen. Posts show images cropped to a few hundred pixels
// so a feed stays a feed; this is where you actually look at one. Tap
// anywhere (or Escape, or Back) to close — no chrome, nothing to aim at.
function imageViewer() {
if (!ui.lightbox) return null;
const close = () => goBack(() => { ui.lightbox = null; });
return h('div', {
class: 'lightbox', onClick: close,
role: 'dialog', 'aria-modal': 'true',
}, h('img', { src: ui.lightbox, alt: '', onClick: (e) => e.stopPropagation() }),
// stop the bubble: the backdrop closes too, and two pops walk out of the room
h('button', { class: 'lightbox-x', 'aria-label': t('close'), onClick: (e) => { e.stopPropagation(); close(); } }, '\u00d7'));
}
if (typeof window !== 'undefined') {
window.addEventListener('keydown', (e) => { if (e.key === 'Escape' && ui.lightbox) goBack(() => { ui.lightbox = null; }); });
}
function render() {
if (_publicChatMode) return;
// A background render mid carousel-drag rebuilds the strip; the morph then
// strips the drag's inline scroll-snap-type:none, so mandatory snap yanks
// the card back to its origin — the "jump partway through" stutter (a
// position jump, not a dropped frame). Hold renders while the FINGER is
// down (pointermove writes scrollLeft on the main thread — a render there
// eats frames); after release the strip is morph-frozen (_skipMorph)
// instead, so the glide runs undisturbed while the history below is
// already switching to the chosen account.
if (_carDragging) { _renderDeferred = true; return; }
// A render that throws must never brick the page silently: stale DOM keeps
// its handlers, every one of them calls render again, and every tap turns
// into a no-op. Show the error and offer the way out instead.
try { renderInner(); } catch (err) { renderCrash(err); }
}
function endCarouselDrag() {
_carDragging = false;
// the strip was frozen against the morph for the glide — unfreeze it and
// make sure one render runs so it catches up with any state committed early
const strip = document.querySelector('.bal-carousel');
if (strip && strip._skipMorph) { strip._skipMorph = false; _renderDeferred = true; }
if (_renderDeferred) { _renderDeferred = false; render(); }
}
function renderCrash(err) {
console.error('render failed:', err);
try {
const div = document.createElement('div');
div.className = 'card col';
div.style.cssText = 'margin:20px;gap:10px';
const h3 = document.createElement('h3');
h3.textContent = 'Something broke';
const pre = document.createElement('div');
pre.className = 'small muted';
pre.style.cssText = 'word-break:break-word;white-space:pre-wrap';
pre.textContent = String((err && (err.stack || err.message)) || err).slice(0, 600);
const btn = document.createElement('button');
btn.className = 'btn-primary btn-block';
btn.textContent = 'Reload';
btn.onclick = () => location.reload();
div.append(h3, pre, btn);
root.replaceChildren(div);
} catch {}
}
function renderInner() {
// A direct render subsumes any coalesced one queued by a background emit, so
// drop the pending one (a user action must not trigger a second rebuild).
if (_renderRaf) { clearTimeout(_renderRaf); _renderRaf = 0; }
_lastRender = Date.now();
// 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 {}
}
// Same idea for scrollable strips (the punks carousel): a rebuild must not
// yank a half-scrolled list back to its start. Anything marked with
// data-keep-scroll gets its position carried across the swap.
const scrolls = {};
for (const el of root.querySelectorAll('[data-keep-scroll]'))
if (el.scrollLeft || el.scrollTop)
scrolls[el.getAttribute('data-keep-scroll')] = { l: el.scrollLeft, t: el.scrollTop };
// The padlock's "protect this device?" ask is modal: it belongs over
// whatever page the tap happened on. A feature screen (your profile, a chat
// thread) used to swallow it — walletScreen paints the card, and
// screenView outranks walletScreen, so the padlock did nothing visible
// until you navigated back to the wallet and the card finally appeared
// there. Declining still lands back exactly where the tap happened,
// because nothing else moves. Onboarding keeps its precedence.
const lockAsk = ui.pw && ui.pw.purpose === 'lock' && !(ui.onb || onbInProgress());
const screen =
(lockAsk && h('div', { class: 'col', style: 'gap:16px' }, brandHeader(false), pwPromptCard()))
|| featureHook('screenView')
|| (ui.screen === 'wallet'
? walletScreen()
: ui.screen === 'accounts'
? accountsScreen()
: ui.screen === 'accountSettings'
? accountSettingsScreen()
: ui.screen === 'vault'
? vaultScreen()
: ui.screen === 'howItWorks'
? howItWorksScreen()
: _bootDeciding
// Not "nobody is signed in" — "we don't know yet". Painting
// Get started here is what flashed the sign-in page over a
// wallet that was about to restore. Every other screen is a
// deliberate choice and still paints; this one waits.
? h('div', { class: 'col', style: 'gap:16px' }, BOOT_NAV ? null : brandHeader(false))
: 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, ui.nameEditOpen,
ui.noteThread && ui.noteThread.focusId, !!ui.userSearch, !!ui.zapSetup, !!ui.hatShop].join('|');
// A deep-linked profile landing is ONE page settling, not a chain of
// navigations: boot screens shuffle beneath the shell (unlock → restored
// wallet), then the resolved profile replaces the shell in place. The key
// still updates, but nothing slides or re-animates until the landing ends.
if (uiChanged('nav', navKey)) {
// ui.navAnimSkip: a one-shot "this navigation must not animate" — set by
// sign-in right before the wallet screen takes over, so the handover is
// an instant swap instead of a fade over a page that's still settling.
if (!(ui.pubProfPending || ui.pubProfHold || ui.navAnimSkip)) _navAt = performance.now();
ui.navAnimSkip = false;
}
applyAnim(screen, 'anim-page', (performance.now() - _navAt) < 340 ? performance.now() - _navAt : -1);
morphChildren(root, [screen, footer(), ...(ui.lightbox ? [imageViewer()] : [])]);
promoteLazySrc(root); // sources on the nodes that actually made it into the page
try { document.documentElement.classList.toggle('no-scroll', !!ui.lightbox); } catch {}
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 {}
}
}
for (const el of root.querySelectorAll('[data-keep-scroll]')) {
const s = scrolls[el.getAttribute('data-keep-scroll')];
if (s) { el.scrollLeft = s.l; el.scrollTop = s.t; }
}
// 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
// 200ms: one-per-frame still meant a visible strobe during boot bursts (every
// rebuild swaps the node under the cursor, dropping its :hover until the next
// mouse move — buttons flashed under a resting pointer). Direct user actions
// still call render() synchronously and always take priority; render() itself
// stamps the throttle, so a user render pushes the next background one out.
let _renderRaf = 0;
let _lastRender = 0;
const BG_RENDER_MIN_MS = 200;
function scheduleRender() {
if (_renderRaf) return;
const wait = Math.max(0, BG_RENDER_MIN_MS - (Date.now() - _lastRender));
_renderRaf = setTimeout(() => { _renderRaf = 0; render(); }, wait);
}
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.
// arkMoveDetail/arkExitDetail/giftDetail are the ark and gift rows' detail
// views — most Spending payments open one of these, and leaving them out
// meant the phone's native Back skipped past the history list entirely
// (it landed on whatever tab minted the previous entry).
// lightbox: a full-screen photo is a place too — the phone's Back must close
// it, not pop the screen under it (which once walked a viewer straight back
// to the start page, so the photo's × then looked like a logout).
const NAV_FIELDS = ['screen', 'tab', 'txDetail', 'arkMoveDetail', 'arkReconDetail', 'arkExitDetail', 'giftDetail', 'bump', 'giftMode', 'claimStep', 'chatOpen', 'msgView', 'msgCommunity', 'msgPeer', 'profilePk', 'profEdit', 'profEditFilled', 'profOverThread', 'settingsPage', 'nameEditOpen', 'noteThread', 'userSearch', 'zapSetup', 'hatShop', 'lightbox'];
function navSnapshot() {
const s = {};
for (const f of NAV_FIELDS) s[f] = ui[f] ?? null;
// Screens with volatile sub-state (a reply draft, typed amounts, search
// results streaming in) snapshot only what IDENTIFIES the screen — a
// keystroke must never mint a history entry.
if (ui.noteThread) s.noteThread = { rootId: ui.noteThread.rootId, focusId: ui.noteThread.focusId, seed: ui.noteThread.seed };
if (ui.userSearch) s.userSearch = { q: '', rows: null };
if (ui.zapSetup) s.zapSetup = { pk: ui.zapSetup.pk, npub: ui.zapSetup.npub, eventId: ui.zapSetup.eventId, amount: '21' };
// Keep the editable fields in this history entry so a refresh restores the
// draft. Copy them: later keystrokes must not mutate earlier snapshots.
s.profEdit = ui.profilePk && ui.profEdit ? Object.fromEntries(
['uname', 'name', 'about', 'picture', 'banner'].map(k => [k, ui.profEdit[k] || ''])) : null;
if (s.profEdit) s.profEdit.touched = { ...ui.profEdit.touched };
s.profEditFilled = !!s.profEdit && !!ui.profEditFilled;
return s;
}
const navSig = ({ profEdit, profEditFilled, ...s }) => JSON.stringify({ ...s, profEdit: !!profEdit });
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)
// The address bar names what is on screen when that thing has a nostr
// address of its own: a thread is /nevent1… (the post or reply it opened
// on), a profile /npub1…. So the link in the bar is the link to the post —
// copy it, send it, reload it — the same paths the deep links already open.
// Every other screen keeps whatever path it had (the till at /pos, the
// plain app at /); only a path we set ourselves is taken back to /.
const OWN_PATH = /^\/(nevent|note|npub)1[a-z0-9]+\/?$/i;
function navUrl(snap) {
try {
const th = snap.noteThread;
if (snap.profilePk && (snap.profOverThread || !th)) return '/' + npubOf(snap.profilePk);
if (th && th.focusId) {
const author = th.seed && th.seed.id === th.focusId ? th.seed.pubkey : undefined;
const enc = neventOf(th.focusId, author);
if (enc) return '/' + enc;
}
return OWN_PATH.test(location.pathname) ? '/' : undefined;
} catch { return undefined; }
}
function syncHistory() {
if (restoringHistory) return;
try {
const snap = navSnapshot();
const sig = navSig(snap);
if (navIndex >= 0 && sig === navSig(navStack[navIndex])) {
// Typing changes the draft, not the page. Replace the current entry
// instead of making Back walk through every version of the form.
if (JSON.stringify(snap) !== JSON.stringify(navStack[navIndex])) {
history.replaceState({ nav: snap, i: navIndex }, '', navUrl(snap));
navStack[navIndex] = snap;
}
return;
}
// The renders between a reload and the restore of where it was (the
// unlock screen, a deep-linked profile over it, the wallet opening) are
// one slot, not a trail: pushing them put two or three dead entries
// between the restored screen and the one before it, so Back after a
// reload went nowhere useful.
if (bootPending && navIndex >= 0) {
navStack = navStack.slice(0, navIndex + 1);
navStack[navIndex] = snap;
history.replaceState({ nav: snap, i: navIndex }, '', navUrl(snap));
if (ui.screen === 'wallet') bootPending = false;
return;
}
// 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, '', navUrl(snap));
else history.pushState(entry, '', navUrl(snap));
} catch {} // history API failures must never break a render
}
// A refresh must land where the user was — on a profile, a settings page, a
// tab — not back home. history.state survives reload, but any boot-time
// render runs syncHistory and replaceStates a blank snapshot over it — so
// the pre-reload nav is captured HERE, at script load, before any render.
const BOOT_NAV = (() => { try { return (history.state && history.state.nav) || null; } catch { return null; } })();
// ...and until it is restored (or the wallet is simply open, or a few
// seconds pass on the unlock screen), boot renders share one history slot.
let bootPending = !!BOOT_NAV;
if (bootPending) setTimeout(() => { bootPending = false; }, 5000);
function restoreNavFromHistory(renderNow = true) {
try {
const nav = BOOT_NAV;
if (!nav || ui.screen !== 'wallet') return;
bootPending = false;
for (const f of NAV_FIELDS) if (f in nav) ui[f] = f === 'profEdit' && nav[f] ? structuredClone(nav[f]) : nav[f];
ui.lightbox = null; nav.lightbox = null; // an object URL doesn't survive a reload
navStack = [nav];
navIndex = 0;
// A reload is a continuation of the same page, not a navigation. Do not
// replay the full-page entrance animation as boot state settles.
ui.navAnimSkip = true;
_navAt = -1e9;
history.replaceState({ nav, i: 0 }, '', navUrl(nav)); // undo any boot-render clobber (and the boot's path tidy)
if (renderNow) render();
} catch {}
}
window.addEventListener('popstate', (e) => {
const st = e.state;
let snap = (st && st.nav) || navSnapshot();
// The entries minted before sign-in (welcome, sign-in, the seed) are still
// in the browser's history. Walking Back into one with a wallet open showed
// the start page over a perfectly open session — which reads as "I got
// logged out". With a wallet open, those entries become the wallet home.
if (['unlock', 'vault'].includes(snap.screen) && activeAccount() && ui.screen === 'wallet') {
snap = Object.fromEntries(NAV_FIELDS.map((f) => [f, null]));
snap.screen = 'wallet'; snap.tab = ui.tab ?? null;
try { history.replaceState({ nav: snap, i: st && typeof st.i === 'number' ? st.i : navIndex }, ''); } catch {}
}
restoringHistory = true;
try {
for (const f of NAV_FIELDS) ui[f] = f === 'profEdit' && snap[f] ? structuredClone(snap[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: ' ',
});
}
// Paste, for a textarea. Beside a one-line input the button belongs in the
// input-group (see Send); above a textarea it floats next to the label as a
// box heavier than the label itself. So put it inside the field — and only
// while the field is empty, which is the only time it's wanted and the only
// time it can't cover what you typed.
function pasteInto(ta, apply) {
const btn = pasteBtn((text) => { apply(text); sync(); });
if (!btn) return ta;
btn.classList.add('paste-in');
btn.append(h('span', {}, t('paste')));
const wrap = h('div', { class: 'paste-wrap' }, ta, btn);
// Toggled on the node rather than through render(): re-rendering a textarea
// on every keystroke rebuilds it under the caret.
const sync = () => wrap.classList.toggle('has-text', !!ta.value);
ta.addEventListener('input', sync);
sync();
return wrap;
}
// ---------------------------------------------------------------- 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 choice sticks.
try {
const v = localStorage.getItem(UNIT_KEY);
return v === 'btc' || v === 'fiat' ? v : 'sats';
} catch {
return 'sats';
}
})();
// sats → BTC → your money → sats. Fiat is a display unit like the other two
// rather than a second number beside them: the balance is one figure, and
// which figure is your choice. It's skipped entirely when no price has ever
// been fetched — a third position showing blanks is worse than two that work.
function toggleUnit() {
const ring = haveRate() ? ['sats', 'btc', 'fiat'] : ['sats', 'btc'];
unit = ring[(ring.indexOf(unit) + 1) % ring.length];
try {
localStorage.setItem(UNIT_KEY, unit);
} catch {}
render();
}
const unitLabel = () => (unit === 'sats' ? 'sats' : unit === 'fiat' ? getCurrency() : 'BTC');
// "Worth X then · Y now" for a payment's detail page — the one place both
// numbers earn their keep, because that page is where the question is asked.
// Absent when there's no price, and when the two would say the same thing.
function worthLine(sats, at, row = null) {
const then = rateAt(at);
const now = rateNow();
if (!now || !at) return null;
const line = row || ((k, v) => h('div', { class: 'line' }, h('span', { class: 'k' }, k), h('span', { class: 'v' }, v)));
// A payment we have a price for is shown at that price; one we don't is
// shown at today's, without ceremony. (The '~' that used to mark the
// difference was more bookkeeping than the beta warrants.)
const thenStr = then.rate && !then.stale ? fmtFiat(sats, then.rate) : null;
const nowStr = fmtFiat(sats, now);
if (!thenStr) return line(t('worthNow'), nowStr);
return line(t('worthThen'), thenStr + (thenStr === nowStr ? '' : ' · ' + t('worthNowInline', { v: nowStr })));
}
// `at` is when the money moved. In fiat it picks the price recorded at that
// moment, so a payment is shown for what it was worth then — the number the
// rest of the world remembers it by. Without it (a balance, a form) the live
// price is used. A '~' means we had no sample near that time and had to price
// it at today's, which is a different claim and shouldn't look like the same
// one.
const fmtAmount = (sats, at) => {
if (unit === 'sats') return fmtSats(sats);
if (unit === 'btc') return fmtBtc(sats);
const { rate } = rateAt(at);
if (!rate) return fmtSats(sats);
// bare, like the other two units: whatever labels this number — the unit
// tag beside a balance, the toggle everywhere else — says which money it is
return fmtFiatBare(sats, rate);
};
// 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
// The create/import card on its own — the unlock screen wraps it in the
// classic chrome, the onboarding wizard's "Get started" step shows it bare.
function unlockCard() {
// The entropy page takes the card over entirely — seed words, tabs and
// other controls step aside until Submit or Back.
if (ui.entropyPage) return h('div', { class: 'card col' }, entropyPage());
// Inside the first-run wizard the words are already on screen: no tabs,
// just a line underneath for whoever brought their own seed (and one back).
const wizard = ui.screen === 'unlock' && ui.onb && ui.onb.step === 'seed';
const swap = wizard ? h('button', {
class: 'linklike small', style: 'align-self:center',
onClick: () => {
clearSeedDrafts(); ui.unlockError = '';
if (ui.unlockTab === 'create') ui.unlockTab = 'import';
else { ui.unlockTab = 'create'; ui.createStep = 'gen'; ui.draftRandom = ui.draftMnemonic = newMnemonic(); }
render();
},
}, ui.unlockTab === 'create' ? t('onbHaveSeed') : t('onbCreateInstead')) : null;
return h(
'div',
{ class: 'card col' },
wizard ? null : h(
'div',
{ class: 'tabs' },
tabBtn(t('createNew'), ui.unlockTab === 'create', () => { clearSeedDrafts(); ui.unlockTab = 'create'; ui.unlockError = ''; render(); }),
tabBtn(t('importExisting'), ui.unlockTab === 'import', () => { clearSeedDrafts(); ui.unlockTab = 'import'; ui.unlockError = ''; render(); })
),
ui.unlockTab === 'create' ? createPane() : importPane(),
ui.unlockError && h('div', { class: 'notice err' }, ui.unlockError),
swap
);
}
function unlockScreen() {
return h(
'div',
{ class: 'col', style: 'gap:16px' },
brandHeader(false),
unlockCard(),
featureHook('unlockExtra'),
ui.fromWallet && accounts.length && !ui.entropyPage
? 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')),
h('div', { class: 'card col', style: 'gap:14px' },
h('h3', {}, '🎁 ' + t('hiwGiftsTitle')),
para('hiwGifts1')),
// The standalone file: the whole app inlined into one HTML — the
// save-and-open-offline story the split build gave up. Only offered when
// there's a server to download it from (on file:// you already have it).
location.protocol.startsWith('http')
? h('div', { class: 'card col', style: 'gap:14px' },
h('h3', {}, '💾 ' + t('hiwStandaloneTitle')),
para('hiwStandalone1'),
h('a', { href: '/standalone.html', download: 'coinos.html' }, t('hiwStandaloneLink')))
: null,
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) {
// Act on POINTERDOWN, not click: on a phone the click event lands
// 100–250ms after the finger does, and a tab switch has no scroll or
// long-press ambiguity to wait out — native segmented controls switch on
// touch-down for exactly this reason. The click handler stays for
// keyboard/AT activation; re-selecting the current tab is a no-op, so the
// pair firing together costs nothing.
//
// And feedback comes FIRST: the full re-render can take 100ms+ on a
// phone, and running it synchronously meant even the button's own active
// state waited for the whole pane to build. Flip the strip's classes with
// a direct DOM write (paints this frame), then run the real render after
// that paint — double-rAF is the "after the next paint" idiom.
// The down-timestamp lives ON THE NODE, not in this closure: the
// pointerdown's own render replaces these handlers (fresh closure, zero
// timestamp) before the browser delivers the paired click, and with tabs
// that TOGGLE, letting that click through un-did the tap it belonged to.
const select = (btn) => {
if (btn && btn.parentElement) {
for (const b of btn.parentElement.children) b.classList.toggle('active', b === btn);
}
requestAnimationFrame(() => requestAnimationFrame(onClick));
};
return h('button', {
class: active ? 'active' : '',
onPointerdown: (e) => {
if (e.button !== 0) return;
e.currentTarget._downAt = performance.now();
select(e.currentTarget);
},
onClick: (e) => { if (performance.now() - (e.currentTarget._downAt || 0) > 500) select(e.currentTarget); },
}, label);
}
function createPane() {
if (ui.createStep === 'gen') {
// The wizard shows words, never a Generate button: a refresh restores
// the step but not the draft (drafts live in memory only), so draw a
// fresh set right here. Nothing was made from the old words.
if (!ui.draftMnemonic && ui.onb && ui.onb.step === 'seed') ui.draftRandom = ui.draftMnemonic = newMnemonic();
if (!ui.draftMnemonic) {
return h(
'div',
{ class: 'col' },
h('p', { class: 'muted' }, t('genIntro')),
h(
'button',
{
class: 'btn-primary btn-block',
onClick: () => {
ui.draftRandom = 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', style: '--rows-3:' + Math.ceil(words.length / 3) + ';--rows-2:' + Math.ceil(words.length / 2) },
words.map((w, i) =>
h('div', { class: 'w' }, h('span', { class: 'n' }, i + 1), h('span', { class: 't' }, w))
)
),
h('button', { class: 'btn-block', style: 'display:flex;align-items:center;justify-content:center;gap:8px', onClick: () => copy(ui.draftMnemonic) },
h('span', { style: 'display:flex', html: ' ' }),
t('copy')),
optionsPanel(),
h(
'button',
{
class: 'btn-primary btn-block',
onClick: () => {
// catch a mistyped passphrase here, while the field to fix it is
// still on screen — the verify step only samples the words
if (ui.passphrase && ui.confirmPass !== ui.passphrase) {
ui.unlockError = t('passphraseMismatch'); render(); return;
}
ui.confirm = pickConfirm(words);
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")
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()),
})
)
),
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; }
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: '' }));
}
// The BYO-entropy page, shared by Create (re-derives the draft phrase) and
// Import (fills the phrase field): the typed text alone determines the seed,
// so the same text recreates the same wallet anywhere. Nothing derives while
// typing — Submit does it once, then hands back the phrase view.
function entropyPage() {
const forCreate = ui.entropyPage === 'create';
return h(
'div',
{ class: 'col', style: 'gap:10px' },
h('h3', { style: 'margin:0' }, t('entropyToggle')),
h('p', { class: 'small muted', style: 'margin:0' }, t('entropyHint')),
h('div', { class: 'warn-box small' }, t('entropyWarn')),
h('textarea', {
rows: '3', style: 'font-family:var(--sans)', placeholder: t('entropyPlaceholder'),
autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false',
value: ui.entropyEntry || '',
onInput: (e) => (ui.entropyEntry = e.target.value),
}),
h(
'div',
{ class: 'row gap6' },
h('button', { class: 'btn-ghost grow', onClick: () => { ui.entropyPage = null; ui.entropyEntry = ''; render(); } }, t('back')),
h('button', { class: 'btn-primary grow', onClick: () => {
const txt = (ui.entropyEntry || '').trim();
if (forCreate) {
ui.ownEntropy = txt;
ui.draftMnemonic = txt ? newMnemonic(128, txt) : (ui.draftRandom || ui.draftMnemonic || newMnemonic());
} else {
ui.importEntropy = txt;
// fills the phrase field with its derived words; clearing the text
// clears only what it filled — a pasted phrase is left alone
if (txt) ui.importText = ui._entropyFilled = newMnemonic(128, txt);
else if (ui.importText === ui._entropyFilled) ui.importText = '';
}
ui.entropyPage = null;
ui.entropyEntry = '';
render();
} }, t('entropySubmit'))
)
);
}
// A seed phrase or entropy typed into the start screens is secret material,
// not a draft: nothing typed there survives leaving the field — switching
// tabs, backing out of the entropy page, going home. Whatever you come back
// to is empty. (An opened wallet clears these too, in openWallet/lock.)
function clearSeedDrafts() {
ui.importText = ''; ui._entropyFilled = null;
ui.entropyEntry = ''; ui.ownEntropy = ''; ui.importEntropy = '';
}
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('span', { class: 'lab' }, t('importLabel')),
pasteInto(ta, (text) => { ta.value = text; ui.importText = text; })
),
// A wallet born from typed entropy comes back the same way: the entropy
// page fills the phrase field with its derived words, visibly.
h(
'button',
{
class: 'btn-ghost btn-sm', style: 'align-self:flex-start',
onClick: () => { ui.entropyPage = 'import'; ui.entropyEntry = ''; render(); },
},
t('entropyToggle')
),
optionsPanel(),
h('button', { class: 'btn-primary btn-block', onClick: () => openWallet(ui.importText) }, t('openWallet'))
);
}
function optionsPanel() {
const pass = h(
'label',
{ class: 'field' },
h('span', { class: 'lab' },
t('passphrase'),
// People assume a password logs in or encrypts; a BIP39 passphrase does
// neither, and getting that wrong loses money — worth a sentence here.
h('button', {
class: 'linklike small', type: 'button', style: 'margin-left:8px',
onClick: (e) => { e.preventDefault(); ui.passInfoOpen = !ui.passInfoOpen; render(); },
}, t('passWhatsThis'))),
ui.passInfoOpen ? h('p', { class: 'small muted', style: 'margin:0 0 6px' }, t('passInfo')) : null,
h(
'div',
{ class: 'input-group' },
h('input', {
type: ui.showPass ? 'text' : 'password',
class: 'mono-input',
autocomplete: 'off',
value: ui.passphrase,
// rendering mid-keystroke is safe (focus + caret are preserved), but
// only the empty↔non-empty flip changes what's on screen (the confirm
// field below), so only that flip pays for a rebuild
onInput: (e) => { const had = !!ui.passphrase; ui.passphrase = e.target.value; if (had !== !!ui.passphrase) render(); },
}),
h('button', { class: 'btn-sm', type: 'button', onClick: () => { ui.showPass = !ui.showPass; render(); } }, ui.showPass ? t('hide') : t('show'))
)
);
// A typo here IS a different wallet, silently — so once a passphrase is
// typed, it's typed twice. (Not needed when it's empty, which is most people.)
const confirm = ui.passphrase
? h(
'label',
{ class: 'field' },
h('span', { class: 'lab' }, t('reenterPassphrase')),
h('input', {
type: ui.showPass ? 'text' : 'password',
class: 'mono-input',
autocomplete: 'off',
value: ui.confirmPass,
onInput: (e) => (ui.confirmPass = e.target.value),
})
)
: null;
return [pass, confirm];
}
// 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)) {
// last line of defense — the panes with the confirm field on screen also
// check before handing off, so this error always lands somewhere fixable
if (ui.passphrase && ui.confirmPass !== ui.passphrase) { ui.unlockError = t('passphraseMismatch'); render(); return; }
await enterWallet(m, ui.passphrase, { generated: opts.generated });
ui.draftMnemonic = ''; // discard the used draft so the next "Add wallet" generates a fresh seed
return;
}
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 = {}) {
// A wallet opening is a new set of numbers: the count-up animation must
// not run from the previous account's balance (a fresh wallet used to
// count DOWN from the old one to zero).
_amtLast.clear();
const acc = addOrGetAccount({
type: 'full',
label: defaultLabel('full'),
mnemonic: (mnemonic || '').trim().replace(/\s+/g, ' '),
passphrase: passphrase || '',
});
// Has this person ever had the recovery phrase in front of them? Creating
// a wallet shows it; importing one means they typed it. A sign-in DERIVES
// it — Google, a passkey and a nostr key all end up at a seed the user has
// never seen, and which nobody can reissue for them. That is the one case
// worth asking about, so it's recorded once and never guessed at again.
if (acc.seedSeen === undefined) acc.seedSeen = !opts.nostrPubkey;
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 = {}) {
// Tear down the previous account's feature runtime BEFORE the wallet
// switches identity: live relay subscriptions and in-memory state (chat
// threads, decrypted DMs) belong to the old keys, and surviving into this
// account they'd both leak the old account's messages on screen and block
// the new account's own subscriptions from ever starting.
_featuresInited = false;
for (const f of FEATURES) { try { f.stop && f.stop(); } catch {} }
activeId = acc.id;
rememberActive(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();
// The relay backup said this wallet has a Spending side: latch it BEFORE
// the first paint, so a restored wallet doesn't open as savings-only and
// reflow when the name lookup proves Spending a beat later.
if (opts.spendingHint && !acc.spendingSetup) { acc.spendingSetup = Date.now(); }
const netName = acc.network;
const stillActive = () => activeAccount() === acc && getNetwork() === netName;
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, accountIndex: acc.deriveIndex || 0 });
// 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();
// an account rebuilt by a silent sign-in starts with the phrase "unseen";
// the directory remembers if it was seen on this device before
if (acc.xpub && !acc.seedSeen) {
try {
const dir = JSON.parse(localStorage.getItem(WATCH_KEY) || '[]');
if (dir.some((e) => e.xpub === acc.xpub && e.seedSeen)) acc.seedSeen = true;
} catch {}
}
persistAccounts();
autoSave(acc);
// A name fetched during sign-in seeds the names state BEFORE features init
// and the first paint — the Receive pane opens showing its QR.
if (opts.nameHint) { try { featureHook('namesSeed', opts.nameHint); } catch {} }
for (const f of FEATURES) { try { f.init && f.init(); } catch {} } // per-feature wallet lifecycle
_featuresInited = true; // a deferred feature landing after this point gets its init() on arrival
// A freshly generated identity provably has no profile yet — let features
// skip the "is there a kind-0?" wait (avatar would sit blank meanwhile).
if (opts.generated) featureHook('identityGenerated');
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; } })();
// With no place to return to, a wallet that was just opened — a sign-in,
// an unlock, a switch to another account — lands on Receive: its deposit
// address and QR up at once. Reopening the same wallet mid-flow (a seed
// loaded into a watch-only one) keeps History.
const keep = nav && (opts.restoreNav || !opts.fresh); // a sign-in starts fresh, a refresh resumes
ui.tab = (!opts.gift && keep && nav.tab) || (opts.fresh ? 'receive' : 'history');
ui.txDetail = (!opts.gift && keep && 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.arkRecvAddr = null; // the ark receive address belongs to one wallet+network
ui.send = blankSend();
ui.draft = null;
ui.sendResult = null;
ui.giftMode = false;
ui.offlineFallback = false;
// Restore the target page before the wallet's first paint. Painting home
// first and then replacing it with a thread made refresh visibly flash.
if (opts.restoreNav) restoreNavFromHistory(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 (!stillActive()) return;
if (!hadCache && !hadNostr) {
await wallet.scan({ silent: false });
}
if (!stillActive()) return;
// 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 {
// A scan can finish after logout or an account switch. Its failure must
// not put the new session (or an empty wallet) into offline mode.
if (stillActive()) enterOfflineFallback();
}
}
// --- accounts -------------------------------------------------------------
// Open the unlock screen in ADDITIVE mode: the sign-in doors (Nostr signer,
// passkey, Google, pasted key/phrase) add the identity alongside the current
// accounts instead of replacing them, and Back returns to the wallet. Shared
// by the Accounts screen and the profile page.
function signInAnother() {
clearSeedDrafts();
ui.fromWallet = true; ui.unlockError = ''; ui.unlockTab = 'import'; ui.screen = 'unlock';
ui.profilePk = null; ui.profEdit = null; ui.profEditFilled = false; ui.chatOpen = false;
render();
}
// The nostr login persisted against an account's OWN wallet state. Feature
// state lives in localStorage keyed by the seed, so this answers "who is that
// account?" for every account on the device, not just the open one — no
// stamping, nothing to go stale.
function loginPkOf(a) {
try {
const base = a.type === 'watch' ? a.xpub : a.xprv || (a.mnemonic ? `${a.mnemonic}\n${a.passphrase || ''}` : null);
if (!base) return null;
const key = cacheKeyFor(a.deriveIndex ? `${base}\n#${a.deriveIndex}` : base);
return (JSON.parse(localStorage.getItem(key + ':nostrlogin') || 'null') || {}).pubkey || null;
} catch { return null; }
}
// Whose wallet this is: the nostr login it carries, else the key its seed
// derives, else whatever was last stamped on the record. The accounts screen
// and the identity switcher have to agree on this or they describe different
// worlds.
function identityPkOf(a) {
if (!a) return null;
let dir = [];
try { dir = JSON.parse(localStorage.getItem(WATCH_KEY) || '[]'); } catch {}
const stamped = (a.xpub && (dir.find((d) => d.xpub === a.xpub) || {}).nostrPk) || null;
return loginPkOf(a)
|| (a.mnemonic ? seedPubkey(a.mnemonic, a.passphrase || '', a.deriveIndex || 0) : null)
|| a.nostrPk || stamped;
}
// The identities on this device — one per seed (a seed's Spending and
// Savings faces are the same person), each with the nostr key its header
// avatar wears: the nostr login when there is one, else the seed's own key.
// Both are read from the account itself; the key remembered on the record
// (saveDirectory) and in the durable directory by xpub is only the fallback,
// for a watch-only entry whose seed isn't here to ask.
// One key per person: a seed's Spending and Savings accounts share it.
const seedKeyOf = (a) => a.type === 'watch' ? 'w:' + a.xpub
: a.mnemonic ? 'm:' + a.mnemonic + '\n' + (a.passphrase || '') + '\n' + (a.deriveIndex || 0)
: 'x:' + (a.xprv || a.xpub || a.id);
function identities() {
const pkOf = identityPkOf;
const bySeed = new Map();
for (const a of accounts) {
if (a.provisional) continue;
const key = seedKeyOf(a);
const active = a.id === activeId;
const pk = pkOf(a);
const cur = bySeed.get(key);
if (!cur) bySeed.set(key, { id: a.id, pk, active, label: seedName(a), network: a.network, watch: a.type === 'watch' });
else {
if (active) { cur.id = a.id; cur.active = true; }
if (!cur.pk && pk) cur.pk = pk;
}
}
const out = [...bySeed.values()];
const me = out.find((x) => x.active);
if (me) me.pk = ctx.shownPubkey() || me.pk;
return out;
}
// Page-level navigation owned by the features (a profile, a chat thread, a
// search, the zap setup) — dropped whenever the session underneath it
// changes, so the next paint is the app's own screen and not the page the
// previous identity (or the unlocked wallet) was reading.
function clearFeatureNav() {
ui.profilePk = null; ui.profEdit = null; ui.profEditFilled = false;
ui.chatOpen = false; ui.userSearch = null; ui.noteThread = null; ui.zapSetup = null;
}
// Sign out of the CURRENT identity only. "Log out" locks every account on
// the device, which is the wrong size when two people (or two of your own
// keys) are signed in and just one should leave. This removes that
// identity's accounts — session, directory, cache, vault — and leaves the
// others exactly as they were, landing on the next one. A nostr login can
// sign straight back in (its seed lives on the relays, encrypted to its
// key); a bare seed wallet needs its recovery phrase, and the confirmation
// says which.
function signOutIdentity() {
const me = activeAccount();
if (!me) return;
const key = seedKeyOf(me);
const mine = accounts.filter((a) => seedKeyOf(a) === key).map((a) => a.id);
clearFeatureNav();
// the active one last: wiping it is what hands off to the next identity
for (const id of mine.filter((id) => id !== me.id)) wipeAccount(id);
wipeAccount(me.id);
if (!activeAccount()) { ui.screen = 'unlock'; ui.unlockTab = 'create'; ui.onb = { step: 'welcome' }; }
render();
}
// Whether the current identity can come back without a recovery phrase:
// a nostr login (extension, bunker, passkey, Google) re-derives its wallet.
const identityRecoverable = () => !!loginPkOf(activeAccount() || {});
// Switch to another identity already signed in on this device: the wallet
// it opens lands on its own history, out of any profile or chat the previous
// identity had open.
function switchIdentity(id) {
const acc = accounts.find((a) => a.id === id);
if (!acc) return;
clearFeatureNav();
switchToView(acc.id, viewsOf(acc)[0]);
}
// 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 || '') + (a.deriveIndex ? '|#' + a.deriveIndex : ''));
const activeAccount = () => accounts.find((a) => a.id === activeId) || null;
// The wallet you last used, remembered durably (localStorage): a fresh window
// or tab — a link clicked from outside, a cold start — starts with no session
// and would otherwise always reopen the FIRST wallet in the directory, not
// the one you switched to elsewhere. Directory ids are stable across sessions.
const LAST_ACTIVE_KEY = 'btc-wallet-last-active';
function rememberActive(id) { try { localStorage.setItem(LAST_ACTIVE_KEY, id); } catch {} }
function preferredAccount() {
let id = null;
try { id = localStorage.getItem(LAST_ACTIVE_KEY); } catch {}
return accounts.find((a) => a.id === id) || accounts[0] || 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, nostrPk: d.nostrPk }));
} catch { return []; }
}
function saveDirectory() {
try {
// Remember each wallet's nostr identity alongside its xpub: a watch-only
// reopening still shows WHO this wallet is (avatar, npub, profile) even
// though nothing can be signed until the keys are back.
const dir = accounts.filter((a) => a.xpub && !a.provisional).map((a) => {
if (a.id === activeId && !wallet.watchOnly) {
// remember the identity the header actually SHOWS — a nostr login
// outranks the seed-derived key, locked or not
try {
a.nostrPk = (featureHook('nostrLoginIdentity') || {}).pubkey
|| (wallet.nostrPubkey && wallet.nostrPubkey()) || a.nostrPk;
} catch {}
}
return { id: a.id, label: a.label, xpub: a.xpub, autoLock: a.autoLock || 0, network: a.network, nostrPk: a.nostrPk, seedSeen: !!a.seedSeen };
});
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 };
// the Add-wallet chooser fixed this wallet's purpose before the seed flow
if (ui.newWalletKind) { acc.kind = ui.newWalletKind; ui.newWalletKind = null; }
accounts.push(acc);
persistAccounts();
}
return acc;
}
// A wallet on an existing seed: the next unused BIP84 account index under the
// parent's phrase. Its coins, ark state, cache and nostr identity are all its
// own; the parent's seed phrase backs up both.
function createDerivedWallet(parentId, kind) {
const parent = accounts.find((a) => a.id === parentId);
if (!parent || !parent.mnemonic) return;
const siblings = accounts.filter((x) => x.mnemonic === parent.mnemonic && (x.passphrase || '') === (parent.passphrase || ''));
const next = 1 + Math.max(0, ...siblings.map((x) => x.deriveIndex || 0));
const acc = {
id: genId(), type: 'full', network: parent.network || getNetwork(),
label: `${parent.label} +${next}`,
viewLabels: { [kind]: `${kind === 'spending' ? t('spendingName') : t('savingsName')} ${next + 1}` },
mnemonic: parent.mnemonic, passphrase: parent.passphrase || '',
deriveIndex: next, kind,
};
accounts.push(acc);
persistAccounts();
if (parent.persisted) { acc.persisted = true; writeVault(); }
ui.addWallet = null;
activateAccount(acc, { fresh: true });
}
// 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) return;
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 {}
// the "Unlock for" deadline outlives refreshes: past it, the password is
// required again no matter what the session cache still holds
if (unlockDeadlinePassed()) {
try { sessionStorage.removeItem(ACCOUNTS_KEY); localStorage.removeItem(UNLOCK_UNTIL_KEY); } catch {}
sess = null;
} else {
armUnlockDeadline();
}
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, { restoreNav: true });
return true;
}
if (hasVault()) {
// A blank (optional) vault password unlocks seamlessly with no prompt.
if (attemptVaultUnlock('')) {
if (accounts.length) activateAccount(preferredAccount(), { fresh: true, restoreNav: true });
else { ui.screen = 'unlock'; render(); }
return true;
}
ui.screen = 'vault'; render(); return true;
}
const watch = loadWatchAccounts();
if (watch.length) {
accounts = watch.slice();
activateAccount(preferredAccount(), { fresh: true, restoreNav: true });
return true;
}
return false;
}
// --- encrypted vault (optional password-persisted full accounts) ----------
const VAULT_KEY = 'btc-wallet-vault';
// Whether we've offered a password on locking. Asked once, ever: someone who
// says no has chosen, and a wallet that nags is a wallet people stop reading.
const LOCK_PW_KEY = 'btc-wallet-lockpw';
const lockPwAsked = () => { try { return !!localStorage.getItem(LOCK_PW_KEY); } catch { return true; } };
const markLockPwAsked = () => { try { localStorage.setItem(LOCK_PW_KEY, '1'); } catch {} };
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) {
// Derive on the wallet's OWN network: accountXpubFor defaults to mainnet,
// and a testnet wallet derives under a different coin type, so leaving it
// to the default produced an xpub that could never match the stored one —
// every unlock re-added the same wallet as a new entry.
const net = v.network || getNetwork();
const xpub = v.xprv ? accountXpubFor({ xprv: v.xprv }, net) : accountXpubFor({ mnemonic: v.mnemonic, passphrase: v.passphrase || '' }, net);
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 the vault is locked behind a real
// password — then the manual Save button (and the padlock's save-first ask)
// 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()) {
// A blank-password vault opens by itself — treating it as locked left
// every wallet created after a plain reload silently unsaved. Fold its
// wallets into the session first: writeVault rebuilds the blob from
// session accounts, and the saved ones must not fall out of it here.
let list;
try { list = decryptVault(loadVaultBlob(), ''); } catch { return; } // a real password: manual
vaultPassword = '';
mergeVaultList(list);
} else {
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.network || getNetwork()) !== 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.network || getNetwork()) !== 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() {
// Declining the lock-time offer is an answer, not a postponement.
if (ui.pw && ui.pw.purpose === 'lock') markLockPwAsked();
// Backing out of save-before-lock means "don't lock" — go home, keys intact.
if (ui.pw && ui.pw.purpose === 'locksave') ui.screen = 'wallet';
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.purpose === 'lock') {
if (!p.v1) { p.error = t('pwNeededToLock'); render(); return; }
if (p.v1 !== p.v2) { p.error = t('pwMismatch'); render(); return; }
// Fold any blank-password vault contents into the session before the
// re-encrypt: writeVault rebuilds the blob from session accounts, and a
// wallet saved by an earlier session must not fall out of the vault just
// because a password is being set now.
try { mergeVaultList(decryptVault(loadVaultBlob(), '')); } catch {}
vaultPassword = p.v1;
writeVault();
persistAccounts();
markLockPwAsked();
ui.pw = null;
softLock(); // stay home, watch-only: closed padlock + "Locked" toast
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' || p.purpose === 'locksave';
writeVault();
persistAccounts();
const finishLock = p.purpose === 'locksave';
ui.pw = null;
if (finishLock) { softLock(); return; }
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;
}
// "Unlock for": a session deadline chosen at the password prompt. Past it the
// app relocks (back to this prompt) — wallets stay saved, nothing is wiped.
const UNLOCK_FOR_KEY = 'btc-wallet-unlock-for';
const UNLOCK_UNTIL_KEY = 'btc-wallet-unlock-until';
const UNLOCK_FOR_OPTIONS = [
{ ms: 300_000, label: 'unlockFor5m' },
{ ms: 1_800_000, label: 'unlockFor30m' },
{ ms: 86_400_000, label: 'unlockFor1d' },
{ ms: 0, label: 'unlockForever' },
];
let _unlockUntilTimer = null;
function armUnlockDeadline() {
clearTimeout(_unlockUntilTimer); _unlockUntilTimer = null;
let until = 0;
try { until = Number(localStorage.getItem(UNLOCK_UNTIL_KEY)) || 0; } catch {}
if (!until) return;
const left = until - Date.now();
if (left <= 0) {
try { localStorage.removeItem(UNLOCK_UNTIL_KEY); } catch {}
if (activeAccount() && !wallet.watchOnly) softLock();
return;
}
_unlockUntilTimer = setTimeout(armUnlockDeadline, Math.min(left, 60_000));
}
function unlockDeadlinePassed() {
try { const u = Number(localStorage.getItem(UNLOCK_UNTIL_KEY)) || 0; return u > 0 && Date.now() >= u; } catch { return false; }
}
// On-open vault unlock (password prompt).
function unlockVault() {
if (!attemptVaultUnlock(ui.vaultPw)) { ui.vaultError = t('pwWrong'); render(); return; }
ui.vaultPw = '';
ui.vaultError = '';
ui.justLocked = false;
const ms = ui.unlockFor != null ? ui.unlockFor : Number(localStorage.getItem(UNLOCK_FOR_KEY)) || 0;
try {
localStorage.setItem(UNLOCK_FOR_KEY, String(ms));
if (ms > 0) localStorage.setItem(UNLOCK_UNTIL_KEY, String(Date.now() + ms));
else localStorage.removeItem(UNLOCK_UNTIL_KEY);
} catch {}
armUnlockDeadline();
const cur = accounts.find((a) => a.id === activeId) || preferredAccount();
if (cur) activateAccount(cur, { 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(preferredAccount(), { 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 = 'history';
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 {}
for (const f of FEATURES) { try { f.wipeCache && f.wipeCache(bases); } 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);
armUnlockDeadline(); // background tabs throttle timers — recheck on return
// A phone freezes a backgrounded tab: timers stop, relay sockets are cut,
// and nothing that happened while you were away ever arrives. Features that
// hold live connections get told how long the app was gone so they can
// catch up rather than sit on a dead subscription.
const away = awayAt ? Date.now() - awayAt : 0;
for (const f of FEATURES) { try { f.resumed && f.resumed(away); } catch {} }
}
// 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({ offerPassword = false } = {}) {
wallet.stopRealtime();
// A locked session must not keep listening as the account that just left —
// and whatever logs in next must not inherit its threads or subscriptions.
_featuresInited = false;
for (const f of FEATURES) { try { f.stop && f.stop(); } catch {} }
clearAccounts();
vaultPassword = null;
wallet.load({ mnemonic: '', passphrase: '', netName: getNetwork(), offline: false });
wallet.mnemonic = '';
// Wallets save themselves to this device without asking, under an empty
// password unless one is set. Demanding a password on the way out is then
// theatre — it protects nothing and asks for something nobody chose. With no
// password, logging out means dropping back to the wallet list; with one, it
// means what it says.
const noPassword = hasVault() && attemptVaultUnlock('');
ui.screen = !hasVault() ? 'unlock' : noPassword ? 'accounts' : 'vault';
ui.unlockTab = 'create';
ui.fromWallet = false;
ui.watchXpub = '';
ui.watchLabel = '';
ui.pw = null;
clearFeatureNav(); // a profile/chat from the locked session must not outlive it
ui.vaultPw = '';
ui.vaultError = '';
ui.confirmClear = false;
ui.confirmRemove = null;
ui.editId = null;
ui.editLabel = '';
ui.createStep = 'gen';
ui.draftMnemonic = '';
ui.importText = '';
ui.entropyPage = null;
ui.entropyEntry = '';
ui.ownEntropy = '';
ui.importEntropy = '';
ui.passphrase = '';
ui.confirmPass = '';
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;
// Offer to set a password on the way out — but once, ever: someone who said
// "Not now" has answered, and a wallet that nags is a wallet people stop
// reading. (The padlock is different: it asks every time via softLock's
// in-place prompt, because locking without a password is impossible and
// the tap would otherwise be a confusing trip to the wallet list.)
if (noPassword && offerPassword && !lockPwAsked()) {
ui.pw = { purpose: 'lock', mode: 'set', v0: '', v1: '', v2: '', error: '' };
}
render();
}
// ================================================================ WALLET
// The avatar IS your profile now — one tap opens it (edit lives there).
// Settings and Lock stand beside it as their own icons; no popup menu.
function avatarMenu() {
const me = (featureHook('nostrLoginIdentity') || {}).pubkey || (wallet.nostrPubkey && wallet.nostrPubkey())
|| (activeAccount() || {}).nostrPk;
// Older watch-only accounts may have no remembered Nostr identity. Keep
// account access available even when there is no profile to open.
const node = (me && featureHook('headerAvatar', me))
|| h('span', {
class: 'chat-avatar header-ava fallback',
html: ' ',
});
return h('button', {
class: 'header-avatar',
title: me ? t('profYourProfile') : t('accounts'),
'aria-label': me ? t('profYourProfile') : t('accounts'),
onClick: () => {
clearFeatureNav(); ui.screen = 'wallet';
if (!me || !featureHook('showProfile', me)) {
ui.screen = 'accounts';
}
render();
},
}, node);
}
function settingsBtn() {
return h('button', {
class: 'header-msgs', title: t('tabSettings'), 'aria-label': t('tabSettings'),
onClick: () => {
clearFeatureNav();
ui.screen = 'wallet'; ui.tab = 'settings'; ui.settingsPage = null; render();
},
}, h('span', {
class: 'hm-ico',
html: ' ',
}));
}
// Lock in place: the keys leave, the view stays. The wallet remains on screen
// watch-only (balances and history, no spending), the padlock closes, and
// tapping it again asks for the password to bring the keys back.
function softLock() {
const watch = loadWatchAccounts();
let blankPw = false;
try { decryptVault(loadVaultBlob(), ''); blankPw = true; } catch {}
// No protecting password yet: locking would protect nothing, so ask for one
// right here — keys intact, home still on screen. Submitting saves the
// vault under it and locks in place; "Not now" just closes the prompt.
// (Tearing the session down first meant "Not now" stranded people on the
// wallet list, signed out of a wallet they never asked to leave.)
if (!hasVault() || blankPw) {
ui.pw = { purpose: 'lock', mode: 'set', v0: '', v1: '', v2: '', error: '' };
render();
return;
}
// Nothing watchable: this session can't stay on screen — classic full lock.
if (!watch.length) { lock({ offerPassword: true }); ui.justLocked = true; return; }
// A wallet that isn't IN the vault can't come back from a lock — unlocking
// decrypts the vault, and this seed isn't there (created while the vault
// sat locked, so autoSave couldn't write it). Save it first: with the
// session password if we hold it, else ask for the vault password now.
const active = activeAccount();
if (active && active.type === 'full' && !active.provisional && !active.persisted) {
if (vaultPassword != null) {
active.persisted = true;
writeVault();
persistAccounts();
} else {
ui.pw = { purpose: 'locksave', accId: active.id, mode: 'enter', v1: '', v2: '', error: '' };
ui.screen = 'accounts'; // the pw prompt renders on the accounts screen
render();
return;
}
}
const keepId = activeId;
// The lock is going through: leave whatever page the padlock was tapped
// from, so what you land on is the wallet wearing a closed padlock — from
// a profile page the toast was the only sign anything had happened.
clearFeatureNav();
_featuresInited = false;
for (const f of FEATURES) { try { f.stop && f.stop(); } catch {} }
vaultPassword = null;
accounts = watch.slice();
persistAccounts();
const cur = accounts.find((a) => a.id === keepId) || accounts[0];
activateAccount(cur, { fresh: true });
toast(t('lockedToast'));
}
function lockBtn() {
const locked = wallet.watchOnly && hasVault();
return h('button', {
class: 'header-msgs',
title: locked ? t('unlock') : t('lockWallet'),
'aria-label': locked ? t('unlock') : t('lockWallet'),
onClick: locked
? () => {
if (attemptVaultUnlock('')) {
const cur = accounts.find((a) => a.id === activeId) || accounts[0];
if (cur) activateAccount(cur, { fresh: true });
} else { ui.justLocked = false; ui.screen = 'vault'; render(); }
}
: () => softLock(),
}, h('span', { class: 'hm-ico', style: 'font-size:18px;line-height:1' }, locked ? '\u{1F512}' : '\u{1F513}'));
}
// 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: () => { clearFeatureNav(); ui.screen = 'wallet'; ui.chatOpen = true; ui.msgView = 'home'; render(); },
}, h('span', {
class: 'hm-ico',
html: ' ',
}));
}
// Find anyone on nostr from the header — results open their profile.
function searchBtn() {
if (!featureHook('userSearchAvailable')) return null;
return h('button', {
class: 'header-msgs',
title: t('searchUsers'),
'aria-label': t('searchUsers'),
onClick: () => {
clearFeatureNav(); ui.screen = 'wallet';
featureHook('openUserSearch');
render();
setTimeout(() => document.querySelector('.user-search-input')?.focus(), 60);
},
}, h('span', {
class: 'hm-ico',
html: ' ',
}));
}
function brandHeader(withLock) {
const acc = activeAccount();
return h(
'div',
{ class: 'row between' },
h(
'div',
{ class: 'brand' + (STAGING ? ' staging' : ' beta'), style: 'cursor:pointer', title: t('home'), onClick: goHome },
STAGING ? h('span', { class: 'staging-badge', title: 'staging build — new wallets start on Mutinynet' }, 'staging')
: h('span', { class: 'beta-badge' }, 'v3 beta'),
h('div', { class: 'logo-full', 'aria-label': 'coinos', role: 'img', html: ' ' })
),
withLock
? h('div', { class: 'row gap6', style: 'align-items:center' },
searchBtn(),
messagesBtn(),
settingsBtn(),
lockBtn(),
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) {
// the recovery phrase first: it is the one thing on this page that
// matters if the phone is lost tonight
case 'wallet': return page(a ? [recoveryCard(a), walletNameCard(a), pubkeyCard(a)] : []);
case 'payments': return page(featureAll('settingsCards').reverse());
case 'pos': return page(featureAll('posSettingsCards').reverse()); // the till's own card first, then the terminal's
case 'network': return page([networkCard(), explorerCard()]); // an old bookmark of the tile; the cards live under Advanced now
case 'nostr': return nostrSettingsView();
case 'notifications': return page(featureAll('notifySettingsCards').reverse());
case 'advanced': return advancedSettingsView();
}
const I = (d) => '' + d + ' ';
const TILE_ICONS = {
wallet: I(' '),
payments: I(' '),
network: I(' '),
nostr: I(' '),
// a till receipt: paper with a torn foot and two lines of figures
pos: I(' '),
notifications: I(' '),
advanced: I(' '),
};
const tile = (id, label, desc) =>
h('button', {
class: 'card col settings-tile',
style: 'align-items:flex-start;gap:6px;text-align:left;padding:14px;cursor:pointer;margin:0',
onClick: () => { ui.settingsPage = id; render(); },
},
h('span', { class: 'st-ico', style: 'color:var(--muted)', html: TILE_ICONS[id] || '' }),
h('span', { style: 'font-weight:600' }, label),
h('span', { class: 'small faint' }, desc));
const grid = (kids) => h('div', { style: 'display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px' }, ...kids);
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'))),
grid([
tile('wallet', t('settingsWallet'), t('settingsWalletDesc')),
tile('payments', t('settingsPayments'), t('settingsPaymentsDesc')),
tile('pos', t('posTitle'), t('settingsPosDesc')),
tile('nostr', t('nostrSettings'), t('settingsNostrDesc')),
tile('notifications', t('settingsNotifications'), t('settingsNotifDesc')),
tile('advanced', t('advancedSettings'), t('settingsAdvancedDesc')),
]),
h('button', { class: 'btn-ghost btn-block', style: 'margin-top:6px', onClick: () => { ui.tab = wallet.offline ? 'settings' : 'history'; render(); } }, t('back'))
);
}
// Everything nostr — identity, sync, wallet connect — on its own page.
// Ask the browser to send a URL scheme's links to this app (the ?u= path
// the Android intents already use). Chrome shows its own permission bar;
// a scheme it won't register throws and we say so.
function registerLinkHandler(scheme) {
try {
navigator.registerProtocolHandler(scheme, location.origin + '/?u=%s');
toast(t('linkHandlerAsked'));
} catch (e) { toast(t('linkHandlerRefused')); }
}
function nostrSettingsView() {
return h(
'div',
{ class: 'col', style: 'gap:16px' },
...featureAll('nostrSettingsCards'),
// The browser's own "open these links here" registry: njump's "Your
// default web app" is a web+nostr: link, and it opens whichever site
// asked for that scheme last. Needs a tap (the browser asks).
typeof navigator !== 'undefined' && navigator.registerProtocolHandler
? h('div', { class: 'card col', style: 'gap:8px' },
h('h3', {}, t('linkHandlerTitle')),
h('p', { class: 'small muted', style: 'margin:0' }, t('linkHandlerDesc')),
h('div', { class: 'row gap6', style: 'flex-wrap:wrap' },
h('button', { class: 'btn-sm', onClick: () => registerLinkHandler('web+nostr') }, t('linkHandlerNostr')),
h('button', { class: 'btn-sm', onClick: () => registerLinkHandler('bitcoin') }, t('linkHandlerBitcoin'))))
: null,
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' },
// which network, which explorer, which Ark server: changed once, if ever
networkCard(),
explorerCard(),
...featureAll('advancedSettingsCards'),
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,
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)
);
}
// Which money the third tap on an amount shows. The list is whatever the
// rates endpoint carries, so it's every currency coinos knows a price for.
// Changing it starts the recorded price history again: the samples behind
// "worth then" are denominated in the currency they were taken in, and
// relabelling them would be a lie.
function currencyPicker() {
return h('select', {
title: t('currencyLabel'),
value: getCurrency(),
onChange: (e) => { setCurrency(e.target.value); refreshRates({ force: true }).then(render); render(); },
}, currencies().map((c) => h('option', { value: c, selected: c === getCurrency() }, c)));
}
// 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: with a wallet already minted, even the wizard yields —
// its remaining steps are niceties, not something to be trapped in.
if (ui.onb || onbInProgress()) { ui.onb = null; try { localStorage.removeItem(ONB_STEP_KEY); } catch {} }
// 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.userSearch = null;
ui.noteThread = null;
ui.zapSetup = null;
ui.arkExitPage = null;
ui.settingsPage = null;
ui.logoutConfirm = null;
ui.nostrReconnect = null;
ui.addrScan = false;
ui.arkMoveOpen = false;
ui.nameEditOpen = null;
ui.tab = wallet.offline ? 'settings' : 'history';
ui.draft = null;
ui.sendResult = null;
ui.sendError = '';
ui.fromWallet = false;
} else if ((ui.onb && ui.onb.step !== 'welcome')
|| ui.draftMnemonic || ui.createStep !== 'gen' || ui.importText) {
// Mid-create with no wallet minted yet (wizard step, a generated seed on
// screen, the confirm quiz, a pasted import): the logo backs out to the
// start page this began from — not the vault prompt, which reads as a
// dead end when you were busy creating something new.
ui.onb = { step: 'welcome' };
try { localStorage.removeItem(ONB_STEP_KEY); } catch {}
ui.nostrLoginOpen = false;
ui.screen = 'unlock';
ui.unlockTab = 'create';
ui.createStep = 'gen';
ui.draftMnemonic = '';
clearSeedDrafts();
ui.entropyPage = null;
ui.confirm = [];
ui.unlockError = '';
} else if (hasVault()) {
// Locked with saved wallets: home is the unlock prompt, not the
// create-a-new-wallet flow — the logo must never read as "start over".
// A passwordless vault isn't locked, though: open it silently and land
// home instead of demanding a password nobody set.
if (attemptVaultUnlock('') && accounts.length) {
activateAccount(preferredAccount(), { fresh: true });
return;
}
ui.screen = 'vault';
} 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.logoutConfirm) {
return h('div', { class: 'col', style: 'gap:16px' },
brandHeader(false),
h('div', { class: 'card col' },
h('h3', {}, t('logout') + '?'),
h('p', { class: 'small muted' }, t('logoutPopBlurb')),
h('button', { class: 'btn-primary btn-block', onClick: () => { ui.logoutConfirm = null; ctx.logout(); } }, t('logout')),
h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.logoutConfirm = null; render(); } }, t('back'))));
}
if (ui.addWallet) {
const aw = ui.addWallet;
const fulls = accounts.filter((x) => x.type === 'full' && x.mnemonic);
return h('div', { class: 'col', style: 'gap:16px' },
brandHeader(false),
h('div', { class: 'card col', style: 'gap:12px' },
h('h3', {}, t('addWallet')),
h('div', { class: 'seg', style: 'display:flex;width:100%' },
['spending', 'savings'].map((k) => h('button', {
type: 'button', class: (aw.kind === k ? 'active ' : '') + 'grow',
onClick: () => { aw.kind = k; render(); },
}, k === 'spending' ? t('spendingName') : t('savingsName')))),
h('div', { class: 'col gap6' },
h('span', { class: 'small muted' }, t('addWalletSeed')),
h('select', { onChange: (e) => { aw.from = e.target.value; render(); } },
h('option', { value: 'new', selected: aw.from === 'new' }, t('addWalletNewSeed')),
fulls.map((x) => h('option', { value: x.id, selected: aw.from === x.id }, t('addWalletShareSeed', { name: seedName(x) }))))),
aw.from !== 'new' ? h('div', { class: 'small faint' }, t('addWalletShareNote')) : null,
h('div', { class: 'row gap6' },
h('button', { class: 'btn-ghost grow', onClick: () => { ui.addWallet = null; render(); } }, t('back')),
h('button', { class: 'btn-primary grow', onClick: () => {
const kind = aw.kind;
if (aw.from === 'new') {
ui.newWalletKind = kind;
ui.addWallet = null;
ui.draftMnemonic = ''; ui.createStep = 'gen'; ui.confirm = []; ui.passphrase = ''; ui.confirmPass = '';
ui.showPass = false; ui.screen = 'unlock'; ui.unlockTab = 'create'; ui.fromWallet = true; ui.unlockError = '';
render();
} else {
createDerivedWallet(aw.from, kind);
}
} }, t('addWalletGo')))));
}
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 ? seedName(acc) : '' })),
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' },
// Reached from an open wallet this is a page, not a boot screen — the
// header icons (search, messages, settings, lock, avatar) stay put.
// Cold boot lands here with nothing loaded yet, and then they hide.
brandHeader(!!activeAccount()),
h('div', { class: 'card col' },
h('h3', {}, t('accounts')),
h('div', { class: 'col', style: 'gap:0' },
// Grouped by whose they are. Three signed-in identities used to give
// three rows all called "Savings", indistinguishable from each other
// and from duplicates — the wallets are per-identity, so the screen
// has to say which identity.
...groupAccounts().flatMap(({ pk, rows }) => [
identityHeading(pk),
...rows.flatMap((a) => viewsOf(a).map((view) => {
const isActive = a.id === activeId && accountSel() === view;
const netTag = a.network && a.network !== 'mainnet' ? ' · ' + a.network : '';
const seedTag = '';
const tag = (a.type === 'watch' ? ' · ' + t('watchOnlyTag') : '') + netTag + seedTag;
if (ui.editId === a.id && ui.editView === view) {
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: () => switchToView(a.id, view),
}, (isActive ? '● ' : '○ ') + viewLabel(a, view) + tag),
h('button', { class: 'btn-sm wallet-row-btn', title: t('renameWallet'), onClick: () => { ui.editId = a.id; ui.editView = view; ui.editLabel = viewLabel(a, view); render(); } }, '✎'),
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(); }, html: ' ' })
),
a.type === 'full' && view === viewsOf(a)[viewsOf(a).length - 1]
? 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.addWallet = { kind: 'spending', from: 'new' }; render(); } }, t('addWallet')),
h('button', { class: 'btn-block', onClick: signInAnother }, t('signInAnother')),
activeAccount() ? h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.logoutConfirm = true; render(); } }, t('logout')) : null,
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'))
);
}
// The accounts on this device, gathered under the identity each belongs to,
// in the order they first appear.
function groupAccounts() {
const out = [];
const byPk = new Map();
for (const a of accounts) {
const pk = identityPkOf(a) || 'none';
if (!byPk.has(pk)) { const g = { pk: pk === 'none' ? null : pk, rows: [] }; byPk.set(pk, g); out.push(g); }
byPk.get(pk).rows.push(a);
}
return out;
}
// Who a group of wallets belongs to: their face and name, and a mark when
// it's the identity you're currently using. One identity on the device needs
// no heading at all — there's nothing to tell apart.
function identityHeading(pk) {
if (!pk || groupAccounts().length < 2) return null;
const me = ctx.shownPubkey && ctx.shownPubkey();
const chip = featureHook('profileChip', pk);
return h('div', {
class: 'row gap6',
style: 'align-items:center;padding:10px 0 4px;border-bottom:1px solid var(--line)',
},
chip || h('span', { class: 'small muted' }, (npubOf(pk) || pk).slice(0, 14) + '…'),
pk === me ? h('span', { class: 'tag conf', style: 'margin-left:auto' }, t('accountsThisOne')) : null);
}
function renameAccount(id) {
const acc = accounts.find((a) => a.id === id);
if (acc) {
const v = (ui.editLabel || '').trim();
if (v) {
// names belong to the VIEW now — Spending and Savings rename separately
acc.viewLabels = { ...(acc.viewLabels || {}), [ui.editView || accountSel()]: 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', style: '--rows-3:' + Math.ceil(words.length / 3) + ';--rows-2:' + Math.ceil(words.length / 2) }, 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';
// seeing it here counts: no need to be asked again elsewhere
markSeedSeen(a);
render();
} }, t('revealWords')),
// the same size as its neighbour: two halves of one row, not a
// big button with a small one tucked beside it
h('button', { class: 'btn-ghost grow', 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),
recoveryCard(a),
walletNameCard(a),
pubkeyCard(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() {
// Forget means forget: cached balances, history and feature state go with
// the seeds (best effort — a still-locked vault's mnemonic-keyed caches
// can't be found without its password; the xpub-keyed ones still match).
for (const a of [...accounts, ...loadWatchAccounts()]) wipeAccountCache(a);
try { localStorage.removeItem(VAULT_KEY); } catch {}
try { localStorage.removeItem(WATCH_KEY); } catch {}
ui.confirmClear = false;
lock();
featureHook('forgetAll'); // discard encrypted sync recovery copies too
}
function pwPromptCard() {
const p = ui.pw;
const change = p.purpose === 'change';
const atLock = p.purpose === 'lock';
const newish = change || p.mode === 'set'; // entering a (new) password with confirm
const atLockSave = p.purpose === 'locksave'; // locking, but the vault needs its password first
return h('div', { class: 'card col' },
h('h3', {}, atLock ? t('lockPwTitle') : atLockSave ? t('lockWallet') : change ? t('changePassword') : p.mode === 'set' ? t('setPassword') : t('enterPassword')),
h('p', { class: 'small muted', style: 'margin:0' },
atLock ? t('lockPwDesc') : change ? t('changePasswordDesc') : atLockSave ? t('lockSaveDesc') : 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 }, atLock ? t('lockPwSkip') : t('back')),
h('button', { class: 'btn-primary grow', onClick: submitPw }, newish ? t('save') : atLockSave ? t('lockAction') : t('unlock'))
)
);
}
function vaultScreen() {
return h(
'div',
{ class: 'col', style: 'gap:16px' },
brandHeader(false),
h('div', { class: 'card col' },
h('div', { class: 'row gap6', style: 'align-items:center' },
h('span', { style: 'font-size:20px;line-height:1' }, '\u{1F512}'),
h('h3', { style: 'margin:0' }, ui.justLocked ? t('lockedTitle') : t('unlockSaved'))),
h('p', { class: 'small muted', style: 'margin:0' }, ui.justLocked ? t('lockedDesc') : 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(); } }),
h('div', { class: 'row between', style: 'align-items:center;gap:10px' },
h('span', { class: 'small muted', style: 'white-space:nowrap;flex-shrink:0' }, t('unlockForLabel')),
h('select', { onChange: (e) => { ui.unlockFor = Number(e.target.value) || 0; } },
UNLOCK_FOR_OPTIONS.map((o) => h('option', {
value: String(o.ms),
selected: (ui.unlockFor != null ? ui.unlockFor : Number(localStorage.getItem(UNLOCK_FOR_KEY)) || 0) === o.ms,
}, t(o.label))))),
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 },
loadWatchAccounts().length ? t('viewWithoutPassword') : t('useAnotherWallet'))
)
);
}
// ================================================================ onboarding
// First visit on a fresh device: a full-screen wizard — welcome, then either
// create/import a seed or sign in with nostr — that ends the moment a wallet
// exists, dropping the user into a plain on-chain wallet. Spending is opt-in:
// the same wizard machinery serves the "set up a spending account" flow
// (username, avatar, confetti), but only when the user asks for it from the
// balance card. Shown whenever the device has no wallet — deleting every
// wallet brings it back. The current step is persisted so a refresh (or the
// legacy-migration round trip) resumes exactly where it left off.
const ONB_STEP_KEY = 'btc-wallet-onb-step';
function onbInProgress() {
try { return !!localStorage.getItem(ONB_STEP_KEY); } catch { return false; }
}
function shouldOnboard() {
return accounts.length === 0 && !hasVault();
}
const PUNK_PICKS = [7, 14, 21, 3, 33, 40, 47, 36, 61, 26, 12, 50];
// The value the wizard publishes is always the full-size art — that URL
// becomes this person's picture on every nostr client. The tiles in the
// picker are 56px, so they load the small copies.
const punkImg = (n) => `punks/${n}.webp`;
const punkTile = (n) => `punks-sm/${n}.webp`;
async function onbUpload(file) {
const fd = new FormData();
fd.append('file', file);
const endpoint = 'https://nostr.build/api/v2/upload/files';
// nostr.build no longer takes anonymous uploads — sign with the wallet's
// nostr key, the one key that's always available without a signer prompt.
const headers = {};
try {
if (wallet.nostrPubkey && wallet.nostrPubkey()) {
headers.authorization = await nip98Header({ signEvent: (e) => wallet.nostrSign(e) }, endpoint, 'POST');
}
} catch {}
const r = await fetch(endpoint, { method: 'POST', body: fd, headers });
const j = await r.json();
const url = j?.data?.[0]?.url;
if (!url) throw new Error(t('onbUploadFailed'));
return url;
}
// Whether the wizard can offer Spending at all: an Ark-capable build on a
// network with the names registrar, for a full (signing) account. Elsewhere
// the offer would be a spinner that never resolves.
function canOfferSpending() {
const acc = activeAccount();
if (!acc || acc.type !== 'full' || wallet.watchOnly) return false;
if (!featureHook('arkReady')) return false;
return featureHook('namesAvailable') === 'yes';
}
function onboardScreen() {
if (!ui.onb) {
let saved = null;
try { saved = localStorage.getItem(ONB_STEP_KEY); } catch {}
// a saved step past wallet creation only makes sense if the wallet is here
if (saved && !['welcome', 'signin', 'seed'].includes(saved) && !activeAccount()) saved = 'welcome';
// 'legacy-went' survives the coinos.io round trip: the user left for the
// migrate page, so coming back must offer "I'm done, continue" — the
// in-memory flag died with the navigation and left them looping.
ui.onb = saved === 'legacy-went'
? { step: 'legacy', wentToLegacy: true }
: { step: saved || 'welcome' };
if (saved === 'signin') ui.nostrLoginOpen = true; // reopen the login card
}
const o = ui.onb;
// The wizard's whole job ends the moment a wallet exists: hand the user
// their on-chain wallet, receive page up. A nostr login in flight holds
// this: the wallet opens mid-login, but whether the wizard should even
// continue (a restored account has onboarded elsewhere) isn't known until
// the login finishes.
if ((o.step === 'welcome' || o.step === 'seed' || o.step === 'signin')
&& ui.screen === 'wallet' && activeAccount() && !ui.nostrLoginBusy) {
// A wallet that arrived by SIGN-IN has a recovery phrase its owner has
// never seen. Show it once, here, while it still reads as part of
// setting up — rather than leaving it buried in settings until the day
// they lose the Google account and find out it was never a backup.
// Handled further down, where page()/title() exist.
const arrived = activeAccount();
if (arrived && arrived.mnemonic && !arrived.seedSeen) o.step = 'backup';
else if (canOfferSpending()) {
// Before the home screen: one offer to set up Spending (a name and a
// payment address), with Not now a single tap away.
o.step = 'spend'; o.enterAddr = undefined;
} else {
ui.onb = null;
try { localStorage.removeItem(ONB_STEP_KEY); } catch {}
// On-chain first: the wizard's promise is "your keys, your coins", and
// Spending stays out of sight until the user sets it up.
ui.account = 'savings';
try { localStorage.setItem(ACCOUNT_KEY, 'savings'); } catch {}
ui.tab = 'receive';
return null; // the caller falls through to the wallet itself
}
}
if (o.step === 'spend') {
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 doesn't count as setting up.
if (addr && addr !== o.enterAddr && !/^npub1/.test(addr)) {
// The avatar ask is for brand-new profiles. An identity that already
// wears a face (a nostr login with a published picture) skips straight
// past the punk picker — restyling a real profile helps nobody.
const me = (featureHook('nostrLoginIdentity') || {}).pubkey || (wallet.nostrPubkey && wallet.nostrPubkey());
const prof = me ? featureHook('cachedProfile', me) : null;
o.step = prof && prof.picture ? nextAfterAvatar() : 'avatar';
}
}
try { localStorage.setItem(ONB_STEP_KEY, o.step === 'legacy' && o.wentToLegacy ? 'legacy-went' : o.step); } catch {}
const page = (kids) => h('div', { class: 'col onb', style: 'gap:20px' }, ...kids);
const title = (txt) => h('h2', { class: 'onb-title' }, txt);
// After the identity niceties: offer a first top-up when Savings can fund
// one and Spending is empty-handed — a spending account with nothing in it
// is a demo, not a wallet.
// A function declaration, not a const: the spend step above calls this
// while advancing past the avatar ask, i.e. BEFORE this line runs — a
// const there is in its temporal dead zone and threw "Cannot access
// before initialization", crashing onboarding right after a name claim or
// a coinos.io migration landed (a profile with a picture took this path).
function nextAfterAvatar() {
return (featureHook('arkReady') && wallet.spendable > 1000
&& (featureHook('spendingSat') || 0) + (featureHook('spendingBoardingSat') || 0) < 1000)
? 'board' : 'success';
}
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;
// persist NOW — location.href never comes back through a render
try { localStorage.setItem(ONB_STEP_KEY, 'legacy-went'); } catch {}
location.href = `https://coinos.io/migrate?to=${encodeURIComponent(addr)}&back=${encodeURIComponent(location.origin + '/')}`;
};
// A migrated name mid-claim (the ?migrated= return) paints as progress
// here, not as the pristine ask the user just finished answering.
const claiming = ui.migrating || pendingMigratedName();
return page([
title(t('onbLegacyTitle')),
h('p', { class: 'muted', style: 'margin:0' },
claiming ? t('migrateWorking', { name: claiming }) : t('onbLegacyBody')),
claiming
? h('div', { class: 'row gap6', style: 'justify-content:center;padding:8px 0' }, h('span', { class: 'spinner' }))
: 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 = 'spend'; render(); } },
o.wentToLegacy || claiming ? t('onbLegacyDone') : t('back')),
ui.onbError ? h('div', { class: 'notice err' }, ui.onbError) : null,
]);
}
if (o.step === 'welcome') {
return page([
h('div', { class: 'onb-hero' }, brandHeader(false)),
title(t('onbWelcomeTitle')),
// Making your own keys is the path; a sign-in is the alternative,
// below the line.
h('button', {
// a stable hook for tests, so this button survives its own copy
id: 'onb-create',
class: 'btn-primary btn-block', style: 'font-size:17px;padding:14px', onClick: () => {
ui.unlockTab = 'create';
ui.unlockError = '';
ui.createStep = 'gen';
// the words are the wallet: show them at once, no Generate to tap
if (!ui.draftMnemonic) ui.draftRandom = ui.draftMnemonic = newMnemonic();
o.step = 'seed';
render();
} }, t('onbStart')),
h('div', { class: 'onb-or' }, h('span', {}, t('onbOrSignIn'))),
// All the sign-in doors right on the welcome screen (Google, passkey,
// Nostr) — the feature supplies them; the single-button fallback keeps
// builds without it working.
featureHook('frontDoorSignin')
|| h('button', { class: 'btn-block', style: 'padding:14px', onClick: () => {
o.step = 'signin';
ui.nostrLoginOpen = true;
render();
} }, h('span', { style: 'display:flex;align-items:center;justify-content:center;gap:8px' },
h('span', { style: 'display:flex;flex-shrink:0', html: NOSTR_MARK }),
t('nlSignIn'))),
ui.onbError ? h('div', { class: 'notice err' }, ui.onbError) : null,
]);
}
if (o.step === 'backup') {
const acc = activeAccount();
// the wallet went away (switched, logged out) — nothing to back up
if (!acc || !acc.mnemonic) { ui.onb = null; try { localStorage.removeItem(ONB_STEP_KEY); } catch {} return null; }
const words = acc.mnemonic.split(' ');
const done = () => {
markSeedSeen(acc);
if (canOfferSpending()) { o.step = 'spend'; o.enterAddr = undefined; render(); return; }
ui.onb = null;
try { localStorage.removeItem(ONB_STEP_KEY); } catch {}
ui.account = 'savings';
try { localStorage.setItem(ACCOUNT_KEY, 'savings'); } catch {}
ui.tab = 'receive';
render();
};
return page([
title(t('onbBackupTitle')),
h('p', { class: 'muted', style: 'margin:0' }, t('onbBackupBody')),
h('div', { class: 'warn-box' }, t('writeDownWarn')),
h('div', { class: 'words', style: '--rows-3:' + Math.ceil(words.length / 3) + ';--rows-2:' + Math.ceil(words.length / 2) }, 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(acc.mnemonic, t('copyPhrase'))),
h('button', { class: 'btn-primary btn-block', style: 'padding:14px', onClick: done }, t('onbBackupDone')),
// Later means later, not never: seedSeen stays false, so the wallet
// keeps a quiet reminder until the phrase has actually been seen.
h('button', { class: 'btn-ghost btn-block', onClick: () => {
if (canOfferSpending()) { o.step = 'spend'; o.enterAddr = undefined; render(); return; }
ui.onb = null;
try { localStorage.removeItem(ONB_STEP_KEY); } catch {}
ui.account = 'savings';
try { localStorage.setItem(ACCOUNT_KEY, 'savings'); } catch {}
ui.tab = 'receive';
render();
} }, t('onbBackupLater')),
]);
}
if (o.step === 'seed') {
// The classic create/import card, inside the wizard: getting started
// means seeing (or bringing) your seed phrase, not a wallet minted
// behind your back. Opening one ends the wizard into the wallet + tour.
return page([
title(t('onbSeedTitle')),
unlockCard(),
// the entropy page brings its own Back — one is enough
ui.entropyPage ? null : h('button', { class: 'linklike small', onClick: () => { ui.unlockError = ''; 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 = 'welcome'; render(); } }, t('back')),
]);
}
if (o.step === 'spend') {
return page([
title(t('onbSpendTitle')),
h('p', { class: 'muted', style: 'margin:0' }, t('onbSpendBody')),
featureHook('namesClaimForm') || h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('onbNameWait'))),
// Optional means optional: one tap to the wallet, no questions — on
// Savings, the account that exists without any setup.
h('button', { class: 'btn-ghost btn-block', onClick: () => {
ui.onb = null;
try { localStorage.removeItem(ONB_STEP_KEY); } catch {}
ui.account = 'savings';
try { localStorage.setItem(ACCOUNT_KEY, 'savings'); } catch {}
ui.tab = 'receive';
render();
} }, t('onbNotNow')),
]);
}
if (o.step === 'avatar') {
const me = (featureHook('nostrLoginIdentity') || {}).pubkey || (wallet.nostrPubkey && wallet.nostrPubkey());
const sel = o.avatar;
// The big avatar IS the selection: it shows exactly what Continue will
// publish — a picked punk, an upload, or the pubkey-derived default.
const preview = sel || (me ? punkUrl(me) : null);
return page([
title(t('onbAvatarTitle')),
h('p', { class: 'muted', style: 'margin:0' }, t('onbAvatarBody')),
preview ? h('img', {
class: 'onb-avatar', src: preview, alt: '',
style: ui.onbBusy ? 'opacity:.5' : '',
onError: (e) => { e.target.style.visibility = 'hidden'; },
}) : null,
h('div', { class: 'onb-punks', 'data-keep-scroll': 'punks' },
...PUNK_PICKS.map((n) =>
h('img', {
class: 'onb-punk' + (sel === punkImg(n) ? ' sel' : ''),
src: punkTile(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();
} })),
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. A picked punk is
// a relative path; kind 0 needs the absolute URL (a relative one
// once replaced a real profile picture with a broken string).
const picked = o.avatar && !/^https?:/i.test(o.avatar) ? 'https://v3.coinos.io/' + o.avatar : o.avatar;
const picture = picked || (me ? new URL(punkUrl(me), 'https://v3.coinos.io/').href : undefined);
const fields = { name: addr ? addr.split('@')[0] : undefined, picture };
// Publishing the profile is a nicety — a signer that can't sign
// right now (a sleeping phone bunker) must not trap the wizard.
// And it's an OFFER: the address-derived name (and, unless they
// actually picked one, the default punk) only fills gaps — it
// never renames an identity that already has a profile.
if (fields.name || fields.picture) {
try { await featureHook('publishProfile', fields, { fillOnly: o.avatar ? ['name'] : ['name', 'picture'] }); }
catch (e) { toast(t('onbProfileSkipped')); console.warn('onboarding: profile publish failed —', e.message); }
}
o.step = nextAfterAvatar();
} catch (e) { ui.onbError = e.message; }
ui.onbBusy = false; render();
} }, ui.onbBusy ? h('span', { class: 'spinner sm' }) : (o.avatar ? t('onbContinue') : t('onbSkipAvatar'))),
h('button', { class: 'btn-ghost btn-block', disabled: ui.onbBusy, onClick: () => {
// re-anchor the spend step's "name changed" detector to the name
// as it stands NOW, or a just-claimed name bounces us right back here
o.enterAddr = featureHook('namesAddress') || null;
o.step = 'spend'; render();
} }, t('back')),
]);
}
if (o.step === 'board') {
return page([
title(t('onbBoardTitle')),
h('p', { class: 'muted', style: 'margin:0' }, t('onbBoardBody')),
featureHook('arkBoardForm')
|| h('div', { class: 'row gap6', style: 'align-items:center;justify-content:center' },
h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('arkConnecting'))),
h('button', { class: 'btn-primary btn-block', style: 'padding:14px', onClick: () => { o.step = 'success'; render(); } }, t('onbContinue')),
]);
}
// 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.removeItem(ONB_STEP_KEY); } catch {}
ui.onb = null;
// Latch Spending onto the account only when the wizard actually set it
// up (a claimed address or funds prove it) — finishing the wizard on a
// network without names, or skipping the spend step, must not conjure
// a Spending wallet nobody asked for.
const acc = activeAccount();
// A name claimed DURING this wizard run (it differs from the address
// recorded on entry) is explicit setup on any network — that's the user
// opting in, not stale evidence. Otherwise the mainnet-only rule from
// spendingActive applies: a stored mainnet name must not vouch for
// spending on another network.
const addr = featureHook('namesAddress');
const named = !!addr && !/^npub1/.test(addr);
const claimedNow = named && addr !== o.enterAddr;
const spendReady = claimedNow || (featureHook('spendingSat') || 0) > 0
|| (getNetwork() === 'mainnet' && named);
if (spendReady) {
if (acc && !acc.spendingSetup) { acc.spendingSetup = Date.now(); persistAccounts(); featureHook('spendingEvident'); }
ui.account = 'spending';
try { localStorage.setItem(ACCOUNT_KEY, 'spending'); } catch {}
}
ui.tab = 'receive';
render();
} }, t('onbEnter')),
h('button', { class: 'btn-ghost btn-block', onClick: () => { o.step = 'avatar'; render(); } }, t('back')),
]);
}
// Returning from the legacy migration: coinos.io sends ?migrated=
// once it has swept the balance and released the name. The param is stashed
// SYNCHRONOUSLY at boot (module init below) because the claim itself needs
// an open wallet — which can arrive well after any single post-load timer.
// The old one-shot ("1.5s after load, if a wallet is active") silently
// dropped the whole hand-off whenever the wallet wasn't ready yet, and the
// user came back to a migrate page that had no idea they'd just migrated.
const MIGRATED_KEY = 'btc-wallet-migrated-name';
try {
const _mu = new URL(location.href);
const _mn = _mu.searchParams.get('migrated');
if (_mn) {
localStorage.setItem(MIGRATED_KEY, _mn);
_mu.searchParams.delete('migrated');
history.replaceState(null, '', _mu.pathname + _mu.search + _mu.hash);
}
} catch {}
function pendingMigratedName() {
try { return localStorage.getItem(MIGRATED_KEY); } catch { return null; }
}
function claimMigratedName() {
const name = pendingMigratedName();
if (!name || ui.migrating) return;
ui.migrating = name;
render();
let tries = 0;
const attempt = () => {
const p = featureHook('namesClaimName', name);
if (!p || !p.then) { if (++tries < 12) return setTimeout(attempt, 5000); ui.migrating = null; return; }
p.then(async () => {
try { localStorage.removeItem(MIGRATED_KEY); } catch {}
// Bring the old account's face and cover along — filled into empty
// kind-0 slots only. Bounded wait: the wizard's punk-picker skip
// checks the cached picture right after this, and losing the race
// would ask a migrated user to restyle the profile they already have.
await Promise.race([
Promise.resolve(featureHook('adoptLegacyProfile', name)).catch(() => {}),
new Promise((r) => setTimeout(r, 5000)),
]);
ui.migrating = null;
// Mid-onboarding: the migrate step's job is done — move the wizard
// along instead of re-offering the page that was just completed. A
// null enterAddr lets the spend step see the claimed name as change
// and advance on its own.
if (ui.onb && ui.onb.step === 'legacy') { ui.onb.step = 'spend'; ui.onb.enterAddr = 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);
try { localStorage.removeItem(MIGRATED_KEY); } catch {}
ui.migrating = null;
toast(t('migrateClaimFailed', { name }));
render();
});
};
attempt();
}
function walletScreen() {
if (ui.onb || onbInProgress()) {
const s = onboardScreen();
if (s) return s; // null = the wizard just handed over; fall through
}
// 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 = ['history', 'receive', 'send'];
if (uiChanged('tabnav', ui.tab)) {
const idx = TAB_ORDER.indexOf(ui.tab);
// The pane slides only BETWEEN tabs the user has seen: the first
// observation after a sign-in (and the handover render itself) is the
// wallet simply appearing, not a navigation.
if (!ui.navAnimSkip && _tabSeen) {
_tabCls = 'anim-tab-' + (_swipeDir || (idx >= 0 && idx < _prevTabIdx ? 'right' : 'left'));
_tabAt = performance.now();
}
_tabSeen = true;
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,
backupNotice(),
...featureAll('walletNotices'),
tabsBar(),
pane
);
}
// The recovery phrase has been on screen: record it once, everywhere it
// counts. Every account on the same seed is covered (one phrase backs them
// all), the session and vault copies are written, and the wallet cache is
// saved so the flag rides the synced snapshot to every other device — a
// phrase read on the laptop shouldn't keep nagging on the phone. The
// directory entry carries it too (saveDirectory), so a silent sign-in that
// rebuilds the account on this device finds it before any sync arrives.
function markSeedSeen(a) {
if (!a || a.seedSeen) return;
const same = (x) => x.mnemonic && x.mnemonic === a.mnemonic && (x.passphrase || '') === (a.passphrase || '');
for (const x of accounts) if (x === a || same(x)) x.seedSeen = true;
persistAccounts();
if (a.persisted) writeVault();
try { wallet.saveCache(); } catch {}
}
// The flag's ride on the wallet snapshot (local cache and the synced copy).
// Commutative — it only ever turns on — so older snapshots merge too.
wallet.registerCacheExtension({
mergeAlways: true,
save: () => ((activeAccount() || {}).seedSeen ? { seedSeen: true } : {}),
load: (d) => {
if (!d || !d.seedSeen) return;
const a = activeAccount();
if (!a || !a.mnemonic || a.seedSeen) return;
const same = (x) => x.mnemonic && x.mnemonic === a.mnemonic && (x.passphrase || '') === (a.passphrase || '');
for (const x of accounts) if (x === a || same(x)) x.seedSeen = true;
persistAccounts();
if (a.persisted) writeVault();
// no render here: a synced merge emits from the wallet, and a local
// cache load is mid-activation, which paints when it's done
},
});
// A wallet whose recovery phrase has never been on screen keeps one quiet
// line about it. "Later" is a real answer, so this is a row and not a modal —
// but it doesn't dismiss, because the risk doesn't go away on its own. It
// disappears the moment the phrase is actually seen (here or in settings).
function backupNotice() {
const a = activeAccount();
if (!a || !a.mnemonic || a.seedSeen || wallet.watchOnly) return null;
return h('div', { class: 'small muted', style: 'margin:10px 0 0;text-align:center' },
t('backupReminder'),
' ',
h('button', { class: 'linklike small', onClick: () => {
ui.tab = 'settings';
ui.settingsPage = 'wallet';
ui.revealShown = 'masked';
render();
} }, t('backupReminderGo')));
}
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';
// Spending is opt-in: a wallet is born on-chain only, and the second balance
// (with its toggle, swipe, and Move money) appears once the user sets it up —
// or the moment money is already there (a restored wallet, a claimed gift),
// because a balance must never hide. Setting up = claiming a username, so a
// name claimed anywhere counts; the account flag latches it for accounts
// whose name lookup isn't available right now.
function spendingActive() {
if (!featureHook('arkReady')) return false;
const acc = activeAccount();
// A single-purpose spending wallet IS the opt-in — the user created it by
// picking "Spending" in Add wallet; no further evidence required.
if (acc?.kind === 'spending') return true;
if (acc?.spendingSetup) {
// Self-heal a flag latched by the old cross-network name leak: off
// mainnet, a LEGACY boolean flag (`true` — the buggy era's value; every
// deliberate latch since carries a timestamp) with zero balance and no
// ark history was never really set up — drop it.
if (acc.spendingSetup === true && getNetwork() !== 'mainnet' && !(featureHook('spendingSat') || 0)
&& !(featureHook('arkMovements') || []).length) {
delete acc.spendingSetup;
persistAccounts();
} else {
return true;
}
}
// A claimed payment address proves spending on MAINNET only: names feature
// state is stored per-wallet without a network, so a mainnet name would
// otherwise conjure a Spending wallet on mutinynet nobody set up.
const addr = getNetwork() === 'mainnet' ? featureHook('namesAddress') : null;
const evident = (featureHook('spendingSat') || 0) > 0 || (!!addr && !/^npub1/.test(addr));
// Latch demonstrated use onto the record, so the Wallets list keeps showing
// this seed's Spending side even while it's locked or not the active one.
if (evident && acc && !acc.spendingSetup) { acc.spendingSetup = Date.now(); persistAccounts(); featureHook('spendingEvident'); }
return evident;
}
// ---- the flat wallet model -------------------------------------------------
// Each seed record surfaces as up to two TOP-LEVEL wallets: its Spending (Ark)
// and Savings (on-chain) sides. A record with a fixed `kind` (single-purpose,
// possibly sharing another wallet's seed at a higher BIP84 account) surfaces
// once. "Spending" and "Savings" are the default names; renames are per-view.
function viewsOf(a) {
if (!a) return [];
if (a.kind) return [a.kind];
if (a.type === 'watch') return ['savings'];
// Spending is opt-in: a seed starts as its on-chain wallet alone, and grows
// a Spending side only once the user sets one up (the wizard's spend step,
// an explicit Add wallet, or funds/an address arriving — see the latch in
// spendingActive).
return a.spendingSetup ? ['spending', 'savings'] : ['savings'];
}
// How a SEED reads to the user: the names of the wallets it carries
// ("Spending + Savings") — its internal record label never surfaces.
function seedName(a) {
return viewsOf(a).map((v) => viewLabel(a, v)).join(' + ');
}
function viewLabel(a, view) {
if (a && a.viewLabels && a.viewLabels[view]) return a.viewLabels[view];
return view === 'spending' ? t('spendingName') : t('savingsName');
}
function switchToView(id, view) {
ui.accountUserChosen = true; // deliberate pick from the accounts screen
ui.account = view;
try { localStorage.setItem(ACCOUNT_KEY, view); } catch {}
if (id === activeId) { ui.screen = 'wallet'; ui.tab = 'history'; render(); }
else {
const acc = accounts.find((a) => a.id === id);
if (acc) switchAccount(id);
}
}
function accountSel() {
const a = activeAccount();
if (a && a.kind) return a.kind; // single-purpose wallet: no other side
if (!spendingActive()) return 'savings'; // one balance, 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
let _accAnim = false; // whether the current face switch animates (decided at the switch)
function setAccountSel(a, dir) {
if (ui.account === a) return;
ui.accountUserChosen = true; // deliberate pick — auto-select must not override
_accDir = dir || (a === 'savings' ? 'left' : 'right');
ui.account = a;
try { localStorage.setItem(ACCOUNT_KEY, a); } catch {}
// Selecting an account shows its history right away — home base — and any
// open payment detail (which belongs to the account it was opened from)
// closes with it.
ui.txDetail = null; ui.arkMoveDetail = null; ui.arkReconDetail = null; ui.arkExitDetail = null; ui.giftDetail = null;
ui.tab = 'history';
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 acc0 = activeAccount();
const kindLocked = !!(acc0 && acc0.kind); // single-purpose top-level wallet
const hasSpending = spendingActive();
const sel = kindLocked ? acc0.kind : hasSpending ? accountSel() : 'savings';
const isSpending = sel === 'spending';
// a single-purpose wallet's switch jumps to a same-seed sibling wallet of
// the opposite purpose, when one exists
const opp = isSpending ? 'savings' : 'spending';
const sibling = kindLocked
? accounts.find((x) => x.id !== acc0.id && x.mnemonic && x.mnemonic === acc0.mnemonic
&& (x.passphrase || '') === (acc0.passphrase || '') && viewsOf(x).includes(opp))
: null;
// The balance area: with both views live it's a CAROUSEL — the other face
// peeks at the edge and drags/snaps into place (native scroll-snap), the
// way wallet cards behave in BlueWallet. Snapping IS the account switch.
// Single-purpose and savings-only wallets keep the lone face.
const manageBtn = () => h('button', {
class: 'balance-switch',
onClick: () => { ui.screen = 'accounts'; render(); },
},
h('span', { html: ' ' }),
h('span', { class: 'bsw-label' }, t('manageAccounts')));
const faceFor = (view) => {
const viewSpending = view === 'spending';
return h('div', { class: 'balance-face' },
h('div', { class: 'row between', style: 'align-items:center' },
h('div', { class: 'row', style: 'gap:7px;align-items:center;min-width:0' },
// account identity at a glance (user feedback): spending wears
// lightning + the ark mark, savings the bitcoin disc — color says
// which rail before the label is read
hasSpending && !kindLocked
? h('span', {
class: 'face-ico ' + (viewSpending ? 'spend' : 'save'),
html: viewSpending ? '\u26a1' : BITCOIN_ICON(14),
})
: null,
h('div', { class: 'small faint', style: 'text-transform:uppercase;letter-spacing:.06em' },
kindLocked ? viewLabel(acc0, view)
: hasSpending ? (viewSpending ? t('spendingLabel') : t('savingLabel')) : t('balance'))),
manageBtn()),
h('div', { class: 'amt', style: firstLoad ? 'opacity:.3' : '' },
firstLoad ? h('span', { class: 'spinner sm', style: 'margin-right:8px' }) : null,
animatedAmount('bal:' + view, viewSpending ? spending : saving), ' ', unitTag('unit')),
// Pending incoming is an on-chain fact — it belongs to the Saving face.
// A feature line says which side it belongs to (ark's Moving… is
// Spending; a gift's Locked reservation is on-chain, so Saving) — one
// face used to take them all, and a gift sent from Savings showed up
// as locked money under Spending.
(() => {
const lines = (viewSpending ? [] : pending > 0 ? [{ label: t('pending'), sat: pending, cls: 'v pending' }] : [])
.concat(featLines.filter((l) => (l.side || 'spending') === (viewSpending ? 'spending' : 'savings')));
return lines.length
? h('div', { class: 'split' },
...lines.map((l) => h('div', {}, h('div', { class: 'k' }, l.label), h('div', { class: l.cls || 'v' }, fmtAmount(l.sat), ' ', unitTag()))))
: null;
})());
};
// Everything below the balance figure — the move button, top-up offer,
// spend-setup offer and the inline unfold panel. In carousel mode these
// belong to the SPENDING CARD ITSELF, not to whichever card is selected:
// the strip's flex row stretches both cards to the tallest, so an extra
// that appears only on selection made the whole strip change height right
// as a swipe settled. Tied to the card, heights never move mid-swipe.
const cardExtras = (view = sel) => [
// "Move money" and friends: the only door between the two balances —
// pointless (and confusing) while there's only one.
...(hasSpending ? featureAll('balanceActions') : []).map((a2) =>
h('button', { class: 'btn-sm', style: 'margin-top:10px', onClick: a2.onClick }, a2.label)),
// A seed with only a Savings side is one tap from growing a Spending one:
// the offer sits right on the balance card. The tap creates it outright —
// same seed, a "Spending" side, latched before any prompt so backing out
// of the niceties can't take it away — then walks the username + avatar
// steps, since a spending account is best enjoyed with a payment address.
// (Explicit latches carry a timestamp; self-heal only eats legacy booleans.)
// A Spending face running on fumes while Savings sits comfortable gets a
// one-tap top-up into the board panel.
hasSpending && view === 'spending' && !ui.arkMoveOpen && wallet.spendable > 1000
&& spending + (featureHook('spendingBoardingSat') || 0) < 1000
&& featureHook('arkReady') && !wallet.watchOnly
? h('button', { class: 'btn-sm', style: 'margin-top:10px', onClick: () => {
// tapped on the peeking card: bring the card in with its panel
if (accountSel() !== 'spending') {
ui.accountUserChosen = true;
ui.account = 'spending';
try { localStorage.setItem(ACCOUNT_KEY, 'spending'); } catch {}
}
ui.arkMoveDir = 'toSpending'; ui.arkMoveOpen = true; render();
} }, t('topUpFromSavings'))
: null,
!hasSpending && !kindLocked && featureHook('arkReady') && !wallet.watchOnly && acc0 && acc0.type === 'full'
&& featureHook('namesSettled') !== 'pending' // a restoring name may prove Spending any second — don't flash the setup ask
? h('button', { class: 'btn-sm', style: 'margin-top:10px', onClick: () => {
acc0.spendingSetup = Date.now();
persistAccounts();
featureHook('spendingEvident');
setAccountSel('spending', 'right');
ui.onbError = '';
ui.onb = { step: 'spend' };
render();
} }, t('spendSetup'))
: null,
// A feature can unfold UI right on the card (ark's inline move panel).
(() => {
if (!hasSpending) return null;
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;
})(),
];
const carouselMode = hasSpending && !kindLocked;
if (carouselMode) {
// Two SEPARATE cards side by side — the neighbor card peeks past the
// active one's edge and drags/snaps into place, BlueWallet-style.
const ORDER2 = ['spending', 'savings'];
// Align the carousel to the selected card after this render lands — but
// never fight a finger or live momentum, and NEVER touch layout when it's
// already where it belongs. Boot fires many renders (cache, ark connect,
// scan, nostr, balance ticks); reading offsetLeft/scrollLeft on each one
// forced a synchronous layout mid-scroll — the stutter. `_alignedTo`
// short-circuits before any layout read once the card is in place.
requestAnimationFrame(() => {
const el = document.querySelector('.bal-carousel');
if (!el || el._alignedTo === sel) return;
// never yank the card while a finger/mouse is on it, or right after a
// scroll — a boot render firing this mid-drag was the "false start"
if (el._dragging || Date.now() - (el._lastScroll || 0) < 350) return;
const k = el.children[ORDER2.indexOf(sel)];
if (!k) return;
const target = k.offsetLeft + k.offsetWidth / 2 - el.clientWidth / 2;
if (Math.abs(el.scrollLeft - target) > 6) el.scrollTo({ left: target, behavior: el._inited ? 'smooth' : 'auto' });
el._alignedTo = sel;
el._inited = true;
});
_accDir = null; // the drag is the animation here
// Commit the account switch — shared by settle (the authority, at rest)
// and the EARLY commit at release: the moment a flick's outcome is known
// the history below starts loading, instead of waiting out the slide.
// The strip itself stays morph-frozen until the glide lands, so this
// render can't disturb the motion.
const commitAccount = (el, view) => {
// the scroll rests (or will rest) on this card — mark it aligned so
// the post-render pass skips its layout read instead of re-aligning
el._alignedTo = view;
ui.accountUserChosen = true; // a deliberate pick — no auto-select behind it
ui.account = view;
try { localStorage.setItem(ACCOUNT_KEY, view); } catch {}
// selecting an account lands on its history (home), any open
// payment detail belonged to the other account
ui.txDetail = null; ui.arkMoveDetail = null; ui.arkReconDetail = null; ui.arkExitDetail = null; ui.giftDetail = null;
ui.tab = 'history';
render();
};
// Where will a released touch flick land? Nearest card, pushed one over
// when the finger left with real speed — the same physics mandatory snap
// applies. A wrong guess only flashes the other history for a beat:
// settle corrects it when the snap actually lands.
const predictLanding = (el) => {
const kids = [...el.children];
if (!kids.length) return null;
const mid = el.scrollLeft + el.clientWidth / 2;
let idx = 0, d0 = Infinity;
kids.forEach((k, i) => {
const d = Math.abs(k.offsetLeft + k.offsetWidth / 2 - mid);
if (d < d0) { d0 = d; idx = i; }
});
const s = el._flick || [];
const a = s[0], b = s[s.length - 1];
const vel = a && b && b.t > a.t ? (b.x - a.x) / (b.t - a.t) : 0; // px/ms, + = toward higher index
if (Math.abs(vel) > 0.3) idx = Math.max(0, Math.min(kids.length - 1, idx + (vel > 0 ? 1 : -1)));
return ORDER2[idx];
};
// The settled-card check, shared by the scroll debounce and drag release.
// While a finger or mouse button is still DOWN nothing switches — hovering
// a card mid-drag is browsing, not choosing; release is the choice.
const settle = (el) => {
if (el._dragging) return;
endCarouselDrag(); // motion has stopped — unfreeze the strip and sync it
const mid = el.scrollLeft + el.clientWidth / 2;
let best = 0, bestD = Infinity;
[...el.children].forEach((k, i) => {
const d = Math.abs(k.offsetLeft + k.offsetWidth / 2 - mid);
if (d < bestD) { bestD = d; best = i; }
});
const view = ORDER2[best];
if (view && view !== accountSel()) commitAccount(el, view);
};
return h('div', {
class: 'bal-carousel',
// The account switch (and its history-list rebuild) is the heaviest work
// the carousel triggers, so it must never run mid-glide (that dropped a
// frame and read as a stutter). The switch is driven by the onScroll
// debounce below, which naturally trails the LAST scroll event — during a
// smooth glide or momentum scroll, scroll events fire continuously until
// motion stops, so the debounce lands after the card is already at rest.
// (scrollend would be the semantic signal but it doesn't fire reliably
// for programmatic scrolls across browsers, so it's not relied on.)
onScrollend: (e) => { const el = e.currentTarget; if (!el._dragging) { clearTimeout(el._settle); settle(el); } },
onTouchstart: (e) => {
const el = e.currentTarget;
el._dragging = true; _carDragging = true; el._skipMorph = true;
// where the finger landed, and where it found the strip — a tap
// moves neither
el._x0 = el.scrollLeft;
el._tx = (e.touches[0] || {}).clientX || 0;
el._moved = false;
clearTimeout(el._settle);
},
onTouchmove: (e) => {
const el = e.currentTarget;
if (!el._moved && Math.abs(((e.touches[0] || {}).clientX || 0) - (el._tx || 0)) > 4) el._moved = true;
},
onTouchend: (e) => {
const el = e.currentTarget;
el._dragging = false; // onScroll's debounce settles it
// A TAP, not a drag: the strip never moved. Unfreeze it right now.
// Otherwise the freeze stood until a scroll settled — and a tap
// produces no scroll events, so it stood until the 700ms safety net,
// and the morph refused every render in between. That's why tapping
// the unit tag on a phone took most of a second to change the number
// while the same tap on a laptop (the mouse path has always had this
// guard) was instant.
if (!el._moved && Math.abs(el.scrollLeft - (el._x0 || 0)) < 2) { endCarouselDrag(); return; }
// The finger is up: lift the render hold (the strip stays frozen via
// _skipMorph) and commit the predicted landing NOW, so the history
// below loads under the glide instead of after it.
_carDragging = false;
const view = predictLanding(el);
if (view && view !== accountSel()) commitAccount(el, view);
// safety: if momentum produces no scroll events, settle may never
// fire — unfreeze the strip and flush anyway
setTimeout(() => { if (!el._dragging && (el._skipMorph || _renderDeferred)) endCarouselDrag(); }, 700);
},
// Desktop: scroll-snap has no mouse drag, so emulate one — grab the
// strip and pull. Touch keeps the native pan (pointerType check);
// buttons keep their clicks untouched. Mandatory snap fights raw
// scrollLeft writes, so it's lifted for the drag and restored once the
// release glide lands on a card.
onPointerDown: (e) => {
if (e.pointerType !== 'mouse' || e.button !== 0) return;
if (e.target.closest && e.target.closest('button, a, input')) return;
e.preventDefault(); // no text selection while pulling
const el = e.currentTarget;
// A new grab invalidates any pending snap-restore from the previous
// release: bumping the token makes a stale restore a no-op, so it can
// never re-apply mandatory snap mid-glide and yank the card back to
// where it started. Kill its timer too so they don't pile up.
el._dragGen = (el._dragGen || 0) + 1;
clearTimeout(el._snapT);
const x0 = e.clientX, s0 = el.scrollLeft;
const kids0 = [...el.children];
// the card the drag started on — a flick throws relative to it
let fromIdx = 0, fromD = Infinity;
kids0.forEach((k, i) => {
const d = Math.abs(k.offsetLeft + k.offsetWidth / 2 - (s0 + el.clientWidth / 2));
if (d < fromD) { fromD = d; fromIdx = i; }
});
let moved = false;
const samples = []; // recent pointer positions for release velocity
const mv = (ev) => {
const dx = ev.clientX - x0;
if (!moved && Math.abs(dx) > 4) {
moved = true;
el._dragging = true;
_carDragging = true; // hold renders so nothing eats frames under the pull
el._skipMorph = true; // and freeze the strip against any later mid-glide morph
el.classList.add('grabbing');
el.style.scrollSnapType = 'none';
}
if (moved) {
el.scrollLeft = s0 - dx;
el._lastScroll = Date.now();
samples.push({ t: performance.now(), x: ev.clientX });
while (samples.length > 8 || (samples.length && samples[0].t < performance.now() - 140)) samples.shift();
}
};
const up = () => {
window.removeEventListener('pointermove', mv);
window.removeEventListener('pointerup', up);
el.classList.remove('grabbing');
el._dragging = false;
// safety: if the release glide produces no scroll events, settle
// never fires to unfreeze the strip — do it after a beat
setTimeout(() => { if (!el._dragging && (el._skipMorph || _renderDeferred)) endCarouselDrag(); }, 700);
if (!moved) { endCarouselDrag(); return; }
el._dragged = Date.now(); // the release's click must not double as a card tap
// Commit to a neighbor on a small, deliberate move — not only when
// the drag drags PAST the halfway point. A short flick that traveled
// ~20% of a card toward the neighbor, or any quick flick, switches;
// otherwise it snaps back. (Nearest-point settling made a short pull
// spring back to where it started — the "slide then jump back"
// glitch.) Direction: a flick uses its velocity sign, a slow drag
// uses how far it actually moved from the card it grabbed.
const kids = [...el.children];
const last = samples[samples.length - 1];
const past = samples[0];
const vel = last && past && last.t > past.t ? (last.x - past.x) / (last.t - past.t) : 0;
const pitch = kids[1] ? (kids[1].offsetLeft - kids[0].offsetLeft) : el.clientWidth || 1;
const fromCenter = kids[fromIdx].offsetLeft + kids[fromIdx].offsetWidth / 2;
const nowCenter = el.scrollLeft + el.clientWidth / 2;
const travelled = (nowCenter - fromCenter) / pitch; // + = toward higher index
let dir = 0;
if (Math.abs(vel) > 0.12) dir = vel < 0 ? 1 : -1; // quick flick
else if (Math.abs(travelled) > 0.15) dir = travelled > 0 ? 1 : -1; // pulled ~a sixth of a card
const best = Math.max(0, Math.min(kids.length - 1, fromIdx + dir));
const k = kids[best];
if (k) el.scrollTo({ left: k.offsetLeft + k.offsetWidth / 2 - el.clientWidth / 2, behavior: 'smooth' });
// Restore mandatory snap only once THIS glide has come to rest, and
// only if no newer drag has taken over — a fixed timer used to fire
// mid-glide and snap the card back to its origin. scrollend marks
// the true end; the timer is just a fallback for when the target is
// already reached (no scroll, no scrollend).
const gen = el._dragGen;
const restore = () => { if (el._dragGen === gen && !el._dragging) el.style.scrollSnapType = ''; };
el.addEventListener('scrollend', restore, { once: true });
clearTimeout(el._snapT);
el._snapT = setTimeout(restore, 700);
// The chosen card is known RIGHT HERE — commit it now so the
// history below loads under the glide. The hold lifts (renders may
// run again) but the strip stays morph-frozen until settle; settle
// then finds the account already switched and just syncs the strip.
_carDragging = false;
const view = ORDER2[best];
if (view && view !== accountSel()) commitAccount(el, view);
};
window.addEventListener('pointermove', mv);
window.addEventListener('pointerup', up);
},
onScroll: (e) => {
const el = e.target;
el._lastScroll = Date.now();
// recent positions — predictLanding reads the release velocity off
// these when a touch flick lifts (same window the desktop drag keeps)
(el._flick ||= []).push({ t: performance.now(), x: el.scrollLeft });
while (el._flick.length > 8 || (el._flick.length && el._flick[0].t < performance.now() - 140)) el._flick.shift();
// trails the LAST scroll event: during a glide/momentum scroll these
// fire continuously, so this lands ~130ms after motion stops — after
// the card is at rest, never mid-glide. settle bails while _dragging.
clearTimeout(el._settle);
el._settle = setTimeout(() => settle(el), 130);
},
}, ORDER2.map((v) => h('div', {
class: 'card balance bal-slide face-' + v,
// Tapping the peeking neighbor switches to it outright — the post-render
// alignment then glides it into place. Guarded against a desktop drag's
// release click and against buttons riding on the card.
onClick: v === sel ? undefined : (e) => {
if (e.target.closest && e.target.closest('button, a, input')) return;
const strip = e.currentTarget.parentNode;
if (strip && Date.now() - (strip._dragged || 0) < 300) return;
ui.accountUserChosen = true; // deliberate pick
ui.account = v;
try { localStorage.setItem(ACCOUNT_KEY, v); } catch {}
ui.txDetail = null; ui.arkMoveDetail = null; ui.arkReconDetail = null; ui.arkExitDetail = null; ui.giftDetail = null;
ui.tab = 'history';
render();
},
}, faceFor(v), ...(v === 'spending' ? cardExtras(v) : []))));
}
const dtRaw = animWindow('acct', sel, 300);
// Decide AT the change whether this face switch animates: only deliberate
// switches (tap/swipe set _accDir) slide — a background flip (arkReady
// arriving and revealing Spending right after sign-in) must not.
if (dtRaw >= 0 && dtRaw < 16.7) _accAnim = !!_accDir && !ui.navAnimSkip;
const dtAcc = _accAnim ? dtRaw : -1;
const heroEl = faceFor(sel);
applyAnim(heroEl, 'anim-tab-' + (_accDir || 'left'), dtAcc);
_accDir = null;
return h('div', { class: 'card balance' + (hasSpending && !kindLocked ? ' face-' + sel : '') }, heroEl, ...cardExtras());
}
function tabsBar() {
// Settings lives in the avatar menu, not the tab strip. History isn't a tab
// at all any more — it's the wallet's HOME, under the two tabs: tapping the
// lit tab again (or native back) puts it away and the history is what's left.
const tabs = [
['receive', t('tabReceive')],
// Watch-only wallets show Send too — it prompts to load the seed to spend.
['send', t('tabSend')],
];
return h(
'div',
{ class: 'tabs' },
tabs.map(([id, label]) =>
tabBtn(label, ui.tab === id, () => {
ui.tab = ui.tab === id ? 'history' : id;
ui.sendError = ''; // a stale send error never survives a tab change
ui.revealShown = false; // re-mask the recovery phrase whenever tabs change
ui.nostrExportStep = false; // and the exported nostr key
ui.txDetail = null; // back to the history list when leaving/returning
ui.arkMoveDetail = null;
ui.arkReconDetail = 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() {
if (ui.tab === 'settings') return settingsTab();
if (ui.tab === 'history') return historyTab();
// Receive and Send are panes that fold open ABOVE the home view, not
// replacements for it: the transaction history stays visible below them, so
// getting back to it is scrolling, not hunting for the toggle. A history
// DETAIL (a tapped row) still claims the whole area — you're reading one
// payment, not browsing — as does the receive celebration takeover.
const pane = ui.tab === 'receive' ? receiveTab() : sendTab();
const hist = historyTab();
const detailOpen = ui.bump || ui.txDetail || ui.arkMoveDetail
|| ui.arkReconDetail || ui.arkExitDetail || ui.giftDetail;
if (detailOpen) return hist;
return h('div', { class: 'col', style: 'gap:16px' },
pane,
h('div', { class: 'small faint', style: 'text-transform:uppercase;letter-spacing:.06em;margin-top:4px' }, t('tabHistory')),
hist);
}
// 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() {
// "Recent" means "plausibly the payment you're watching for right now".
// The open-time baseline can't cover a payment the socket only DISCOVERS
// seconds after it was taken — with a 2h window, money that landed a while
// ago celebrated on open as if it had just arrived. A payment confirmed
// more than 15 minutes ago is news, not an event.
const now = Date.now() / 1000;
return wallet.txs.some((tx) => tx.net > 0 && (!tx.confirmed || (tx.blockTime && now - tx.blockTime < 900)));
}
// ---------------------------------------------------------------- Receive
// A dropdown that shows an icon beside each option (a native can't
// render SVG). opts: [{id,label,icon}] where icon is an emoji or an
// string. Closes on select or on a tap outside (transparent backdrop).
function iconGlyph(ic) {
if (!ic) return null;
return String(ic).trim().startsWith('<')
? h('span', { class: 'sel-ico', html: ic })
: h('span', { class: 'sel-ico' }, ic);
}
function receiveTab() {
// Feature takeovers: boarding success, Ark receive celebration, etc.
const takeover = featureHook('receiveTakeover');
if (takeover) return takeover;
// 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. The recency guard additionally ensures an
// already-old payment never celebrates when a wallet is opened.
if (ui.receiveSeenIndex != null && wallet.nextReceiveIndex > ui.receiveSeenIndex && hasRecentIncoming()) {
// Mark it acknowledged as soon as it's shown — so a refresh or navigating
// away (without tapping) won't bring the celebration back. It stays visible
// this session (ui.receiveSeenIndex is unchanged) until the tap below.
wallet.setReceiveAck(wallet.nextReceiveIndex);
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'))
);
}
// The pane follows the global account: Spending shows the payment address
// (receives Ark and Lightning), Savings shows a fresh on-chain address.
const fresh = wallet.freshReceive();
const nameMode = featureAll('receiveModes').find((m) => m.id === 'name');
if (accountSel() === 'spending') {
// Spending needs the network (registrar + ark); offline, offer the
// on-chain path right here instead of an endless spinner.
if (wallet.offline)
return h('div', { class: 'card col', style: 'gap:12px' },
h('div', { class: 'notice info' }, t('receiveOfflineSpending')),
h('button', { class: 'btn-primary btn-block', onClick: () => setAccountSel('savings') }, t('receiveUseSavings')));
if (nameMode) return nameMode.render(null);
// No payment address off mainnet — show the reusable ark address rather
// than silently falling back to an on-chain one that isn't Spending.
if (featureHook('arkReady')) {
if (!ui.arkRecvAddr) {
Promise.resolve(featureHook('arkStaticAddress'))
.then((a2) => { if (a2 && ui.arkRecvAddr !== a2) { ui.arkRecvAddr = a2; render(); } })
.catch(() => {});
return h('div', { class: 'card col', style: 'align-items:center;padding:24px' }, h('span', { class: 'spinner' }));
}
return h(
'div',
{ class: 'card col', style: 'align-items:center;gap:14px' },
h('div', { html: qrSvg(ui.arkRecvAddr) }),
h('div', { class: 'addr-box break', style: 'width:100%' }, ui.arkRecvAddr),
copyBtn(ui.arkRecvAddr, t('copyAddress'))
);
}
}
const addr = fresh.address;
return h(
'div',
{ class: 'card col', style: 'align-items:center;gap:14px' },
h('div', { html: qrSvg(addr) }),
h('div', { class: 'addr-box', style: 'width:100%' }, addr),
copyBtn(addr, t('copyAddress'))
);
}
// ---------------------------------------------------------------- Send
// Form to enter a recovery phrase / xprv and make a watch-only wallet spendable.
// Shared by the Send tab and Settings. The entered seed must derive to this
// account's xpub — you can't load the wrong wallet's seed.
function loadSeedCard() {
const ls = ui.loadSeed;
const seedTa = h('textarea', {
class: 'mono-input', rows: '3', placeholder: t('seedPlaceholder'),
autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: ls.value,
onInput: (e) => { ls.value = e.target.value; },
});
return h(
'div',
{ class: 'card col' },
h('h3', { style: 'margin:0' }, t('loadSeedTitle')),
h('p', { class: 'small muted', style: 'margin:0' }, t('loadSeedDesc')),
pasteInto(seedTa, (text) => { seedTa.value = text; ls.value = text; }),
h('input', {
type: 'password', class: 'mono-input', placeholder: t('bip39PassphraseOpt'),
autocapitalize: 'none', autocomplete: 'off', value: ls.passphrase,
onInput: (e) => { ls.passphrase = e.target.value; },
}),
h('label', { class: 'row gap6', style: 'align-items:center;cursor:pointer' },
h('input', { type: 'checkbox', checked: ls.save, onChange: (e) => { ls.save = e.target.checked; } }),
h('span', { class: 'small' }, t('alsoSaveDevice'))
),
ls.error ? h('div', { class: 'notice err' }, ls.error) : null,
h('div', { class: 'row gap6' },
h('button', { class: 'btn-ghost', onClick: cancelLoadSeed }, t('cancel')),
h('button', { class: 'btn-primary grow', onClick: doLoadSeed }, t('loadSeedBtn'))
)
);
}
function sendTab() {
// Watch-only wallet (e.g. restored after a session wipe without "Save to
// device"): prompt to re-enter the seed before spending.
if (wallet.watchOnly) {
if (ui.loadSeed) return loadSeedCard();
return h('div', { class: 'card col', style: 'gap:12px' },
h('h3', {}, t('watchOnlySendTitle')),
h('p', { class: 'small muted', style: 'margin:0' }, t('watchOnlySendDesc')),
h('button', { class: 'btn-primary btn-block', onClick: () => startLoadSeed() }, t('loadSeedBtn'))
);
}
if (ui.sendResult) return sendResultView();
if (ui.broadcastTx) return broadcastConfirmView();
const featView = featureHook('sendView');
if (featView) return featView;
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 = '';
// A scanned bolt11 (optionally lightning:-prefixed) → pay it over Lightning.
if (featureMatchSend(text)) return;
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=&label=... — amount is in BTC; convert to the
// current display unit so it lands in the form's amount field correctly.
function parseBip21(uri) {
const m = /^bitcoin:([^?]*)(?:\?(.*))?$/i.exec(uri.trim());
if (!m) return {};
const address = decodeURIComponent((m[1] || '').trim());
let amount = null;
if (m[2]) {
const amt = new URLSearchParams(m[2]).get('amount');
if (amt && isFinite(Number(amt)) && Number(amt) > 0) {
const sats = Math.round(Number(amt) * SATS);
amount = unit === 'sats' ? String(sats) : String(Number(amt));
}
}
return { address, amount };
}
function broadcastConfirmView() {
const b = ui.broadcastTx;
return h(
'div',
{ class: 'card col' },
h('h3', {}, t('broadcastScanned')),
h(
'div',
{ class: 'summary col', style: 'gap:0' },
...b.outputs.map((o) =>
h('div', { class: 'line' },
h('span', { class: 'k mono break' }, o.address ? shortAddr(o.address, 14, 8) : '—'),
h('span', { class: 'v' }, fmtAmount(o.value), ' ', unitTag())
)
),
h('div', { class: 'line' },
h('span', { class: 'k' }, t('transactionId')),
h('span', { class: 'v mono break' }, shortTxid(b.txid))
)
),
ui.sendError && h('div', { class: 'notice err' }, ui.sendError),
h('div', { class: 'row gap6' },
h('button', { class: 'btn-ghost', onClick: () => { ui.broadcastTx = null; ui.sendError = ''; render(); } }, t('back')),
ui.busy
? h('button', { class: 'btn-primary grow', disabled: true }, h('span', { class: 'spinner' }))
: h('button', { class: 'btn-primary grow', onClick: broadcastScanned }, t('broadcastNow'))
)
);
}
// --- RBF fee bump (with a fee-rate picker) ---------------------------------
function bumpRate() {
const s = ui.bump;
if (s.feeChoice === 'custom') return Math.max(1, Math.round(Number(s.customFee) || 1));
const fr = wallet.feeRates;
return (fr && fr[s.feeChoice]) || 5;
}
// Open the bump screen: fetch + reconstruct the original, default to Priority.
async function bumpFee(txid) {
if (wallet.offline) { toast(t('scanOffline')); return; }
ui.busy = true;
render();
try {
const prep = await wallet.prepareBump(txid);
ui.bump = { prep, feeChoice: 'fastestFee', customFee: '' };
ui.sendError = '';
} catch (e) {
toast(e.message);
}
ui.busy = false;
render();
}
function bumpView() {
const s = ui.bump;
const feeOpts = [
['economyFee', t('feeEconomy')],
['halfHourFee', t('feeNormal')],
['fastestFee', t('feePriority')],
['custom', t('feeCustom')],
];
const rate = bumpRate();
let pl = null;
try { pl = wallet.planBump(s.prep, rate); } catch {}
const newFee = pl && pl.ok ? pl.fee : null;
const planErr = pl && !pl.ok ? t('bumpInsufficient') : '';
return h(
'div',
{ class: 'card col' },
h('h3', {}, t('bumpConfirm')),
h('div', { class: 'summary col', style: 'gap:0' },
...s.prep.recipients.map((r) =>
h('div', { class: 'line' },
h('span', { class: 'k mono break' }, r.address ? shortAddr(r.address, 14, 8) : '—'),
h('span', { class: 'v' }, fmtAmount(r.value), ' ', unitTag())
)
),
h('div', { class: 'line' },
h('span', { class: 'k' }, t('networkFee')),
h('span', { class: 'v' }, fmtAmount(s.prep.oldFee) + ' → ' + (newFee != null ? fmtAmount(newFee) : '—') + ' ' + unitLabel())
)
),
h('div', { class: 'field' },
h('span', { class: 'lab' }, t('feeRate')),
h('div', { class: 'seg', style: 'display:flex;width:100%' },
feeOpts.map(([k, label]) =>
h('button', {
type: 'button', class: (s.feeChoice === k ? 'active ' : '') + 'grow',
onClick: () => { s.feeChoice = k; if (k === 'custom' && !s.customFee) s.customFee = String(rate); render(); },
}, label)
)
),
s.feeChoice === 'custom'
? h('div', { class: 'input-group mt8' },
h('input', { type: 'number', min: '1', placeholder: 'sat/vB', value: s.customFee,
onInput: (e) => (s.customFee = e.target.value), onChange: () => render() }),
h('span', { class: 'small muted', style: 'align-self:center' }, 'sat/vB'))
: h('div', { class: 'small faint mt8' }, t('selectedRate', { n: rate }))
),
(ui.sendError || planErr) && h('div', { class: 'notice err' }, ui.sendError || planErr),
h('div', { class: 'row gap6' },
h('button', { class: 'btn-ghost', onClick: () => { ui.sendError = ''; goBack(() => { ui.bump = null; }); } }, t('back')),
ui.busy
? h('button', { class: 'btn-primary grow', disabled: true }, h('span', { class: 'spinner' }))
: h('button', { class: 'btn-primary grow', disabled: !newFee, onClick: doBump }, t('replaceTx'))
)
);
}
async function doBump() {
ui.busy = true;
ui.sendError = '';
render();
try {
const d = wallet.buildBump(ui.bump.prep, bumpRate());
const txid = await wallet.broadcast(d.hex);
ui.sendResult = { txid };
ui.bump = null;
ui.txDetail = null;
ui.tab = 'send';
await wallet.scan().catch(() => {});
} catch (e) {
ui.sendError = t('broadcastFailed', { msg: e.message });
}
ui.busy = false;
render();
}
async function broadcastScanned() {
if (wallet.offline) { ui.sendError = t('scanOffline'); render(); return; }
ui.busy = true;
ui.sendError = '';
render();
try {
const txid = await wallet.broadcast(ui.broadcastTx.hex);
ui.sendResult = { txid };
ui.broadcastTx = null;
await wallet.scan().catch(() => {});
} catch (e) {
ui.sendError = t('broadcastFailed', { msg: e.message });
}
ui.busy = false;
render();
}
// Full address as wrapping nodes, first/last 6 chars emphasized — readable
// without horizontally scrolling the input. Returns DOM nodes for in-place
// updates (the address input doesn't re-render on every keystroke).
function addrVerifyNodes(a) {
const n = 6;
if (!a) return [];
if (a.length <= n * 2) return [document.createTextNode(a)];
return [
h('span', { class: 'hl' }, a.slice(0, n)),
document.createTextNode(a.slice(n, -n)),
h('span', { class: 'hl' }, a.slice(-n)),
];
}
// True once the destination is a real on-chain or silent-payment address — used to
// progressively reveal the amount/fee/coin controls. A Lightning invoice instead
// auto-advances to its own confirmation, so it never needs them.
function destReady(a) {
a = (a || '').trim();
return !!a && (wallet.isOnchainAddress(a) || FEATURES.some((f) => f.isSendDest && f.isSendDest(a)));
}
// Recipient search under the destination input: usernames and npubs resolve
// to candidates with avatars; picking one runs the same path as pasting.
const sendSearch = { rows: null, sync: null };
let sendRevealTimer = null;
// With the phone keyboard up, the space under the recipient field is scarce —
// park the field at the top of the view so the candidate list gets what's
// left. Runs on focus and again whenever fresh candidates land (the list
// growing is what pushes things under the keyboard). The .send-suggest
// bottom margin (style.css) guarantees there's page below to scroll into.
function parkSendField() {
if (!matchMedia('(pointer: coarse)').matches) return;
const el = document.activeElement;
if (el && el.isConnected && el.tagName === 'INPUT') el.scrollIntoView({ block: 'start', behavior: 'smooth' });
}
// Results update the panel IMPERATIVELY (sendSearch.sync, set by the mounted
// recipient row) rather than via render(): a full render swaps the focused
// input for a fresh node, which resets Android's keyboard session and cancels
// key auto-repeat — holding backspace deleted one character per press.
const sendSearcher = makeSearcher((q, rows) => {
sendSearch.rows = rows;
if (sendSearch.sync) sendSearch.sync(); else render();
if (rows && rows.length) setTimeout(parkSendField, 50);
});
// One recipient: address + amount. Max is only offered for a single recipient.
function recipientRow(s, r, i) {
const single = s.recipients.length === 1;
const maxOn = single && s.max;
r._ready = destReady(r.address); // reflected each render; onInput re-renders on a flip
// same idea for destination annotations (e.g. the lightning-address zap
// button): they only exist in the rendered tree, so a flip must re-render
r._note = !!featureHook('sendFormNote', r.address);
// Updated imperatively on input (and on render) so paste, typing, and scan
// all reflect immediately without disrupting the input's focus/cursor.
// data-fresh: these two are filled IMPERATIVELY through closures over this
// render's nodes — the morph must install these exact nodes, not keep last
// render's (whose closures are stale). Both are fully filled before the
// morph runs, so the wholesale swap carries identical content.
const check = h('div', { class: 'addr-check', 'data-fresh': '1' });
const syncCheck = () => {
const a = r.address.trim();
const nodes = addrVerifyNodes(a);
check.replaceChildren(...nodes);
check.style.display = a ? '' : 'none';
};
// The suggestions panel gets the same imperative treatment — see the
// sendSearcher note: a render between key repeats kills backspace-hold.
const suggest = i === 0 ? h('div', { class: 'list send-suggest', style: 'display:none', 'data-fresh': '1' }) : null;
const syncSuggest = () => {
if (!suggest) return;
const show = sendSearch.rows && sendSearch.rows.length && searchable(r.address);
suggest.replaceChildren(...(show ? resultRows(h, sendSearch.rows, pickRecipient, (pk, node) => featureHook('wrapAvatar', pk, node)) : []));
suggest.style.display = show ? '' : 'none';
};
if (i === 0) sendSearch.sync = syncSuggest;
// A pasted/typed/scanned payload a feature recognizes (e.g. a bolt11) jumps
// straight to that feature's own confirmation flow.
const tryFeature = (v, typed) => featureMatchSend(v, typed);
const addrInput = h('input', {
type: 'text', class: 'mono-input grow', placeholder: i === 0 ? t('destPlaceholder') : 'bc1q…',
autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: r.address,
// the delay lets the keyboard start opening (and any render() swap of
// this input) before parkSendField measures the live activeElement.
// warmSearch pre-opens the search transports so the first keystroke's
// results don't pay three cold handshakes.
onFocus: () => { warmSearch(); setTimeout(parkSendField, 300); },
onInput: (e) => {
const v = e.target.value;
r.address = v; syncCheck();
const hadError = !!ui.sendError;
ui.sendError = ''; // editing the destination starts over — drop stale errors
if (i === 0) sendSearcher.update(v); // candidates render when results land
if (tryFeature(v, true)) return; // a bolt11 advances to its own confirmation
// reveal/hide amount + controls as validity flips, an annotation
// (zap button) appears/disappears, or an error clears — but only after
// typing pauses: an immediate render swaps this input for a fresh node,
// and Android cancels key auto-repeat when the focused field resets
// (holding backspace deleted a single character).
if (hadError || destReady(v) !== r._ready || !!featureHook('sendFormNote', v) !== r._note) {
clearTimeout(sendRevealTimer);
sendRevealTimer = setTimeout(render, 250);
}
},
});
const pickRecipient = (cand) => {
const v = cand.address || npubOf(cand.pk);
// State first: clear() re-renders, and that render must already see the
// picked address — the old order repainted the half-typed query and left
// the pick looking like it did nothing.
r.address = v;
ui.sendError = '';
sendSearcher.clear();
if (!featureMatchSend(v)) render();
};
const row = h(
'div',
{ class: 'col gap6' },
h('div', { class: 'input-group' },
addrInput,
pasteBtn((text) => { addrInput.value = text; r.address = text; ui.sendError = ''; syncCheck(); if (!tryFeature(text)) render(); }),
i === 0 && canScan() && h('button', {
type: 'button', class: 'btn-sm', title: t('scanQr'), onClick: scanIntoSend,
html: ' ',
}),
!single && h('button', { type: 'button', class: 'btn-sm', title: t('remove'), onClick: () => { s.recipients.splice(i, 1); render(); } }, '✕')
),
check,
suggest,
r._ready ? h('div', { class: 'input-group' },
h('input', {
type: 'number', step: unit === 'sats' ? '1' : '0.00000001', min: '0',
placeholder: unit === 'sats' ? '0' : '0.00000000',
disabled: maxOn,
value: maxOn
? (unit === 'sats' ? String(estimatedMaxSats()) : fmtBtc(estimatedMaxSats()))
: r.amount,
onInput: (e) => {
let v = e.target.value;
// Chrome emits scientific notation when stepping tiny BTC amounts.
if (v && /e/i.test(v)) {
const n = Number(v);
if (isFinite(n)) {
v = unit === 'sats' ? String(Math.round(n)) : n.toFixed(8).replace(/\.?0+$/, '');
e.target.value = v;
}
}
r.amount = v;
},
}),
h('button', { type: 'button', title: t('switchUnit'), onClick: toggleUnit }, unitLabel()),
single && !featureHook('hideSendControls', r.address) && h('button', { type: 'button', class: s.max ? 'btn-primary' : '', onClick: () => { s.max = !s.max; render(); } }, t('max')),
// features that hide the fee controls (ark) can still offer their own
// Max — it fills the amount with that rail's spendable balance
single && featureHook('hideSendControls', r.address)
? (() => {
const v = featureHook('sendMaxFill', r.address);
return v != null ? h('button', { type: 'button', onClick: () => { r.amount = v; s.max = false; render(); } }, t('max')) : null;
})()
: null
) : null
);
syncCheck();
syncSuggest();
return row;
}
function sendForm() {
const s = ui.send;
// Progressive disclosure: until the destination is a valid on-chain/SP address,
// show only the destination input (a Lightning invoice auto-advances instead).
const ready = destReady((s.recipients[0] || {}).address);
const plainAddr = (s.recipients[0] || {}).address;
const arkDest = s.recipients.length === 1 && !!featureHook('hideSendControls', plainAddr);
const feeOpts = [
['economyFee', t('feeEconomy')],
['halfHourFee', t('feeNormal')],
['fastestFee', t('feePriority')],
['custom', t('feeCustom')],
];
return h(
'div',
{ class: 'card col' },
h(
'div',
{ class: 'field' },
h('span', { class: 'lab' }, s.recipients.length > 1 ? t('recipients') : t('recipient')),
h('div', { class: 'col', style: 'gap:14px' },
s.recipients.map((r, i) => recipientRow(s, r, i))
),
ready && !arkDest && s.recipients.length < 10 &&
h('button', {
type: 'button', class: 'linklike small mt8',
onClick: () => { s.recipients.push({ address: '', amount: '' }); s.max = false; render(); },
}, t('addRecipient'))
),
// any feature may annotate the destination (ark hint, lightning-address
// zap offer, …) — each hook decides for itself whether the text is its
s.recipients.length === 1 ? (featureHook('sendFormNote', plainAddr) || null) : null,
ready && !arkDest && h(
'div',
{ class: 'field' },
h('span', { class: 'lab' }, t('feeRate')),
h(
'div',
{ class: 'seg', style: 'display:flex;width:100%' },
feeOpts.map(([k, label]) =>
h('button', {
type: 'button', class: (s.feeChoice === k ? 'active ' : '') + 'grow',
onClick: () => {
s.feeChoice = k;
if (k === 'custom' && !s.customFee) {
s.customFee = String((wallet.feeRates && wallet.feeRates.economyFee) || 1);
}
render();
},
}, label)
)
),
s.feeChoice === 'custom' &&
h('div', { class: 'input-group mt8' },
h('input', { type: 'number', min: '1', placeholder: 'sat/vB', value: s.customFee, onInput: (e) => (s.customFee = e.target.value) }),
h('span', { class: 'small muted', style: 'align-self:center' }, 'sat/vB')
),
s.feeChoice !== 'custom' &&
h('div', { class: 'small faint mt8' }, t('selectedRate', { n: currentFeeRate() }))
),
ready && !arkDest ? coinControl() : null,
ui.sendError && h('div', { class: 'notice err' }, ui.sendError),
ready && h('button', { class: 'btn-primary btn-block', onClick: reviewSend }, t('reviewTx')),
...FEATURES.map((f) => (f.sendFormExtras ? f.sendFormExtras() : null))
);
}
function coinControl() {
const s = ui.send;
const head = h(
'div',
{ class: 'row between' },
h('span', { class: 'lab', style: 'margin:0' }, t('coinSelection')),
h(
'div',
{ class: 'seg' },
h('button', { type: 'button', class: !s.manual ? 'active' : '', onClick: () => { s.manual = false; render(); } }, t('automatic')),
h('button', { type: 'button', class: s.manual ? 'active' : '', onClick: () => { s.manual = true; render(); } }, t('manual'))
)
);
if (!s.manual) return h('div', { class: 'col gap6' }, head);
if (!wallet.utxos.length)
return h('div', { class: 'col gap6' }, head, h('div', { class: 'small muted' }, t('noCoins')));
let selTotal = 0;
const rows = wallet.utxos.map((u) => {
const id = utxoId(u);
const checked = s.coins.has(id);
if (checked) selTotal += u.value;
return h(
'label',
{ class: 'coin' },
h('input', {
type: 'checkbox', checked,
onChange: (e) => { e.target.checked ? s.coins.add(id) : s.coins.delete(id); render(); },
}),
h('div', { class: 'grow' },
h('div', { class: 'mono small break' }, shortAddr(u.address, 14, 10),
!u.confirmed ? h('span', { class: 'tag pending', style: 'margin-left:6px' }, t('pendingTag')) : null),
h('div', { class: 'path' }, `${u.chain}/${u.index} · ${shortTxid(u.txid)}:${u.vout}`)
),
h('div', { class: 'amount small' }, fmtAmount(u.value))
);
});
return h(
'div',
{ class: 'col gap6' },
head,
h('div', { class: 'list' }, rows),
h('div', { class: 'row between small' },
h('span', { class: 'muted' }, t('nSelected', { n: s.coins.size })),
h('span', { class: 'amount' }, fmtAmount(selTotal), ' ', unitTag())
)
);
}
// Coins that a send would draw from (all, or the manually-selected subset).
function spendableCoins() {
const s = ui.send;
return s.manual ? wallet.utxos.filter((u) => s.coins.has(utxoId(u))) : wallet.utxos;
}
// Estimated max sendable = selected total − fee for (n inputs, 1 output).
function estimatedMaxSats() {
const coins = spendableCoins();
const total = coins.reduce((a, u) => a + u.value, 0);
const vbytes = 11 + 68 * coins.length + 31;
const fee = Math.ceil(vbytes * currentFeeRate());
return Math.max(0, total - fee);
}
function currentFeeRate() {
const s = ui.send;
if (s.feeChoice === 'custom') return Math.max(1, Math.round(Number(s.customFee) || 1));
const fr = wallet.feeRates;
if (fr && fr[s.feeChoice]) return fr[s.feeChoice];
return 5;
}
function reviewSend() {
ui.sendError = '';
try {
const s = ui.send;
// Feature destinations (bolt11 → Lightning swap, ark1 → Ark send, ...)
// divert to their own confirmation flows.
for (const f of FEATURES) {
if (f.interceptReview && f.interceptReview(s)) return;
}
const feeRate = currentFeeRate();
let coinIds = null;
if (s.manual) {
coinIds = [...s.coins];
if (!coinIds.length) throw new Error(t('selectCoin'));
}
let recipients, sendMax = false;
if (s.max && s.recipients.length === 1) {
const addr = s.recipients[0].address.trim();
if (!addr) throw new Error(t('enterRecipientAddr'));
recipients = [{ address: addr, amount: 0 }];
sendMax = true;
} else {
recipients = s.recipients.map((r, i) => {
const addr = r.address.trim();
if (!addr) throw new Error(t('enterAddrForN', { n: i + 1 }));
const sats = parseAmount(r.amount, unit, rateNow());
if (!sats || sats <= 0) throw new Error(t('enterValidAmtForN', { n: i + 1 }));
return { address: addr, amount: sats };
});
}
ui.draft = wallet.buildTx({ recipients, feeRate, coinIds, sendMax });
} catch (e) {
ui.draft = null;
ui.sendError = e.message;
}
render();
}
function reviewView() {
const d = ui.draft;
const changeAddr = wallet.freshChange().address;
const outs = d.outputs.filter((o) => o.address !== changeAddr);
return h(
'div',
{ class: 'card col' },
h('h3', {}, t('reviewTx')),
h(
'div',
{ class: 'summary col', style: 'gap:0' },
...outs.map((o) =>
h('div', { class: 'line' },
o.silent
? h('span', { class: 'k col', style: 'gap:2px;align-items:flex-start' },
h('span', { class: 'mono break' }, shortAddr(o.silent, 12, 8)),
h('span', { class: 'small faint' }, t('silentPaymentNote'))
)
: h('span', { class: 'k mono break' }, shortAddr(o.address, 14, 8)),
h('span', { class: 'v' }, fmtAmount(o.amount), ' ', unitTag())
)
),
h('div', { class: 'line' },
h('span', { class: 'k' }, t('networkFee')),
h('span', { class: 'v' }, fmtAmount(d.fee), ' ', unitTag())
)
),
ui.sendError && h('div', { class: 'notice err' }, ui.sendError),
wallet.spendsUnconfirmed(d.tx)
? h('div', { class: 'notice info' }, t('unconfirmedInputWarn'))
: null,
wallet.offline
? h('div', { class: 'notice info' }, t('offlineSignNote'))
: null,
h(
'div',
{ class: 'row gap6' },
h('button', { class: 'btn-ghost', onClick: () => { ui.draft = null; ui.sendError = ''; render(); } }, t('back')),
ui.busy
? h('button', { class: 'btn-primary grow', disabled: true }, h('span', { class: 'spinner' }))
: wallet.offline
? h('button', { class: 'btn-primary grow', onClick: signForExport }, t('signTx'))
: h('button', { class: 'btn-primary grow', onClick: broadcast }, t('signBroadcast'))
),
// Online: also allow signing without broadcasting, to relay the signed tx
// from another device (air-gapped, or a different network).
!wallet.offline && !ui.busy
? h('button', { class: 'btn-block', style: 'margin-top:8px', onClick: signForExport }, t('signExport'))
: null
);
}
async function broadcast() {
ui.busy = true;
ui.sendError = '';
render();
try {
const hexTx = wallet.sign(ui.draft.tx);
const txid = await Promise.race([
wallet.broadcast(hexTx),
new Promise((_, rej) => setTimeout(() => rej(new Error(t('broadcastTimeout'))), 30000)),
]);
wallet.applySentTx(ui.draft.tx); // update balance/history locally, right now
ui.sendResult = { txid };
ui.draft = null;
ui.send = blankSend();
ui.busy = false;
render(); // the realtime watcher / backstop poll reconciles + confirms later
return;
} catch (e) {
ui.sendError = t('broadcastFailed', { msg: e.message });
}
ui.busy = false;
render();
}
function signForExport() {
ui.sendError = '';
try {
const tx = ui.draft.tx;
const hexTx = wallet.sign(tx);
ui.sendResult = { signedHex: hexTx, txid: tx.id };
ui.draft = null;
ui.send = blankSend();
} catch (e) {
ui.sendError = t('signingFailed', { msg: e.message });
}
render();
}
function sendResultView() {
const r = ui.sendResult;
const again = h('button', { class: 'btn-block mt8', onClick: () => { ui.sendResult = null; render(); } }, t('done'));
if (r.signedHex) {
return h(
'div',
{ class: 'card col' },
h('div', { class: 'warn-box' }, t('txSignedNote')),
h('div', { class: 'small muted' }, t('transactionId')),
h('div', { class: 'addr-box' }, r.txid),
h('div', { class: 'small muted mt8' }, t('signedTxRaw')),
h('textarea', { readonly: true, style: 'min-height:120px', value: r.signedHex }),
h('div', { class: 'row gap6' },
copyBtn(r.signedHex, t('copyHex')),
h('button', { class: 'btn-sm', onClick: () => download(`tx-${r.txid.slice(0, 8)}.txt`, r.signedHex, 'text/plain') }, t('downloadLabel')),
h('div', { class: 'grow', html: '' })
),
h('details', { class: 'mt8' }, h('summary', { class: 'small muted' }, t('showQrAirgap')), h('div', { style: 'margin-top:10px', html: qrSvg(r.signedHex) })),
again
);
}
return h(
'div',
{ class: 'card col', style: 'align-items:center' },
h('div', { class: 'notice ok', style: 'width:100%' }, t('txBroadcast')),
h('div', { class: 'small muted' }, t('transactionId')),
h('div', { class: 'addr-box' }, r.txid),
h('div', { class: 'row gap6' },
copyBtn(r.txid, t('copyTxid')),
h('a', { class: 'btn btn-sm', href: wallet.api.explorerTx(r.txid), target: '_blank', rel: 'noopener', onClick: (e) => { e.preventDefault(); openExternal(wallet.api.explorerTx(r.txid)); } }, t('viewOnMempool'))
),
again
);
}
function txHistoryItem(tx) {
const incoming = tx.net >= 0;
const stuck = !tx.confirmed && wallet.isStuck(tx);
const deco = featureHook('decorateTxRow', tx); // e.g. a claimed gift's claim tx
return h(
'div',
{ class: 'item', style: 'cursor:pointer', onClick: () => openTx(tx.txid) },
// rail glyph in the direction-tinted circle: feature decoration (ark/sp/
// gift) if any, else the Bitcoin mark for a plain on-chain tx. Direction
// is carried by the tint + signed amount.
deco
? h('div', { class: `ico ${incoming ? 'in' : 'out'}` }, deco.icon)
: h('div', { class: `ico ${incoming ? 'in' : 'out'}`, html: BITCOIN_ICON(22) }),
h('div', { class: 'grow' },
h('div', { class: 'row gap6' },
deco ? deco.label :
incoming ? t('received') : t('sent'),
tx.confirmed ? null
: stuck ? h('span', { class: 'tag', style: 'background:var(--red-soft);color:var(--red)' }, t('stuckTag'))
: h('span', { class: 'tag pending' }, t('pendingTag'))
),
h('div', { class: 'small faint' }, tx.confirmed ? timeAgo(tx.blockTime) : stuck ? t('stuckNote') : t('awaitingConfirmation'))
),
h('div', { style: 'text-align:right' },
// priced at the moment it happened, not at today's number
h('div', { class: incoming ? 'amount-pos' : 'amount-neg' }, (incoming ? '+' : '') + fmtAmount(tx.net, tx.blockTime)),
!incoming && tx.fee ? h('div', { class: 'small faint' }, t('feeShort', { x: fmtAmount(tx.fee, tx.blockTime) })) : null
)
);
}
// Prev / page-of / next controls. Returns null when there's only one page.
// The buttons LOOP within the pages (never disabled): Next on the last page
// returns to page 1, Prev on page 1 jumps to the end — unlike the swipe
// ring, they never leave the list for the tabs.
const PAGE_SIZE = 10;
// The history list currently on screen ({ page, pages }), null when a detail
// view / spinner / another tab is up — the swipe gesture paginates against
// this and only falls through to tab navigation once the pages run out.
let _histPager = null;
function pager(page, total, onPage) {
const pages = Math.ceil(total / PAGE_SIZE);
if (pages <= 1) return null;
return h('div', { class: 'row between', style: 'align-items:center;padding-top:10px' },
h('button', { class: 'btn-sm', onClick: () => onPage((page - 1 + pages) % pages) }, t('prevPage')),
// the page number is a picker: forty pages is a long swipe to the middle
h('select', {
class: 'small muted page-pick', 'aria-label': t('pageXofY', { x: page + 1, y: pages }),
onChange: (e) => onPage(parseInt(e.target.value, 10) || 0),
}, ...Array.from({ length: pages }, (_, i) => h('option', { value: String(i), selected: i === page }, t('pageXofY', { x: i + 1, y: pages })))),
h('button', { class: 'btn-sm', onClick: () => onPage((page + 1) % pages) }, t('nextPage'))
);
}
function historyTab() {
_histPager = null; // set again below only when the pageable list renders
if (ui.bump) return bumpView();
// History is SCOPED to the selected account: Savings shows the on-chain
// ledger (BIP84 txs, silent-payment receipts, gift reservations), Spending
// shows the ark side (receives, sends, boards, LN) — each card of the
// carousel owns its own timeline.
const histSel = accountSel();
const txs = histSel === 'spending' ? [] : wallet.history; // newest first
const featDetail = featureHook('historyDetail');
if (featDetail) return featDetail;
if (ui.txDetail) {
const tx = wallet.history.find((x) => x.txid === ui.txDetail);
if (tx) return txDetailView(tx);
ui.txDetail = null;
}
if (wallet.offline)
return h('div', { class: 'card' }, h('p', { class: 'muted center', style: 'margin:0' }, t('historyOffline')));
if (!wallet.loaded || (histSel !== 'spending' && wallet.historyLoading && !txs.length))
return h(
'div',
{ class: 'card center col', style: 'align-items:center;gap:10px' },
h('span', { class: 'spinner' }),
wallet.historyLoading ? h('p', { class: 'small muted', style: 'margin:0' }, t('loadingHistory')) : null
);
const featEntries = featureAll('historyEntries', txs, histSel);
if (!txs.length && !featEntries.length)
return h('div', { class: 'card' }, h('p', { class: 'muted center', style: 'margin:0' }, t('noTxYet')));
// Transactions and feature entries merge into one timeline.
const entries = [
...txs.map((tx) => ({ time: tx.confirmed ? (tx.blockTime || 0) * 1000 : Date.now(), render: () => txHistoryItem(tx) })),
...featEntries,
].sort((a, b) => b.time - a.time);
const txPages = Math.ceil(entries.length / PAGE_SIZE);
const txPage = Math.min(ui.txPage, Math.max(0, txPages - 1));
const txSlice = entries.slice(txPage * PAGE_SIZE, txPage * PAGE_SIZE + PAGE_SIZE);
_histPager = { page: txPage, pages: txPages };
return h(
'div',
{ class: 'card history-card' },
h('div', { class: 'list' },
...txSlice.map((e) => e.render())
),
pager(txPage, entries.length, (p) => { ui.txPage = p; render(); }),
wallet.historyLoading
? h(
'div',
{ class: 'row gap6', style: 'padding:10px 0 2px;justify-content:center' },
h('span', { class: 'spinner sm' }),
h('span', { class: 'small muted' }, t('loadingHistory'))
)
: null
);
}
// Open a tx's detail view, lazily fetching its fee/details if we don't have them
// yet. A watcher-credited receive arrives with no fee (the push can't compute it
// from the raw tx), so fill it in from the explorer on demand — works for pending
// mempool txs too.
async function openTx(txid) {
ui.txDetail = txid;
render();
const tx = wallet.history.find((t) => t.txid === txid);
if (!tx || tx.sp || tx.fee || wallet.offline) return;
try {
const full = await wallet.api.getTx(txid);
if (!full || ui.txDetail !== txid) return;
if (full.fee != null) tx.fee = full.fee;
if (full.weight) tx.vsize = Math.ceil(full.weight / 4);
if (full.status && full.status.confirmed) {
tx.confirmed = true;
tx.blockHeight = full.status.block_height || tx.blockHeight;
tx.blockTime = full.status.block_time || tx.blockTime;
}
wallet.saveCache();
render();
} catch {}
}
function txDetailView(tx) {
const incoming = tx.net >= 0;
const line = (k, v) => h('div', { class: 'line' }, h('span', { class: 'k' }, k), h('span', { class: 'v' }, v));
return h(
'div',
{ class: 'card col' },
h('div', { class: 'row between' },
h('h3', {}, incoming ? t('received') : t('sent')),
h('span', { class: `tag ${tx.confirmed ? 'conf' : 'pending'}` }, tx.confirmed ? t('confirmedTag') : t('pendingTag'))
),
h('div', { class: 'amt', style: 'font-size:30px' },
h('span', { class: incoming ? 'amount-pos' : 'amount-neg' }, (incoming ? '+' : '') + fmtAmount(tx.net, tx.blockTime)),
' ', unitTag('unit')
),
h('div', { class: 'summary col', style: 'gap:0' },
line(t('status'), tx.confirmed ? t('confirmed') : t('pending')),
tx.confirmed ? line(t('block'), String(tx.blockHeight || '—')) : null,
tx.confirmed && tx.blockTime ? line(t('date'), new Date(tx.blockTime * 1000).toLocaleString()) : null,
// what it was worth then, beside what it is now — the one place both
// numbers earn their keep, because this is the page you open to ask
worthLine(tx.net, tx.blockTime),
tx.fee ? line(t('networkFee'), fmtAmount(tx.fee, tx.blockTime) + ' ' + unitLabel()) : null
),
...FEATURES.map((f) => (f.txDetailSection ? f.txDetailSection(tx) : null)),
!tx.confirmed && wallet.isStuck(tx)
? h('div', { class: 'warn-box' }, incoming ? t('stuckIncomingNote') : t('stuckOutgoingNote'))
: null,
h('div', { class: 'col gap6' },
h('span', { class: 'lab' }, t('transactionId')),
h('div', { class: 'addr-box', style: 'font-size:13px' }, tx.txid)
),
h('div', { class: 'row gap6 wrap' },
copyBtn(tx.txid, t('copyId')),
h('a', { class: 'btn btn-sm', href: wallet.api.explorerTx(tx.txid), target: '_blank', rel: 'noopener', onClick: (e) => { e.preventDefault(); openExternal(wallet.api.explorerTx(tx.txid)); } }, t('viewOnMempool'))
),
// RBF: an unconfirmed send can be rebroadcast at a higher fee.
!tx.confirmed && !incoming && !wallet.offline
? (ui.busy
? h('button', { class: 'btn-primary btn-block', disabled: true }, h('span', { class: 'spinner' }))
: h('button', { class: 'btn-primary btn-block', onClick: () => bumpFee(tx.txid) }, t('bumpFee')))
: null,
h('button', { class: 'btn-ghost btn-block', onClick: () => goBack(() => { ui.txDetail = null; }) }, t('backToHistory'))
);
}
// Offline snapshot exchange: export coins on an online device, import on an
// offline (air-gapped) one to sign without internet.
function snapshotActions() {
return h(
'div',
{ class: 'col gap6' },
h('p', { class: 'small muted', style: 'margin:0' },
t('offlineTransferDesc')),
h('div', { class: 'row gap6 wrap' },
h('button', { class: 'btn-sm', disabled: !wallet.utxos.length, onClick: exportSnapshot }, t('exportSnapshot')),
h('label', { class: 'btn btn-sm', style: 'cursor:pointer' }, t('importSnapshot'),
h('input', { type: 'file', accept: 'application/json,.json', style: 'display:none', onChange: importSnapshotFile })
)
)
);
}
function exportSnapshot() {
const snap = wallet.exportSnapshot();
const stamp = new Date().toISOString().slice(0, 10);
download(`wallet-snapshot-${wallet.netName}-${stamp}.json`, JSON.stringify(snap, null, 2));
toast(t('snapshotExported'));
}
async function importSnapshotFile(e) {
const file = e.target.files[0];
if (!file) return;
try {
const snap = JSON.parse(await file.text());
const res = wallet.importSnapshot(snap);
let msg = t('importedNCoins', { n: res.imported });
if (res.unmatched.length) msg += t('unmatchedSuffix', { n: res.unmatched.length });
toast(msg);
ui.tab = 'settings';
render();
} catch (err) {
toast(t('importFailed', { msg: err.message }));
}
e.target.value = '';
}
// ================================================================ start
// Load the active language's strings (English is inline; others are fetched),
// ---- feature registry -------------------------------------------------
// Optional features receive the app context and plug into fixed seams. This
// sits just before boot so every helper it captures is defined; the hooks are
// only invoked at runtime (first render happens after loadLocale below).
const ctx = {
h, ui, render, wallet, toast, copy, copyBtn, pasteBtn, blankSend, goBack, goHome, openExternal, showSend,
fmtAmount, unitLabel, unitTag, getUnit: () => unit, toggleUnit, download,
// in fiat the typed figure is money, so it needs today's price to become sats
parseAmount: (v, u) => parseAmount(v, u, rateNow()),
worthLine,
openImage: (src) => { ui.lightbox = src; render(); },
// What woke this run, if anything did, and how to say we're finished.
// Both read through a closure: ctx is built before these are defined, and
// a bare `wakeDone` shorthand captured the binding while it was still
// undefined — the job then ran its full half minute every time.
wakePayload: () => WAKE_PAYLOAD,
wakeDone: () => wakeDone(),
// "The next balance isn't a change, it's the first real value" — state
// arriving late (the ark store opening past boot's patience) must not read
// as money landing: without this the balance counts up from 0 in green,
// which is the celebration a received payment gets.
forgetAmountAnim: () => _amtLast.clear(),
// the one-tap zap amount, synced across devices with the rest of the state
zapDefaultSat: () => (wallet.loadFeatureState ? (wallet.loadFeatureState('prefs', {}).zapSat || 0) : 0),
setZapDefaultSat: (n) => {
const p = wallet.loadFeatureState('prefs', {});
p.zapSat = Math.max(1, Math.floor(n) || 0);
wallet.saveFeatureState('prefs', p);
try { wallet.saveCache(); } catch {}
},
// the tap's sound effect — on unless switched off from the held-⚡ screen
zapSound: () => !wallet.loadFeatureState || wallet.loadFeatureState('prefs', {}).zapSound !== false,
setZapSound: (on) => {
const p = wallet.loadFeatureState('prefs', {});
if (on) delete p.zapSound; else p.zapSound = false;
wallet.saveFeatureState('prefs', p);
try { wallet.saveCache(); } catch {}
},
// Per-recipient auto-pay budget: a fixed-amount LNURL/address the user
// trusts to pay without confirmation, up to this many sats. Keyed by the
// recipient's address/lnurl. Synced with the rest of prefs.
lnBudget: (key) => {
if (!key || !wallet.loadFeatureState) return 0;
return (wallet.loadFeatureState('prefs', {}).lnBudgets || {})[key] || 0;
},
setLnBudget: (key, sat) => {
if (!key) return;
const p = wallet.loadFeatureState('prefs', {});
p.lnBudgets = { ...(p.lnBudgets || {}) };
if (sat > 0) p.lnBudgets[key] = Math.floor(sat); else delete p.lnBudgets[key];
wallet.saveFeatureState('prefs', p);
try { wallet.saveCache(); } catch {}
},
brandHeader, activeAccount, setAccounts: (list) => { accounts = list; },
getAccounts: () => accounts,
claimTargets, enterWallet, activateAccount, commitAccount,
// cross-feature calls (e.g. gifts asking the ark feature for ark-gift
// support) — lazy so it resolves against the final FEATURES list, and a
// build without the target feature simply gets null back
hook: (name, ...args) => featureHook(name, ...args),
// full sign-out (distinct from the padlock's lock-in-place): clear the
// session and leave — offering a password if none protects the vault yet
logout: () => {
lock({ offerPassword: true });
// Logging out means the front door, not the password prompt: land on the
// start page (Get started / sign in). Saved wallets stay one Unlock away.
if (!ui.pw) { ui.screen = 'unlock'; ui.unlockTab = 'create'; ui.onb = { step: 'welcome' }; render(); }
},
// The drastic exit: leave AND remove everything saved on this device.
// Routes through the Delete-all warning — never a single tap.
// The popup has already shown the delete-everything warning by the time
// this runs — wipe now and land on the front door.
// identity switching lives on the profile page (the Accounts screen is
// about Spending/Savings faces, not who you are)
signInAnother, identities, switchIdentity, signOutIdentity, identityRecoverable,
logoutForget: () => {
ui.profilePk = null; ui.profEdit = null; ui.profEditFilled = false; ui.chatOpen = false;
ui.pubProf = null;
clearAll();
},
// the identity the header avatar currently wears (login > seed key >
// remembered) — profile "mine"-ness must match what the user clicked
shownPubkey: () => (featureHook('nostrLoginIdentity') || {}).pubkey
|| (wallet.nostrPubkey && wallet.nostrPubkey()) || (activeAccount() || {}).nostrPk || null,
getAccount: () => accountSel(),
setAccount: (a, dir) => setAccountSel(a, dir),
// Open (or create) a wallet from a mnemonic — used by nostr login.
openMnemonic: async (mnemonic, passphrase, opts) => enterWallet(mnemonic, passphrase, opts),
// Upload an image (avatar) and get back its URL — the same signed
// nostr.build path the onboarding picker uses.
uploadImage: (file) => onbUpload(file),
// A nostr login that lands mid-wizard: a restored wallet has been through
// onboarding elsewhere, so the wizard ends without tour or prompts; a fresh
// one falls through to the wallet + tour on the next render.
onbNostrLogin: (restored) => {
if (!ui.onb) return;
if (restored) { ui.onb = null; try { localStorage.removeItem(ONB_STEP_KEY); } catch {} }
},
};
// Android wrapper intents: the TWA app (android/) registers for lightning:/
// bitcoin:/nostr:/lnurl: URIs and forwards them here as ?u=. Read
// BEFORE features construct: a nostr npub/nprofile rewrites itself onto the
// existing /npub… profile deep link (which the messages feature reads at
// construction); payment URIs stash for dispatch once a wallet is open.
// Woken by the Android wrapper (see android/, WakeJob): the wallet is being
// loaded off-screen to deal with something. The payload is read once, here,
// because whether anything CAN be done depends on whether a wallet opens at
// all — and if none does, the job should end straight away rather than hold
// a WebView open for half a minute over a locked wallet.
const WAKE_PAYLOAD = (() => {
try {
const w = new URLSearchParams(location.search).get('wake');
if (w == null) return null;
history.replaceState(null, '', location.pathname || '/');
return w;
} catch { return null; }
})();
const wakeDone = () => {
try { if (WAKE_PAYLOAD != null && window.CoinosHost && window.CoinosHost.done) window.CoinosHost.done(); } catch {}
};
// v3.coinos.io/pos: the point of sale, straight away — a bookmark or a
// home-screen icon for the till. Consumed here (before any feature reads
// the path) and opened once a wallet is up; the pos feature owns the rest.
const BOOT_POS = (() => {
try {
if (!/^\/pos\/?$/i.test(location.pathname)) return false;
history.replaceState(null, '', '/' + (location.search || ''));
return true;
} catch { return false; }
})();
if (BOOT_POS) ui.posAtBoot = true;
const INTENT_URI = (() => {
try {
const u = new URLSearchParams(location.search).get('u');
if (!u) return null;
history.replaceState(null, '', location.pathname || '/');
// web+nostr: is what a browser hands a registered protocol handler
// (njump's "Your default web app"); nostr: is the Android intent
const m = u.match(/^(?:web\+)?nostr:((npub|nprofile|note|nevent)1[a-z0-9]+)\/?$/i);
if (m) { history.replaceState(null, '', '/' + m[1]); return null; }
return u.replace(/^web\+/i, '');
} catch { return null; }
})();
const FEATURES = buildFeatures(ctx);
// Deferred features (gifts, NWC, POS) hold placeholder slots in FEATURES —
// hook precedence is position — and arrive as separate chunks right after
// boot, filled in place. If a wallet opened before they landed, each newcomer
// gets the init() it missed; if the wallet locked again, it doesn't.
let _featuresInited = false;
const _deferredReady = (async () => {
try {
const late = await loadDeferredFeatures(ctx, FEATURES);
if (_featuresInited) for (const f of late) { try { f.init && f.init(); } catch {} }
if (late.length) render();
} catch (e) { console.error('deferred features failed to load', e); }
})();
// apply text direction, then restore a wallet left open in this tab — otherwise
// show the unlock screen.
applyDir();
// Auto log-out: start the countdown only when the app loses focus / is hidden,
// and cancel it the moment it's focused again. So it never logs out mid-use.
// Swipe left/right moves between the main tabs, the way thumbs expect.
// Horizontal-dominant, quick, and only on the wallet's own tab pages — never
// inside chat, profiles, takeovers, or the (offline-forced) settings tab.
(() => {
const ORDER = ['history', 'receive', 'send'];
let sx = 0, sy = 0, t0 = 0, live = false, onBalance = false, swallowClick = false;
window.addEventListener('click', (e) => {
if (!swallowClick) return;
swallowClick = false;
e.stopPropagation();
e.preventDefault();
}, true);
window.addEventListener('touchstart', (e) => {
live = e.touches.length === 1 && ui.screen === 'wallet' && !ui.chatOpen && !ui.profilePk
&& !ui.arkExitPage && ORDER.includes(ui.tab);
if (!live) return;
// the carousel owns its own drag — the flip gesture only serves the
// single-face balance (kind-locked / savings-only wallets)
if (e.target.closest && e.target.closest('.bal-carousel')) { live = false; return; }
onBalance = !!(e.target.closest && e.target.closest('.balance'));
sx = e.touches[0].clientX;
sy = e.touches[0].clientY;
t0 = Date.now();
}, { passive: true });
window.addEventListener('touchcancel', () => { live = false; }, { passive: true });
window.addEventListener('touchend', (e) => {
if (!live) return;
live = false;
const t = e.changedTouches[0];
const dx = t.clientX - sx, dy = t.clientY - sy;
if (Date.now() - t0 > 600 || Math.abs(dx) < 60 || Math.abs(dx) < 1.8 * Math.abs(dy)) return;
// A touch sequence still fires a compatibility click at the release
// point; after a swipe that would ALSO press whatever is under the
// finger. Swallow exactly one click.
swallowClick = true;
setTimeout(() => { swallowClick = false; }, 400);
// a swipe that began on the balance card flips the account instead
if (onBalance) {
const want = dx < 0 ? 'savings' : 'spending';
if (spendingActive()) setAccountSel(want, dx < 0 ? 'left' : 'right');
return;
}
// The swipe surfaces form ONE RING that loops:
// … page0 ⇄ page1 … lastPage ⇄ Receive ⇄ Send ⇄ page0 …
// Swiping left over the history list turns pages older (the pager's
// Next); at the last page it wraps into Receive, then Send, then back to
// page 0. Swiping right from page 0 goes the other way — Send, then
// Receive, then the LAST page. No dead ends anywhere.
const resets = () => {
// same resets as a tab-bar tap
ui.sendError = '';
ui.revealShown = false;
ui.txDetail = null;
ui.arkMoveDetail = null;
ui.arkReconDetail = null;
ui.giftDetail = null;
ui.addrScan = false;
ui.bump = null;
ui.giftMode = false;
};
const goTab = (tab) => { _swipeDir = dx < 0 ? 'left' : 'right'; ui.tab = tab; resets(); render(); };
// entering history from a wrap lands on a chosen page (an oversized
// index clamps to the last page in historyTab)
const goHist = (page) => { _swipeDir = dx < 0 ? 'left' : 'right'; ui.tab = 'history'; resets(); ui.txPage = page; render(); };
if (ui.tab === 'history') {
// a detail view / spinner / empty list publishes no pager — treat it
// as a single page so the swipe still reaches the tabs
const { page, pages } = _histPager || { page: 0, pages: 1 };
if (dx < 0) { if (page < pages - 1) { ui.txPage = page + 1; render(); } else goTab('receive'); }
else if (page > 0) { ui.txPage = page - 1; render(); }
else goTab('send');
return;
}
if (ui.tab === 'receive') { if (dx < 0) goTab('send'); else goHist(1e9); return; }
if (ui.tab === 'send') { if (dx < 0) goHist(0); else goTab('receive'); return; }
}, { passive: true });
})();
window.addEventListener('blur', onAppHidden);
window.addEventListener('focus', onAppVisible);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') onAppHidden();
else onAppVisible();
});
// Claim a stashed migrated name as soon as a wallet is actually open — a
// vault unlock or slow boot can put that minutes after 'load'. The stash
// survives reloads, so an unfinished claim resumes on the next visit too.
// Dispatch a stashed payment intent (lightning:/bitcoin:/lnurl: from the
// Android wrapper) once a wallet is actually open — a locked vault or slow
// boot can put that well after load. handleScanned routes it exactly like a
// scanned QR: bolt11s and lnurls through the feature chain, bitcoin: through
// BIP-21 (nostr profile intents already rerouted onto the /npub deep link).
if (INTENT_URI) {
const started = Date.now();
const tick = setInterval(() => {
if (Date.now() - started > 10 * 60_000) return clearInterval(tick);
if (!activeAccount() || !wallet.loaded || ui.screen !== 'wallet') return;
clearInterval(tick);
ui.tab = 'send';
render();
handleScanned(INTENT_URI);
// A lightning:lnurl… (or ln-address) intent often lands BEFORE the
// feature that pays it is ready — ark connects lazily, so zaps'
// matchSendText declines and the raw URI falls through into the
// recipient field. Keep re-offering the text to the feature chain for a
// while; when a feature finally claims it (the zap amount screen takes
// over), sweep the leftover raw text out of the recipient.
let tries = 0;
const retry = setInterval(() => {
const s = ui.send;
const pending = s && s.recipients.length === 1 && s.recipients[0].address === INTENT_URI;
if (!pending || ++tries > 20) return clearInterval(retry);
if (featureMatchSend(INTENT_URI)) {
clearInterval(retry);
s.recipients[0].address = '';
render();
}
}, 1000);
}, 500);
}
window.addEventListener('load', () => {
if (!pendingMigratedName()) return;
const started = Date.now();
const tick = setInterval(() => {
if (!pendingMigratedName() || Date.now() - started > 10 * 60_000) return clearInterval(tick);
if (activeAccount()) { clearInterval(tick); claimMigratedName(); }
}, 1000);
});
loadLocale(getLang()).finally(async () => {
applyBootAutoLogout(); // clear an overdue session before we read it for claim targets
// Public read-only community chat: no wallet, no login — the join material
// ships in the bundle, so the page decrypts client-side (src/public-chat.js).
// A share-anywhere link; its sign-in button leads back to the normal app.
if (location.pathname === '/chat') {
_publicChatMode = true; // app renders stand down — the page owns #app
const m = await import('./public-chat.js');
m.mountPublicChat();
return;
}
// A gift link's feature is deferred — wait for it before asking who owns
// the URL, or the claim would fall through to a normal boot and be lost.
if (/^\/(g|ag|lg)\//.test(location.pathname)) await _deferredReady;
if (featureHook('bootUrl')) return; // a feature consumed the URL (e.g. a gift claim)
// The Spending balance and its history are read from an IndexedDB-backed
// store, which is synchronous to read but asynchronous to OPEN. Opening a
// wallet before it lands paints a Spending card with nothing in it, and the
// real numbers arrive a beat later — a visible jump, and the balance
// counted itself up in green as if money had just come in. The shell is on
// screen either way; waiting shows it a moment longer rather than showing a
// wrong balance. The hook caps its own wait.
// The price, early: the unit toggle only offers fiat once we've had one,
// and a wallet opening in fiat should open with a number in it.
refreshRates().then(() => { if (unit === 'fiat') render(); }).catch(() => {});
setInterval(() => { refreshRates().then(() => { if (unit === 'fiat') render(); }).catch(() => {}); }, 10 * 60_000);
const storeReady = featureHook('arkStoreReady');
if (storeReady) await storeReady;
_bootDeciding = false; // from here on the screen is a decision, not a guess
const opened = restoreAccountsState();
if (!opened) render();
if (WAKE_PAYLOAD != null) {
// Nothing opened — a locked vault, or no wallet on this device. There is
// nothing to answer with, so say so now.
if (!opened) wakeDone();
// And a backstop: a wallet that opened but has no feature interested in
// this payload must still end the job.
else setTimeout(wakeDone, 40_000);
}
});