diff options
| -rw-r--r-- | dev.js | 18 | ||||
| -rw-r--r-- | names/ownership.js | 10 | ||||
| -rw-r--r-- | names/server.js | 6 | ||||
| -rw-r--r-- | src/app.js | 43 | ||||
| -rw-r--r-- | src/features/ark.js | 86 | ||||
| -rw-r--r-- | src/features/sync.js | 47 | ||||
| -rw-r--r-- | src/recipient-search.js | 24 | ||||
| -rw-r--r-- | tools/account-access-test.js | 82 | ||||
| -rw-r--r-- | tools/post-emoji-test.js | 79 | ||||
| -rw-r--r-- | tools/profile-recovery-test.js | 86 | ||||
| -rw-r--r-- | tools/registry-ownership-test.js | 18 | ||||
| -rw-r--r-- | tools/spending-restore-test.js | 119 | ||||
| -rw-r--r-- | tools/thread-reply-test.js | 93 |
13 files changed, 641 insertions, 70 deletions
@@ -6,12 +6,13 @@ // localhost or from a phone over the LAN/Tailscale — no per-device config, no // CORS. HTTP backends by path prefix; WebSocket backends by exact path. -import { buildHtml, buildJsQr } from './build.js'; +import { buildHtml, buildJsQr, buildNip46, buildVerifyWorker } from './build.js'; const port = Number(process.env.PORT || 5173); const HTTP_PROXY = { '/boltz': 'http://localhost:9001', '/esplora': 'http://localhost:3000', '/sp': 'http://localhost:8888' }; const WS_PROXY = { '/electrum': 'ws://localhost:50003', '/sp/ws': 'ws://localhost:8888/ws' }; +const JS_ASSETS = { '/jsqr.js': buildJsQr, '/nip46.js': buildNip46, '/verify-worker.js': buildVerifyWorker }; const CORS = { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'GET,POST,OPTIONS', 'access-control-allow-headers': 'content-type' }; Bun.serve({ @@ -40,11 +41,22 @@ Bun.serve({ return new Response(await r.arrayBuffer(), { status: r.status, headers: { 'content-type': r.headers.get('content-type') || 'application/json', ...CORS } }); } } - if (path === '/jsqr.js') { - return new Response(await buildJsQr({ minify: false }), { + if (Object.hasOwn(JS_ASSETS, path)) { + return new Response(await JS_ASSETS[path]({ minify: false }), { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': NO_STORE }, }); } + if (/^\/(punks|punks-sm)\//.test(path)) { + // These are bundled images, not app routes. Returning index.html here + // produces broken avatars and rebuilds the app for every face. + if (!/^\/(punks|punks-sm)\/([1-9]|[1-5][0-9]|6[0-4])\.webp$/.test(path)) { + return new Response('Not found', { status: 404 }); + } + const file = Bun.file('static' + path); + return await file.exists() + ? new Response(file, { headers: { 'content-type': 'image/webp', 'cache-control': NO_STORE } }) + : new Response('Not found', { status: 404 }); + } const loc = path.match(/^\/locales\/([a-z]{2})\.json$/); if (loc) { const f = Bun.file('src/locales/' + loc[1] + '.json'); diff --git a/names/ownership.js b/names/ownership.js new file mode 100644 index 0000000..19ae4ef --- /dev/null +++ b/names/ownership.js @@ -0,0 +1,10 @@ +// A migration grant names the identity that owns the address. Its manager +// may submit the registration without becoming that identity. +export function registrationIdentity({ existing, signer, manager, grant, granted }) { + const pubkey = existing?.pubkey || (granted ? grant.pubkey : signer); + const nominated = /^[0-9a-f]{64}$/.test(manager || '') && manager !== pubkey; + return { + pubkey, + manager: nominated ? manager : existing?.manager || (granted ? grant.manager : undefined), + }; +} diff --git a/names/server.js b/names/server.js index ed44ad3..97ac662 100644 --- a/names/server.js +++ b/names/server.js @@ -31,6 +31,7 @@ import { decodeAddress } from '../src/ark/proto.js'; import { decodeNoffer } from '../src/noffer.js'; import { decodeBolt11 } from '../src/ark/lightning.js'; import { decodeOffer } from '../src/bolt12.js'; +import { registrationIdentity } from './ownership.js'; const CFG = JSON.parse(readFileSync(process.env.NAMES_CONFIG || join(import.meta.dir, 'config.json'), 'utf8')); @@ -1624,10 +1625,7 @@ Bun.serve({ const recordId = await cfWrite(name, domain, published, existing?.recordId); state.names[key] = { - pubkey: existing?.pubkey || auth.pubkey, - // the owner may nominate a key that manages the record from here on - manager: (claim.manager && /^[0-9a-f]{64}$/.test(claim.manager) && auth.pubkey !== claim.manager) - ? claim.manager : existing?.manager, + ...registrationIdentity({ existing, signer: auth.pubkey, manager: claim.manager, grant, granted }), uri: published, recordId, updated: Date.now(), domain, // the wallet's CLINK offer key, so LNURL can ask it for invoices offerPk: (claim.offerPk && /^[0-9a-f]{64}$/.test(claim.offerPk)) ? claim.offerPk : existing?.offerPk, @@ -756,7 +756,7 @@ window.addEventListener('popstate', (e) => { // 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 (snap.screen && snap.screen !== 'wallet' && activeAccount() && ui.screen === 'wallet') { + 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 {} @@ -1381,6 +1381,7 @@ async function activateAccount(acc, opts = {}) { // 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 {} } @@ -1481,15 +1482,19 @@ async function activateAccount(acc, opts = {}) { // 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 { - enterOfflineFallback(); + // 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(); } } @@ -2267,7 +2272,8 @@ function lock({ offerPassword = false } = {}) { function avatarMenu() { const me = (featureHook('nostrLoginIdentity') || {}).pubkey || (wallet.nostrPubkey && wallet.nostrPubkey()) || (activeAccount() || {}).nostrPk; - // No identity (watch-only wallet): a neutral face with nowhere to go. + // 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', @@ -2275,8 +2281,15 @@ function avatarMenu() { }); return h('button', { class: 'header-avatar', - title: me ? t('profYourProfile') : undefined, - onClick: me ? () => { featureHook('showProfile', me); render(); } : undefined, + 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); } @@ -2284,7 +2297,7 @@ function settingsBtn() { return h('button', { class: 'header-msgs', title: t('tabSettings'), 'aria-label': t('tabSettings'), onClick: () => { - ui.chatOpen = false; ui.profilePk = null; ui.userSearch = null; ui.noteThread = null; + clearFeatureNav(); ui.screen = 'wallet'; ui.tab = 'settings'; ui.settingsPage = null; render(); }, }, h('span', { @@ -2371,7 +2384,7 @@ function messagesBtn() { class: 'header-msgs' + (unread ? ' unread' : ''), title: t('msgDmsTitle'), 'aria-label': t('msgDmsTitle'), - onClick: () => { ui.profilePk = null; ui.userSearch = null; ui.noteThread = null; ui.zapSetup = null; ui.chatOpen = true; ui.msgView = 'home'; render(); }, + onClick: () => { clearFeatureNav(); ui.screen = 'wallet'; ui.chatOpen = true; ui.msgView = 'home'; render(); }, }, h('span', { class: 'hm-ico', html: '<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>', @@ -2386,6 +2399,7 @@ function searchBtn() { title: t('searchUsers'), 'aria-label': t('searchUsers'), onClick: () => { + clearFeatureNav(); ui.screen = 'wallet'; featureHook('openUserSearch'); render(); setTimeout(() => document.querySelector('.user-search-input')?.focus(), 60); @@ -2800,6 +2814,15 @@ function claimTargets() { // 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); @@ -2866,7 +2889,7 @@ function accountsScreen() { // 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(wallet.loaded), + brandHeader(!!activeAccount()), h('div', { class: 'card col' }, h('h3', {}, t('accounts')), h('div', { class: 'col', style: 'gap:0' }, @@ -2909,6 +2932,8 @@ function accountsScreen() { ]) ), 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')) ), @@ -3447,7 +3472,7 @@ function onboardScreen() { // 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 ? 'https://v3.coinos.io/' + punkUrl(me) : undefined); + 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. diff --git a/src/features/ark.js b/src/features/ark.js index 93578da..22ba5fd 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -144,11 +144,11 @@ export function installArkWallet(wallet) { }); } -// A slim projection of ark state for the sync snapshot — the sync relay caps -// events at 64 KB and the full state (deep-genesis vtxo bytes + done-action -// hex) blew past it, so publishes silently failed and nothing synced. Another +// A slim projection of ark state for the sync snapshot. Oversized domains +// now travel as encrypted Blossom blobs, so preserve the complete history +// and spent-coin stubs instead of rebuilding them from the mailbox. Another // device needs, per vtxo: id/amount/state (+ bytes ONLY for spendable/pending, -// which it might spend/exit); the movement log (capped) for ark history; and +// which it might spend/exit); the movement log for ark history; and // done board/offboard/exit actions (stripped of hex) so on-chain rows still // label. In-flight actions and their signing material stay device-local. export function slimArkForSync(s) { @@ -156,22 +156,20 @@ export function slimArkForSync(s) { const HEAVY = ['bytes', 'destBytes', 'changeBytes', 'vtxoBytes', 'txHex', 'fundingTxHex', 'outputVtxos']; return { v: s.v, serverPubkey: s.serverPubkey, mailboxCheckpoint: s.mailboxCheckpoint, nextKeyIndex: s.nextKeyIndex, receiveAckTs: s.receiveAckTs, - // Spent stubs and done actions are pure history and grow forever — cap - // them (newest kept) so the sync event's size is bounded for life. + // Spent stubs keep old snapshots/mailbox messages from resurrecting coins. vtxos: (() => { // On the wire the bytes stay HEX for now: a device still on a build // that only reads hex would otherwise fail to decode every coin merged // from this one. Flip to base64 once builds before 2026-09-10 are gone. const live = (s.vtxos || []).filter((v) => v.state !== 'spent') .map((v) => (v.bytes ? { ...v, bytes: vtxoBytesToHex(v.bytes) } : v)); - const stubs = (s.vtxos || []).filter((v) => v.state === 'spent').slice(-200) + const stubs = (s.vtxos || []).filter((v) => v.state === 'spent') .map((v) => ({ id: v.id, amountSat: v.amountSat, state: 'spent', keyIndex: v.keyIndex, expiryHeight: v.expiryHeight })); return [...live, ...stubs]; })(), actions: (() => { const done = (s.actions || []).filter((a) => a.step === 'done'); const core = done.filter((a) => ['board', 'offboard', 'exit'].includes(a.type)) - .slice(-50) .map((a) => { const c = { ...a }; for (const k of HEAVY) delete c[k]; return c; }); // Spends travel as SLIVERS — id, net amount, and which coins they // consumed. That last field is what lets ANOTHER device recognize a @@ -179,14 +177,13 @@ export function slimArkForSync(s) { // another device" row beside the synced Sent movement (one spend was // showing twice, at input magnitude the second time). const spends = done.filter((a) => ['send', 'ln-pay'].includes(a.type)) - .slice(-100) .map((a) => ({ id: a.id, type: a.type, step: 'done', amountSat: a.amountSat, - inputIds: (a.parts || []).map((p) => p.inputId).concat(a.inputId ? [a.inputId] : []).filter(Boolean), + inputIds: [...new Set([...(a.inputIds || []), ...(a.parts || []).map((p) => p.inputId), a.inputId].filter(Boolean))], })); return [...core, ...spends]; })(), - movements: (s.movements || []).slice(-200).map((m, i, arr) => + movements: (s.movements || []).map((m, i, arr) => // Older rows only need to render a history line; the bolt11 (~400 chars // each) and preimage are what pushed snapshots past relay size limits. (arr.length - i <= 50 ? m : (({ invoice, preimage, ...rest }) => rest)(m))), @@ -285,7 +282,12 @@ export function mergeArkStates(a, b) { // skips every message this device never processed — balance appears (the // vtxo unions in) but its receive movement and celebration never happen. // Keep the local checkpoint; re-read messages dedupe harmlessly. - out.mailboxCheckpoint = a.mailboxCheckpoint || 0; + const pristine = !a.mailboxCheckpoint && !a.vtxos?.length && !a.movements?.length + && !a.actions?.length && !a.scheduled?.length; + // A manager's empty shell is not local history. Restore the snapshot's + // cursor with its coins/history, just as the !a case above does, rather + // than replaying the entire mailbox on a first login. + out.mailboxCheckpoint = pristine ? b.mailboxCheckpoint || 0 : a.mailboxCheckpoint || 0; out.nextKeyIndex = Math.max(a.nextKeyIndex || 1, b.nextKeyIndex || 1); out.nextLnRecvIndex = Math.max(a.nextLnRecvIndex || 0, b.nextLnRecvIndex || 0); out.receiveAckTs = Math.max(a.receiveAckTs || 0, b.receiveAckTs || 0); @@ -397,6 +399,7 @@ export function arkFeature(ctx) { for (const f of [...arkPersistFlushers]) { try { f(); } catch {} } arkPersistFlushers.clear(); arkInitGen++; + lastAutoInit = 0; if (arkTimer) clearInterval(arkTimer); arkTimer = null; if (ark) ark.stopMailboxStream(); @@ -430,7 +433,6 @@ export function arkFeature(ctx) { function initArk() { stopArk(); ui.arkError = ''; - lastAutoInit = Date.now(); // The ark store is IndexedDB — asynchronous to OPEN, synchronous to read // once it has. A boot that beats it open reads no state at all: no // Spending balance, no Spending history, and nothing here that looks @@ -441,7 +443,7 @@ export function arkFeature(ctx) { store.open().then(() => { ctx.forgetAmountAnim && ctx.forgetAmountAnim(); render(); initArk(); }).catch(() => {}); return; } - if (arkAvailable() && arkWanted()) connectArk().catch(() => {}); + if (arkAvailable() && arkWanted()) { lastAutoInit = Date.now(); connectArk().catch(() => {}); } } // Automatic re-inits (a synced snapshot arriving) must not restart a @@ -453,7 +455,9 @@ export function arkFeature(ctx) { function maybeInitArk() { if (ark || arkConnectPromise) return; if (Date.now() - lastAutoInit < 20000) return; - initArk(); + // A fresh snapshot may be held under its server pubkey, so arkWanted() + // cannot see it until the handshake identifies the server and adopts it. + if (arkAvailable()) { lastAutoInit = Date.now(); connectArk().catch(() => {}); } } // Operator console rescue for a round participation stranded server-side @@ -524,10 +528,13 @@ export function arkFeature(ctx) { const tx = (wallet.txs || []).find((t2) => t2.txid === txid2); return tx && tx.confirmed ? { confirmed: true, block_height: tx.blockHeight || NaN } : null; }, - onUpdate: (m) => { maybeAutoSelectSpending(); scheduleLnTag(m); render(); }, + onUpdate: (m) => { if (gen !== arkInitGen) return; maybeAutoSelectSpending(); scheduleLnTag(m); render(); }, }); ui.arkError = ''; - arkConnectPromise = mgr.init().then(() => { + // Open the server connection while Nostr restores, but do not replay the + // mailbox before the snapshot's history and cursor have arrived. + const restored = Promise.resolve(wallet.nostrRestoreReady || wallet.syncFromNostr?.()).catch(() => false); + arkConnectPromise = Promise.all([mgr.init(), restored]).then(() => { if (gen !== arkInitGen) throw new Error('superseded'); // wallet switched mid-connect ark = mgr; // Sync may have arrived while init awaited the server handshake. @@ -556,20 +563,24 @@ export function arkFeature(ctx) { // A failed mailbox read must not starve the action drives: boards and // refreshes progress on chain state alone, so push them even when the // sync itself errored. - const tick = () => mgr.sync().catch(() => mgr.resumePending().catch(() => {})) + let ticking = null; + const tick = () => ticking || (ticking = mgr.sync().catch(async () => { + // Even when mailbox catch-up fails, independently check stale coins + // and advance recoverable local actions. + await mgr.reconcile().catch(() => {}); + await mgr.resumePending().catch(() => {}); + }) .then(() => driveExits(mgr)).catch(() => {}) .then(() => { if (ark === mgr) return maybeAutoRefresh(mgr); }).catch(() => {}) - .then(() => { if (ark === mgr) return maybeAutoWithdraw(mgr); }).catch(() => {}); - tick(); - // Reconcile once on connect: a vtxo synced in from another device (or - // one this device held while a spend happened elsewhere) is checked - // against the server, so a stale spendable is caught here rather than - // only at send time. - mgr.reconcile().catch(() => {}); + .then(() => { if (ark === mgr) return maybeAutoWithdraw(mgr); }).catch(() => {}) + .finally(() => { ticking = null; })); + // sync() already reconciles on its first pass. Start the live stream + // afterwards, at the caught-up cursor: running both together replayed + // the backlog one message/render at a time alongside the batch read. + tick().finally(() => { if (ark === mgr) mgr.startMailboxStream(); }); mergeBgWorkerState(mgr).catch(() => {}); // absorb what the SW did while we were closed // Receives arrive in real time over the mailbox stream; the poll is the // fallback and what drives in-flight boards/refreshes forward. - mgr.startMailboxStream(); scheduleLnTag(mgr); // receives that landed while this device was closed arkTimer = setInterval(() => { if (ark === mgr) tick(); }, getNetwork() === 'regtest' ? 5000 : 30000); render(); @@ -577,7 +588,7 @@ export function arkFeature(ctx) { }).catch((e) => { if (gen === arkInitGen) { ui.arkError = e.message; render(); } throw e; - }).finally(() => { arkConnectPromise = null; }); + }).finally(() => { if (gen === arkInitGen) arkConnectPromise = null; }); render(); return arkConnectPromise; } @@ -1543,22 +1554,28 @@ export function arkFeature(ctx) { // way this goes, say so, so the chip settles instead of pulsing until it // times out. (A handover to the Lightning flow doesn't report — that flow // is still carrying the same zap, and reports for itself.) - const bail = () => { + const bail = (error) => { if (!z.autoSat) return; // a form zap: the card keeps the screen and its own status ctx.hook('zapSettled', z.eventId, false); - z.autoSat = 0; ctx.showSend(); + z.autoSat = 0; + if (z.eventId) { + if (ui.arkZap === z) ui.arkZap = null; + toast('⚡ ' + (error || t('arkZapNoArk')), 4000); + return true; + } else ctx.showSend(); }; // an instant zap: resolution succeeded — pay the default amount now, no - // form, and report by toast; failures fall back to the classic form + // form. Post zaps settle in place; other payments retain their form. const auto = async (label) => { try { await performArkZap(z, z.autoSat); if (ui.arkZap === z) ui.arkZap = null; ctx.hook('zapSettled', z.eventId, true, z.autoSat); - toast('⚡ ' + t('zapSentShort', { n: fmtAmount(z.autoSat) + ' ' + unitLabel() })); + if (!z.eventId) toast('⚡ ' + t('zapSentShort', { n: fmtAmount(z.autoSat) + ' ' + unitLabel() })); render(); } catch (e) { - bail(); ui.sendError = e.message; render(); + if (!bail(e.message)) ui.sendError = e.message; + render(); } }; (async () => { @@ -1567,7 +1584,7 @@ export function arkFeature(ctx) { const adv = await lookupArkZapTarget(pk).catch(() => ({ status: 'noark' })); if (!live()) return; if (adv.status === 'ready') { Object.assign(z, adv); if (z.autoSat) return auto(); render(); return; } - if (adv.status === 'wrongnet') { Object.assign(z, adv); bail(); render(); return; } + if (adv.status === 'wrongnet') { Object.assign(z, adv); bail(t('arkGiftWrongNet', { net: adv.net })); render(); return; } // 1b. BIP-353 with an ark instruction; remember on-chain as last resort const uris = []; try { @@ -1605,6 +1622,7 @@ export function arkFeature(ctx) { } // 3. on-chain fallback if (onchain) { + if (z.autoSat && z.eventId) { bail(); render(); return; } ui.arkZap = null; ui.send.recipients[0].address = onchain; bail(); @@ -1612,7 +1630,7 @@ export function arkFeature(ctx) { return; } if (live()) { z.status = 'noark'; bail(); render(); } - })().catch((e) => { if (live()) { z.status = 'noark'; bail(); ui.sendError = e.message; render(); } }); + })().catch((e) => { if (live()) { z.status = 'noark'; if (!bail(e.message)) ui.sendError = e.message; render(); } }); } // shared by the ark and lightning zap flows (see zaps.js for the twin) diff --git a/src/features/sync.js b/src/features/sync.js index 415717f..ed22322 100644 --- a/src/features/sync.js +++ b/src/features/sync.js @@ -247,8 +247,25 @@ export function installSyncWallet(wallet, { outbox = syncOutbox() } = {}) { }, }); + // Account activation, Spending connect and the live subscription all need + // the same initial restore. Share it so history is merged newest-first + // before either live source starts delivering an old backlog piecemeal. + const restore = wallet.syncFromNostr.bind(wallet); + let restoring = null; + wallet.syncFromNostr = () => { + const pk = wallet.nostr.pk, net = wallet.netName; + if (restoring && restoring.pk === pk && restoring.net === net) return restoring.promise; + const run = { pk, net, promise: null }; + run.promise = restore().finally(() => { if (restoring === run) restoring = null; }); + restoring = run; + wallet.nostrRestoreReady ||= run.promise; + return run.promise; + }; + // identity follows the open wallet wallet.registerLoadHook(() => { + restoring = null; + wallet.nostrRestoreReady = null; if (wallet.mnemonic) wallet.nostr.load(wallet.mnemonic, wallet.passphrase, wallet.accountIndex || 0); else wallet.nostr.unload(); outbox?.wake(); @@ -305,24 +322,32 @@ export function installSyncWallet(wallet, { outbox = syncOutbox() } = {}) { // vtxos union) applies live; full snapshots stay a load-time affair. With // per-device slots no device overwrites another's, so this only ever adds. let stateUnsub = null; + let stateGeneration = 0; wallet.registerRealtimeHook({ start: () => { const sync = getSyncConfig(); if (!sync.enabled || !wallet.nostr.pk) return; wallet.nostr.setRelays(sync.relays); if (stateUnsub) { try { stateUnsub(); } catch {} } - stateUnsub = wallet.nostr.subscribeStates((v, dtag) => { - if (!v.netName || v.netName !== wallet.netName) return; - if (!isOurDtag(dtag, wallet.netName)) return; - if (isOwnDeviceDtag(dtag, wallet.netName) && (v.savedAt || 0) === (wallet._savedAt || 0)) return; // our own echo - if (!v.blob) { wallet._mergeSnapshotExtensions(v); return; } - const pk = wallet.nostr.pk; - resolveStates([{ state: v, dtag }]).then((r) => { - if (r[0] && wallet.nostr.pk === pk && wallet.netName === r[0].state.netName) wallet._mergeSnapshotExtensions(r[0].state); - }).catch(() => {}); - }); + stateUnsub = null; + const generation = ++stateGeneration; + const identity = wallet.nostr.pk, network = wallet.netName; + Promise.resolve(wallet.nostrRestoreReady || wallet.syncFromNostr()).then(() => { + if (generation !== stateGeneration || wallet.nostr.pk !== identity || wallet.netName !== network) return; + stateUnsub = wallet.nostr.subscribeStates((v, dtag) => { + if (generation !== stateGeneration || wallet.nostr.pk !== identity) return; + if (!v.netName || v.netName !== wallet.netName) return; + if (!isOurDtag(dtag, wallet.netName)) return; + if (isOwnDeviceDtag(dtag, wallet.netName) && (v.savedAt || 0) === (wallet._savedAt || 0)) return; // our own echo + if (!v.blob) { wallet._mergeSnapshotExtensions(v); return; } + const pk = wallet.nostr.pk; + resolveStates([{ state: v, dtag }]).then((r) => { + if (generation === stateGeneration && r[0] && wallet.nostr.pk === pk && wallet.netName === r[0].state.netName) wallet._mergeSnapshotExtensions(r[0].state); + }).catch(() => {}); + }); + }).catch(() => {}); }, - stop: () => { if (stateUnsub) { try { stateUnsub(); } catch {} stateUnsub = null; } }, + stop: () => { stateGeneration++; if (stateUnsub) { try { stateUnsub(); } catch {} stateUnsub = null; } }, }); // Capture and sign changed domains NOW, under the saving wallet's identity. diff --git a/src/recipient-search.js b/src/recipient-search.js index be3c70e..e0339ca 100644 --- a/src/recipient-search.js +++ b/src/recipient-search.js @@ -16,23 +16,28 @@ import { queryOn, parseNostrPubkey, npubOf, PROFILE_RELAYS } from './nostr.js'; // Self-hosted (dist/punks) — coinos.io's own copies 502 for a third of the // set. Same deterministic formula the coinos app uses. const punkN = (pk) => Math.floor((parseInt(pk.slice(-2), 16) / 256) * 64) + 1; -export const punkUrl = (pk) => `punks/${punkN(pk)}.webp`; +export const punkImageUrl = (n, small = false) => + `${typeof location !== 'undefined' && location.protocol === 'file:' ? '' : '/'}punks${small ? '-sm' : ''}/${n}.webp`; +export const punkUrl = (pk) => punkImageUrl(punkN(pk)); // The art is 240px, which is what a punk published as someone's nostr // picture should be — and eight times what a 30px circle needs. We ship a // 128px copy of each (a fifth of the bytes, 20KB → 4KB) and paint the circles // from that, so a brand-new wallet with nothing cached fills its faces in // straight away. Only the 64px profile avatar takes the full-size one. -export const punkSmallUrl = (pk) => `punks-sm/${punkN(pk)}.webp`; -export const punkSmallN = (n) => `punks-sm/${n}.webp`; +export const punkSmallUrl = (pk) => punkImageUrl(punkN(pk), true); +export const punkSmallN = (n) => punkImageUrl(n, true); -export const fallbackAvatar = (h, pk, name, cls) => - h('div', { class: cls + ' fallback' }, - h('img', { +const failedPunks = new Map(); +export const fallbackAvatar = (h, pk, name, cls) => { + const src = cls.includes('profile-avatar') ? punkUrl(pk) : punkSmallUrl(pk); + return h('div', { class: cls + ' fallback' }, + Date.now() - (failedPunks.get(src) || 0) < 60_000 ? null : h('img', { class: 'punk', alt: '', - src: cls.includes('profile-avatar') ? punkUrl(pk) : punkSmallUrl(pk), - onError: (e) => { e.target.style.display = 'none'; }, + src, + onError: (e) => { failedPunks.set(src, Date.now()); e.target.remove(); }, }), (name || npubOf(pk) || '??').slice(0, 2)); +}; const REGISTRAR = 'https://names.coinos.io'; const SEARCH_RELAYS = ['wss://search.nos.today', 'wss://relay.nostr.band']; @@ -103,7 +108,8 @@ export function warmSearch() { // Caches survive reloads: a name searched yesterday paints instantly today. // Profiles cap at 200 by recency; whole-query results keep for 12 hours. const PROF_KEY = 'btc-wallet-search-profiles'; -const QUERY_KEY = 'btc-wallet-search-queries'; +// Discard old results that identified migrated accounts by their wallet key. +const QUERY_KEY = 'btc-wallet-search-queries-v2'; const QUERY_TTL = 12 * 3600_000; const profileCache = new Map(); // pk -> { name, picture } const queryCache = new Map(); // q -> rows diff --git a/tools/account-access-test.js b/tools/account-access-test.js new file mode 100644 index 0000000..ad6ce3f --- /dev/null +++ b/tools/account-access-test.js @@ -0,0 +1,82 @@ +// A watch-only account without remembered Nostr metadata must still offer +// account switching and logout. Uses an unfunded, generated public key only. +// Run: bun tools/account-access-test.js +import assert from 'node:assert/strict'; +import puppeteer from 'puppeteer-core'; +import { generateMnemonic } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english'; +import { accountXpubFor, cacheKeyFor } from '../src/wallet.js'; +import { buildHtml } from '../build.js'; + +const xpub = accountXpubFor({ mnemonic: generateMnemonic(wordlist) }); +const html = await buildHtml({ minify: false, pwa: false }); +const server = Bun.serve({ port: 0, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] }); +const page = await browser.newPage(); +const errors = []; +page.on('pageerror', (e) => errors.push(e.stack)); +await page.setRequestInterception(true); +page.on('request', (r) => r.url().startsWith(server.url.origin) ? r.continue() : r.abort()); +const check = (name, value) => { assert(value, name); console.log(' ✓ ' + name); }; +const click = (text) => page.evaluate((label) => { + const button = [...document.querySelectorAll('button')].find((b) => b.textContent.trim() === label); + if (!button) throw new Error('Missing button: ' + label); + button.click(); +}, text); +try { + await page.goto(server.url.href); + await page.evaluate((xpub) => { + localStorage.setItem('btc-wallet-watch', JSON.stringify([{ id: 'test-watch', label: 'Saved wallet', xpub }])); + }, xpub); + await page.reload(); + await page.waitForSelector('.header-avatar'); + check('fresh local account defaults to mainnet', await page.evaluate(() => JSON.parse(localStorage.getItem('btc-wallet-watch'))[0].network === 'mainnet')); + check('anonymous avatar has an accessible Accounts label', await page.$eval('.header-avatar', (e) => e.getAttribute('aria-label') === 'Accounts')); + await page.click('.header-avatar'); + check('avatar opens Accounts with sign-in and logout', await page.evaluate(() => ['Accounts', 'Sign into another account', 'Log out'].every((s) => document.body.innerText.includes(s)))); + const saved = await page.evaluate(() => localStorage.getItem('btc-wallet-watch')); + await click('Sign into another account'); + // Expand the Nostr login card without supplying a private key. + await click('Sign in with Nostr'); + await page.waitForSelector('input[placeholder="nsec, private key, or bunker://…"]'); + check('Nostr key sign-in is reachable without deleting the old wallet', await page.evaluate((saved) => localStorage.getItem('btc-wallet-watch') === saved, saved)); + await click('← Back'); + await page.waitForSelector('.header-avatar'); + await page.click('.header-avatar'); + await click('Log out'); + check('logout asks before ending the session', await page.evaluate(() => document.body.innerText.includes('Your accounts stay saved on this device.'))); + await click('Log out'); + check('logout reaches the sign-in screen and retains the saved wallet', await page.evaluate((saved) => !document.querySelector('.header-avatar') && document.body.innerText.includes('Sign in with Nostr') && localStorage.getItem('btc-wallet-watch') === saved, saved)); + // Remember a public login identity on the watch-only account so every + // header destination is available, without supplying a private key. + await page.evaluate(({ key }) => { + localStorage.setItem(key + ':nostrlogin', JSON.stringify({ pubkey: 'a'.repeat(64), type: 'extension' })); + history.replaceState(null, ''); + }, { key: cacheKeyFor(xpub) }); + await page.reload(); + await page.waitForSelector('[aria-label="Messages"]'); + await click('Accounts'); + const onAccounts = () => page.evaluate(() => [...document.querySelectorAll('h3')].some(e => e.textContent === 'Accounts')); + for (const label of ['Search', 'Messages', 'Your profile', 'Settings']) { + check(label + ' starts on Accounts', await onAccounts()); + await page.click('[aria-label="' + label + '"]'); + check(label + ' opens from Accounts', await page.evaluate(label => { + const nav = history.state.nav; + return nav.screen === 'wallet' && ![...document.querySelectorAll('h3')].some(e => e.textContent === 'Accounts') + && (label === 'Search' ? !!document.querySelector('.user-search-input') + : label === 'Messages' ? nav.chatOpen && nav.msgView === 'home' + : label === 'Your profile' ? !!document.querySelector('.npub-box') && nav.profilePk === 'a'.repeat(64) + : nav.tab === 'settings'); + }, label)); + await page.goBack(); + await page.waitForFunction(() => [...document.querySelectorAll('h3')].some(e => e.textContent === 'Accounts')); + check('Back returns from ' + label + ' to Accounts', await onAccounts()); + } + await page.click('[aria-label="Lock wallet"]'); + check('Lock opens its password prompt from Accounts', await page.evaluate(() => !!document.querySelector('input[type="password"]'))); + await click('Not now'); + await page.click('.brand'); + check('the logo returns to the wallet', await page.evaluate(() => history.state.nav.screen === 'wallet' && ![...document.querySelectorAll('h3')].some(e => e.textContent === 'Accounts'))); + assert.deepEqual(errors, []); + check('no browser errors', true); +} finally { await browser.close(); server.stop(true); } diff --git a/tools/post-emoji-test.js b/tools/post-emoji-test.js new file mode 100644 index 0000000..6c7a9aa --- /dev/null +++ b/tools/post-emoji-test.js @@ -0,0 +1,79 @@ +// Exercise public reaction ingestion, rendering, signing and withdrawal. +// No requests or events leave this local fixture. Run: bun tools/post-emoji-test.js +import assert from 'node:assert/strict'; +import puppeteer from 'puppeteer-core'; +const app = await Bun.file('src/app.js').text(); +const dom = app.slice(app.indexOf('function h(tag,'), app.indexOf('// ---------------------------------------------------------------- morphing')); +const entry = ` +import { messagesFeature } from './src/features/messages.js'; +${dom} +const mine = 'a'.repeat(64), author = 'b'.repeat(64); +const note = { id: 'c'.repeat(64), pubkey: author, kind: 1, tags: [], content: 'A post' }; +const ui = { screen: 'wallet' }, data = {}; +window.sent = []; +let seq = 0, feature; +const signer = { signEvent: async e => ({ ...e, pubkey: mine, id: 'signed-' + ++seq }) }; +const wallet = { loaded: true, loadFeatureState: (k, d) => data[k] || d, saveFeatureState: (k, v) => data[k] = v }; +function render() { + if (!feature) return; + document.querySelector('#panel').replaceChildren(feature.panel(note)); + document.querySelector('#actions').replaceChildren(feature.actions(author, note, { canZap: false })); +} +feature = messagesFeature({ h, wallet, ui, render, toast() {}, brandHeader: () => null, + hook: k => k === 'nostrLoginIdentity' ? { pubkey: mine, signer } : null }); +window.test = { + add: (content, code, url, pk = author) => { + const ev = { id: 'received-' + ++seq, pubkey: pk, kind: 7, content, + tags: [['e', note.id], ...(code ? [['emoji', code, url]] : [])] }; + feature.ingest(ev); render(); return ev; + }, + duplicate: ev => { feature.ingest(ev); render(); }, + send: async emoji => { await feature.send(note, emoji); render(); }, + remove: async () => { await feature.remove(note); render(); }, + count: () => feature.count(note.id), mine, +}; +`; +const bundle = await Bun.build({ entrypoints: ['post-emoji-entry'], target: 'browser', plugins: [{ name: 'post-emoji', setup(build) { + build.onResolve({ filter: /^post-emoji-entry$/ }, () => ({ path: 'entry', namespace: 'post-emoji' })); + build.onLoad({ filter: /.*/, namespace: 'post-emoji' }, () => ({ contents: entry, loader: 'js', resolveDir: process.cwd() })); + build.onLoad({ filter: /src\/features\/messages\.js$/ }, async ({ path }) => ({ loader: 'js', contents: (await Bun.file(path).text()) + .replace("id: 'messages',", "id: 'messages', ingest: noteEvent, panel: whoPanel, actions: noteActions, send: reactTo, remove: unreact, count: whoCount,") })); + build.onLoad({ filter: /src\/nostr\.js$/ }, async ({ path }) => ({ loader: 'js', contents: (await Bun.file(path).text()) + .replace('export async function queryOn(relays, filter, maxWait = 1500) {', 'export async function queryOn(relays, filter, maxWait = 1500) { return [];') + .replace('export async function publishOn(relays, evt) {', 'export async function publishOn(relays, evt) { window.sent.push(evt); return true;') })); +} }] }); +assert(bundle.success, bundle.logs.join('\n')); +const html = `<!doctype html><meta charset="utf-8"><style>${await Bun.file('src/style.css').text()}</style><div id="panel"></div><div id="actions"></div><script type="module">${await bundle.outputs[0].text()}</script>`; +const gif = Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64'); +const server = Bun.serve({ port: 0, fetch: req => new URL(req.url).pathname.endsWith('.gif') + ? new Response(gif, { headers: { 'content-type': 'image/gif' } }) + : new Response(html, { headers: { 'content-type': 'text/html' } }) }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] }); +const page = await browser.newPage(); +const errors = []; +page.on('pageerror', e => errors.push(e.message)); +await page.setRequestInterception(true); +page.on('request', r => r.url().startsWith(server.url.origin) ? r.continue() : r.abort()); +const check = (name, value) => { assert(value, name); console.log('✓ ' + name); }; +try { + await page.goto(server.url.href); + await page.waitForFunction(() => !!window.test); + await page.evaluate(() => { window.first = test.add(':catJAM:', 'catJAM', location.origin + '/cat.gif'); }); + await page.waitForFunction(() => document.querySelector('#panel .cemoji')?.naturalWidth > 0); + check('tagged catJAM renders a decodable image with its shortcode as alt text', await page.$eval('#panel .cemoji', e => e.alt === ':catJAM:' && e.src.endsWith('/cat.gif'))); + await page.evaluate(() => test.duplicate(first)); + check('duplicate events do not inflate the tally', await page.evaluate(() => test.count() === 1)); + await page.evaluate(() => test.add(':catJAM:', 'catJAM', location.origin + '/different.gif', 'd'.repeat(64))); + check('identical shortcodes with different images stay separate', await page.evaluate(() => [...document.querySelectorAll('#panel .cemoji')].map(e => e.src).join('|') === location.origin + '/cat.gif|' + location.origin + '/different.gif')); + await page.evaluate(() => test.add(':a_very_long_custom_emoji:', 'a_very_long_custom_emoji', location.origin + '/long.gif')); + check('long shortcodes are preserved', await page.evaluate(() => !!document.querySelector('img[alt=":a_very_long_custom_emoji:"]'))); + await page.evaluate(() => { test.add(':missing:'); test.add(':unsafe:', 'unsafe', 'javascript:alert(1)'); test.add('+'); test.add('🔥'); }); + check('missing or unsafe tags fall back to text and standard reactions still render', await page.evaluate(() => [':missing:', ':unsafe:', '❤️', '🔥'].every(s => document.querySelector('#panel').innerText.includes(s)) && !document.querySelector('img[src^="javascript:"]'))); + await page.evaluate(() => test.send(':catJAM:')); + check('outgoing custom reactions carry their image tag', await page.evaluate(() => sent.at(-1).tags.some(t => t[0] === 'emoji' && t[1] === 'catJAM' && t[2] === location.origin + '/different.gif'))); + check('our custom reaction renders on the action button', await page.evaluate(() => document.querySelector('#actions .cemoji')?.getAttribute('alt') === ':catJAM:')); + await page.evaluate(() => test.remove()); + check('withdrawing it preserves other reactions and publishes deletion', await page.evaluate(() => !document.querySelector('#actions .cemoji') && sent.at(-1).kind === 5 && test.count() === 7)); + assert.deepEqual(errors, []); + console.log('✓ no browser errors'); +} finally { await browser.close(); server.stop(true); } diff --git a/tools/profile-recovery-test.js b/tools/profile-recovery-test.js new file mode 100644 index 0000000..b2c871a --- /dev/null +++ b/tools/profile-recovery-test.js @@ -0,0 +1,86 @@ +// Profile misses must preserve cached details, fall back to index relays, +// and remain retryable. Tests real rendering with deterministic relay replies. +// Run: bun tools/profile-recovery-test.js +import assert from 'node:assert/strict'; +import puppeteer from 'puppeteer-core'; +const app = await Bun.file('src/app.js').text(); +const dom = app.slice(app.indexOf('function h(tag,'), app.indexOf('// ---------------------------------------------------------------- morphing')); +const entry = ` +import { messagesFeature } from './src/features/messages.js'; +import { fallbackAvatar, punkUrl, punkSmallUrl } from './src/recipient-search.js'; +${dom} +const pk = 'a'.repeat(64), other = 'b'.repeat(64); +const cached = { name: 'Cached person', picture: 'https://example.invalid/photo.jpg', about: 'Cached bio' }; +const data = { profiles: { [pk]: { ...cached, t: Date.now(), eventAt: 1 } }, profPages: { full: { [pk]: { t: Date.now(), v: cached } } } }; +const ui = { screen: 'wallet', profilePk: pk }; +let feature, mode = 'empty', release; +const asks = []; +const event = (author, name, created_at = 2) => ({ pubkey: author, kind: 0, created_at, content: JSON.stringify({ name, about: name + ' bio', picture: '' }), tags: [] }); +window.__profileQuery = async (relays, filter) => { + asks.push({ relays, kinds: filter.kinds }); + if (filter.kinds[0] !== 0) return []; + if (mode === 'hold') return new Promise(r => { release = r; }); + if (mode === 'index' && relays.includes('wss://purplepag.es')) return [event(filter.authors[0], 'Recovered person')]; + return []; +}; +const wallet = { mnemonic: 'test', loadFeatureState: (key, fallback) => data[key] || fallback, + saveFeatureState: (key, value) => data[key] = value, nostr: null }; +function render() { + if (feature) document.querySelector('#profile').replaceChildren(feature.testView(pk)); +} +feature = messagesFeature({ h, ui, wallet, render, hook: () => null, brandHeader: () => null, toast() {} }); +window.test = { + pk, other, asks, data, feature, event, render, + fetch: (author = pk) => feature.testFetch(author), + show: author => { ui.profilePk = author; render(); }, + mode: value => mode = value, + release: () => { mode = 'empty'; release([]); }, + avatar: () => { document.querySelector('#avatar').replaceChildren(fallbackAvatar(h, pk, 'Person', 'chat-avatar profile-avatar')); }, + urls: () => [punkUrl(pk), punkSmallUrl(pk)], +}; +render(); +`; +const bundle = await Bun.build({ entrypoints: ['profile-test-entry'], target: 'browser', plugins: [{ name: 'profile-test', setup(build) { + build.onResolve({ filter: /^profile-test-entry$/ }, () => ({ path: 'entry', namespace: 'profile-test' })); + build.onLoad({ filter: /.*/, namespace: 'profile-test' }, () => ({ contents: entry, loader: 'js', resolveDir: process.cwd() })); + build.onLoad({ filter: /src\/features\/messages\.js$/ }, async ({ path }) => ({ contents: (await Bun.file(path).text()) + .replace("id: 'messages',", "id: 'messages', testFetch: fetchFullProfile, testView: profileScreen, testApply: applyProfile, testLight: (pk) => profiles.get(pk), testFull: (pk) => fullProfiles.get(pk),"), loader: 'js' })); + build.onLoad({ filter: /src\/nostr\.js$/ }, async ({ path }) => ({ contents: (await Bun.file(path).text()) + .replace('export async function queryOn(relays, filter, maxWait = 1500) {', 'export async function queryOn(relays, filter, maxWait = 1500) { if (globalThis.__profileQuery) return globalThis.__profileQuery(relays, filter);'), loader: 'js' })); +} }] }); +assert(bundle.success, bundle.logs.join('\n')); +const css = await Bun.file('src/style.css').text(); +const html = `<!doctype html><meta charset="utf-8"><style>${css}</style><div id="profile"></div><div id="avatar"></div><script type="module">${await bundle.outputs[0].text()}</script>`; +const server = Bun.serve({ port: 0, fetch: req => new URL(req.url).pathname.startsWith('/punks') + ? new Response('missing', { status: 404 }) : new Response(html, { headers: { 'content-type': 'text/html' } }) }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] }); +const page = await browser.newPage(); +const errors = []; +page.on('pageerror', e => errors.push(e.message)); +await page.setRequestInterception(true); +page.on('request', r => r.url().startsWith(server.url.origin) ? r.continue() : r.abort()); +const check = (name, value) => { assert(value, name); console.log(' ✓ ' + name); }; +try { + await page.goto(server.url.href + 'someone/'); + await page.waitForFunction(() => !!window.test); + await page.evaluate(() => test.fetch()); + check('an empty fetch preserves cached name, photo and bio', await page.evaluate(() => test.feature.testLight(test.pk).name === 'Cached person' && test.feature.testFull(test.pk).about === 'Cached bio' && test.data.profiles[test.pk].picture === 'https://example.invalid/photo.jpg')); + check('a miss offers Retry instead of becoming permanent', await page.evaluate(() => document.body.innerText.includes('Couldn’t load the latest profile.') && [...document.querySelectorAll('button')].some(b => b.textContent === 'Retry'))); + await page.evaluate(() => { test.mode('index'); [...document.querySelectorAll('button')].find(b => b.textContent === 'Retry').click(); }); + await page.waitForFunction(() => document.body.innerText.includes('Recovered person bio')); + check('retry recovers a profile from an index relay', await page.evaluate(() => test.feature.testLight(test.pk).name === 'Recovered person' && !document.body.innerText.includes('Couldn’t load the latest profile.'))); + await page.evaluate(() => { test.mode('hold'); window.pending = test.fetch(test.other); test.feature.testApply(test.other, test.event(test.other, 'Arrived in batch', 10)); test.release(); }); + await page.evaluate(() => window.pending); + check('a slow empty page fetch cannot erase a successful batch answer', await page.evaluate(() => test.feature.testLight(test.other).name === 'Arrived in batch' && test.feature.testFull(test.other).name === 'Arrived in batch')); + await page.evaluate(() => test.feature.testApply(test.other, test.event(test.other, 'Old answer', 5))); + check('an older relay response cannot overwrite newer metadata', await page.evaluate(() => test.feature.testLight(test.other).name === 'Arrived in batch')); + await page.evaluate(async () => { const missing = 'c'.repeat(64); test.mode('empty'); test.show(missing); await test.fetch(missing); }); + check('a key without cached or published details shows a neutral missing-profile message and Retry', await page.evaluate(() => document.body.innerText.includes('No profile details found on the available relays.') && !document.body.innerText.includes('Couldn’t load the latest profile.') && [...document.querySelectorAll('button')].some(b => b.textContent === 'Retry'))); + check('avatar paths are rooted even on a trailing-slash profile URL', await page.evaluate(() => test.urls().every(url => url.startsWith('/punks')))); + await page.evaluate(() => test.avatar()); + await page.waitForFunction(() => !document.querySelector('#avatar img')); + await page.evaluate(() => test.avatar()); + check('failed fallback images stay removed across redraws', await page.evaluate(() => !document.querySelector('#avatar img') && document.querySelector('#avatar').textContent === 'Pe')); + assert.deepEqual(errors, []); + console.log(' ✓ no browser errors'); +} finally { await browser.close(); server.stop(true); } diff --git a/tools/registry-ownership-test.js b/tools/registry-ownership-test.js new file mode 100644 index 0000000..43683a1 --- /dev/null +++ b/tools/registry-ownership-test.js @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict'; +import { registrationIdentity } from '../names/ownership.js'; + +const owner = 'a'.repeat(64), wallet = 'b'.repeat(64), stranger = 'c'.repeat(64); +const grant = { pubkey: owner, manager: wallet }; +for (const signer of [owner, wallet]) { + assert.deepEqual(registrationIdentity({ signer, grant, granted: true }), { pubkey: owner, manager: wallet }); +} +console.log('✓ owner and manager registrations both preserve the migration identity'); +assert.deepEqual(registrationIdentity({ signer: wallet, manager: wallet, grant, granted: true }), { pubkey: owner, manager: wallet }); +console.log('✓ the signing wallet can remain the nominated manager'); +const existing = { pubkey: owner, manager: wallet }; +assert.deepEqual(registrationIdentity({ existing, signer: wallet, grant: { pubkey: stranger }, granted: false }), existing); +console.log('✓ later wallet updates preserve existing ownership and management'); +assert.deepEqual(registrationIdentity({ signer: wallet, grant, granted: false }), { pubkey: wallet, manager: undefined }); +assert.deepEqual(registrationIdentity({ signer: owner, manager: wallet }), existing); +assert.deepEqual(registrationIdentity({ signer: owner, manager: owner }), { pubkey: owner, manager: undefined }); +console.log('✓ ordinary claims retain their signer and ignore unauthorized grants'); diff --git a/tools/spending-restore-test.js b/tools/spending-restore-test.js new file mode 100644 index 0000000..34cdd59 --- /dev/null +++ b/tools/spending-restore-test.js @@ -0,0 +1,119 @@ +// Snapshot-first Spending restore, without real relays, keys, or payments. +// Run: bun tools/spending-restore-test.js +import assert from 'node:assert/strict'; +import { slimArkForSync, mergeArkStates, arkFeature } from '../src/features/ark.js'; +import { installSyncWallet } from '../src/features/sync.js'; +import { ArkManager } from '../src/ark/manager.js'; + +class Storage { + data = new Map(); + get length() { return this.data.size; } + key(i) { return [...this.data.keys()][i] ?? null; } + getItem(k) { return this.data.get(k) ?? null; } + setItem(k, v) { this.data.set(k, String(v)); } + removeItem(k) { this.data.delete(k); } +} +globalThis.localStorage = new Storage(); +globalThis.fetch = async () => { throw new Error('Network disabled in restore test'); }; +const serverPubkey = '02' + 'ab'.repeat(32); +const empty = () => ({ v: 1, serverPubkey, mailboxCheckpoint: 0, vtxos: [], actions: [], movements: [], scheduled: [] }); +const full = { + ...empty(), mailboxCheckpoint: 123456, nextKeyIndex: 401, + vtxos: Array.from({ length: 400 }, (_, i) => ({ id: 'coin-' + i, state: 'spent', amountSat: 100, keyIndex: 0 })), + movements: Array.from({ length: 400 }, (_, i) => ({ id: 'move-' + i, type: 'receive', status: 'complete', vtxoId: 'coin-' + i, amountSat: 100, ts: 1700000000000 + i })), + actions: Array.from({ length: 120 }, (_, i) => ({ id: 'send-' + i, type: 'send', step: 'done', amountSat: 50, inputIds: ['coin-' + i] })), +}; +const snapshot = slimArkForSync(full); +assert.equal(snapshot.movements.length, 400); +assert.equal(snapshot.vtxos.length, 400); +assert.equal(snapshot.actions.length, 120); +assert.deepEqual(slimArkForSync(snapshot).actions[0].inputIds, ['coin-0']); +assert.equal(mergeArkStates(empty(), snapshot).mailboxCheckpoint, 123456); +const local = { ...empty(), mailboxCheckpoint: 10, movements: [{ id: 'local' }] }; +assert.equal(mergeArkStates(local, snapshot).mailboxCheckpoint, 10); +assert.equal(mergeArkStates({ ...empty(), actions: [{ id: 'pending', step: 'signing' }] }, snapshot).mailboxCheckpoint, 0); +const older = { ...empty(), vtxos: [{ id: 'coin-0', amountSat: 100, state: 'spendable' }] }; +assert.equal(mergeArkStates(snapshot, older).vtxos.find(v => v.id === 'coin-0').state, 'spent'); +console.log('✓ complete history, spent stubs and spend provenance survive repeated snapshotting'); +console.log('✓ fresh restores inherit the cursor; existing or unfinished local work does not skip messages'); + +const hooks = { realtime: [], load: [] }; +const wallet = { + offline: false, netName: 'mainnet', txs: [], _savedAt: 0, + registerCacheSavedHook() {}, registerLoadHook: f => hooks.load.push(f), + registerRealtimeHook: h => hooks.realtime.push(h), + _mergeSnapshotExtensions(s) { if (s.arkState) this.arkState = mergeArkStates(this.arkState, s.arkState); }, + _applySnapshot() {}, emit() {}, saveCache() {}, +}; +installSyncWallet(wallet, { outbox: null }); +wallet.nostr.load('abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'); +let fetched = 0, subscribed = 0, finish; +wallet.nostr.fetchAllStates = () => { fetched++; return new Promise(r => { finish = r; }); }; +wallet.nostr.subscribeStates = () => { subscribed++; return () => {}; }; +hooks.realtime[0].start(); +const restore = wallet.syncFromNostr(); +assert.equal(fetched, 1); +assert.equal(subscribed, 0); +finish([ + { created_at: 2, dtag: 'bitcoin-wallet:x:one:ark', state: { netName: 'mainnet', arkState: snapshot } }, + { created_at: 1, dtag: 'bitcoin-wallet:x:two:ark', state: { netName: 'mainnet', arkState: older } }, +]); +assert.equal(await restore, true); +await Promise.resolve(); +assert.equal(subscribed, 1); +assert.equal(wallet.arkState.movements.length, 400); +assert.equal(wallet.arkState.vtxos[0].state, 'spent'); +console.log('✓ initial fetch is shared and sorted before live snapshot delivery begins'); +hooks.realtime[0].stop(); +wallet.nostrRestoreReady = null; +hooks.realtime[0].start(); +hooks.realtime[0].stop(); +const stopped = wallet.nostrRestoreReady; +finish([]); await stopped; await Promise.resolve(); +assert.equal(subscribed, 1); +console.log('✓ logout during restore does not restart the old identity subscription'); + +// Real Ark feature lifecycle, with only the manager's network operations +// replaced. Exercise the first-login case where state is held until the ASP +// handshake establishes which server owns it. +const original = {}; +for (const name of ['init', 'sync', 'startMailboxStream', 'stopMailboxStream']) original[name] = ArkManager.prototype[name]; +let syncs = 0, streams = 0, releaseRestore, releaseCatchup, manager; +const restored = new Promise(r => { releaseRestore = r; }); +const catchup = new Promise(r => { releaseCatchup = r; }); +ArkManager.prototype.init = async function () { + manager = this; + this.info = { serverPubkey }; + this.state = this.storage.adopt(serverPubkey) || this.storage.load() || empty(); + return this; +}; +ArkManager.prototype.sync = async function () { syncs++; assert.equal(this.state.mailboxCheckpoint, 123456); await catchup; }; +ArkManager.prototype.startMailboxStream = function () { streams++; }; +ArkManager.prototype.stopMailboxStream = function () {}; +let extension; +const arkWallet = { + _cacheKey: () => 'fresh-spending-test', account: () => ({}), + registerCacheExtension: e => { extension = e; }, + nostrRestoreReady: restored, saveCache() {}, loadFeatureState: () => ({}), +}; +const feature = arkFeature({ wallet: arkWallet, ui: {}, render() {}, hook() {}, getAccount: () => 'spending' }); +const pause = () => new Promise(r => setTimeout(r, 20)); +try { + feature.init(); // no local Spending state yet + extension.load({ arkState: snapshot }); + await pause(); + assert.ok(manager, 'held snapshot starts a connection immediately, without the old 20-second throttle'); + assert.equal(syncs, 0); + assert.equal(streams, 0); + assert.equal(manager.state.movements.length, 400); + releaseRestore(true); await pause(); + assert.equal(syncs, 1); + assert.equal(streams, 0); + releaseCatchup(); await pause(); + assert.equal(streams, 1); + console.log('✓ fresh Spending adopts the whole snapshot before catch-up, then starts its live stream'); +} finally { + feature.stop(); + for (const [name, fn] of Object.entries(original)) ArkManager.prototype[name] = fn; +} +process.exit(0); diff --git a/tools/thread-reply-test.js b/tools/thread-reply-test.js new file mode 100644 index 0000000..c125ef8 --- /dev/null +++ b/tools/thread-reply-test.js @@ -0,0 +1,93 @@ +// Real reply buttons/composer with controlled relay replies and no publishing. +// Run: bun tools/thread-reply-test.js +import assert from 'node:assert/strict'; +import puppeteer from 'puppeteer-core'; +const app = await Bun.file('src/app.js').text(); +const dom = app.slice(app.indexOf('function h(tag,'), app.indexOf('// ---------------------------------------------------------------- morphing')); +const entry = ` +import { messagesFeature } from './src/features/messages.js'; +${dom} +const author = 'a'.repeat(64), mine = 'b'.repeat(64), rootId = 'c'.repeat(64); +const root = { id: rootId, pubkey: author, kind: 1, content: 'Root post', tags: [], created_at: 1 }; +const reply = { ...root, id: 'd'.repeat(64), content: 'The reply you tapped', tags: [['e', rootId, '', 'root']], created_at: 2 }; +const other = { ...reply, id: 'e'.repeat(64), content: 'Another reply not returned by the relay', created_at: 3 }; +const ui = { screen: 'wallet' }, waiting = []; +window.sent = []; +window.query = filter => filter.kinds?.[0] === 1 && (filter.ids || filter['#e']) ? new Promise(resolve => waiting.push({ filter, resolve })) : Promise.resolve([]); +const signer = { signEvent: async e => ({ ...e, id: 'f'.repeat(64), pubkey: mine }) }; +let feature; +const wallet = { loaded: true, nostrRelays: () => [], loadFeatureState: (k, d) => d, saveFeatureState() {} }; +function render() { + if (!feature) return; + document.querySelector('#app').replaceChildren(feature.screenView() || feature.row(author, reply, 'Someone')); +} +feature = messagesFeature({ h, wallet, ui, render, toast() {}, brandHeader: () => null, + hook: k => k === 'nostrLoginIdentity' ? { pubkey: mine, signer } : null }); +window.test = { ui, reply, other, root, render, waiting, + release: () => { for (const q of waiting.splice(0)) q.resolve(q.filter.ids ? [root] : []); }, + overlay: () => { + ui.profilePk = author; ui.profOverThread = true; render(); + document.querySelector('#outside').replaceChildren(feature.actions(author, other, { canZap: false })); + }, + missing: () => { + const ev = { ...other, id: '1'.repeat(64), tags: [['e', '2'.repeat(64), '', 'root']] }; + document.querySelector('#outside').replaceChildren(feature.actions(author, ev, { canZap: false })); + }, + empty: () => { for (const q of waiting.splice(0)) q.resolve([]); }, +}; +render(); +`; +const bundle = await Bun.build({ entrypoints: ['thread-test-entry'], target: 'browser', plugins: [{ name: 'thread-test', setup(build) { + build.onResolve({ filter: /^thread-test-entry$/ }, () => ({ path: 'entry', namespace: 'thread-test' })); + build.onLoad({ filter: /.*/, namespace: 'thread-test' }, () => ({ contents: entry, loader: 'js', resolveDir: process.cwd() })); + build.onLoad({ filter: /src\/features\/messages\.js$/ }, async ({ path }) => ({ loader: 'js', contents: (await Bun.file(path).text()) + .replace("id: 'messages',", "id: 'messages', row: noteRow, actions: noteActions,") })); + build.onLoad({ filter: /src\/nostr\.js$/ }, async ({ path }) => ({ loader: 'js', contents: (await Bun.file(path).text()) + .replace('export async function queryOn(relays, filter, maxWait = 1500) {', 'export async function queryOn(relays, filter, maxWait = 1500) { return window.query(filter);') + .replace('export async function publishOn(relays, evt) {', 'export async function publishOn(relays, evt) { window.sent.push(evt); return true;') + .replace(/export function subscribeOn\(([^)]*)\) \{/, 'export function subscribeOn($1) { return () => {};') })); +} }] }); +assert(bundle.success, bundle.logs.join('\n')); +const html = `<!doctype html><meta charset="utf-8"><style>${await Bun.file('src/style.css').text()}</style><div id="app"></div><div id="outside"></div><input id="elsewhere"><script type="module">${await bundle.outputs[0].text()}</script>`; +const server = Bun.serve({ port: 0, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] }); +const page = await browser.newPage(), errors = []; +page.on('pageerror', e => errors.push(e.message)); +await page.setRequestInterception(true); +page.on('request', r => r.url().startsWith(server.url.origin) ? r.continue() : r.abort()); +const check = (name, value) => { assert(value, name); console.log('✓ ' + name); }; +const inline = () => page.evaluate(() => { + const input = document.querySelector('.thread-reply-input'); + const box = input?.parentElement.parentElement; + return !!input && box.previousElementSibling?.dataset.zapPost === test.ui.noteThread.focusId; +}); +try { + await page.goto(server.url.href); + await page.waitForSelector('#app .note-act'); + await page.click('#app .note-act'); + check('Reply immediately shows its composer beneath the tapped post', await inline()); + check('the input receives focus before relay queries finish', await page.evaluate(() => document.activeElement.matches('.thread-reply-input'))); + await page.type('.thread-reply-input', 'A reply before the root loads'); + await page.click('.thread-reply-send'); + await page.waitForFunction(() => sent.length === 1); + check('publishing targets the selected reply and its original root', await page.evaluate(() => sent[0].tags.some(t => t[0] === 'e' && t[1] === test.reply.id && t[3] === 'reply') && sent[0].tags.some(t => t[0] === 'e' && t[1] === test.root.id && t[3] === 'root'))); + await page.waitForFunction(() => test.waiting.length === 2); + await page.evaluate(() => test.release()); + await page.waitForFunction(() => document.body.innerText.includes('Root post')); + check('a relay result omitting the tapped reply keeps it and its composer', await inline()); + await page.evaluate(() => test.overlay()); + check('fixture starts with a profile covering the existing thread', await page.evaluate(() => test.ui.profOverThread && !document.querySelector('.thread-reply-input'))); + await page.click('#outside .note-act'); + check('Reply leaves the covering profile and inserts an uncached reply into the thread', await inline() && await page.evaluate(() => !test.ui.profOverThread && document.activeElement.matches('.thread-reply-input'))); + await page.evaluate(() => test.missing()); + await page.click('#outside .note-act'); + await page.waitForFunction(() => test.waiting.length === 2); + await page.evaluate(() => test.empty()); + await page.waitForFunction(() => !document.querySelector('#app .spinner')); + check('an unavailable root still leaves the selected post replyable', await inline()); + await page.evaluate(() => { test.ui.noteThread = null; test.ui.profilePk = null; test.render(); document.querySelector('#elsewhere').focus(); }); + await new Promise(r => setTimeout(r, 120)); + check('deferred focus does not steal focus after leaving the thread', await page.evaluate(() => document.activeElement.id === 'elsewhere')); + assert.deepEqual(errors, []); + console.log('✓ no browser errors'); +} finally { await browser.close(); server.stop(true); } |
