// Settings → Wallet → Delete account: asks the registrar what stands in the // way, says what goes and what stays (and how many sats are still here), // and on a ticked box + tap has the registrar forget the account (a NIP-98 // request signed by the wallet's key) and removes the wallet from the device. // The registrar here is a stand-in inside the test; relays are the stand-ins. // // Run: bun tools/account-delete-test.js import puppeteer from './puppeteer.js'; import { generateMnemonic } from '@scure/bip39'; import { wordlist } from '@scure/bip39/wordlists/english'; import { verifyEvent } from 'nostr-tools/pure'; import { createHash } from 'node:crypto'; import { buildHtml } from '../build.js'; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); let ok = true; const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; const html = await buildHtml({ minify: true, pwa: false }); const server = Bun.serve({ port: 5271, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) }); const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); const page = await browser.newPage(); await page.setViewport({ width: 420, height: 900 }); page.on('pageerror', (e) => { console.log(' page error:', e.message); ok = false; }); // the registrar: records what it's asked, answers as the real one would const calls = []; let owed = { pendingSat: 0, withdrawSat: 0 }; const cors = { 'access-control-allow-origin': '*', 'access-control-allow-headers': 'content-type,authorization', 'access-control-allow-methods': 'GET,POST,DELETE,OPTIONS' }; await page.setRequestInterception(true); page.on('request', (req) => { const u = new URL(req.url()); if (u.hostname !== 'names.coinos.io') return req.continue(); if (req.method() === 'OPTIONS') return req.respond({ status: 200, headers: cors, body: '{}' }); const reply = (status, j) => req.respond({ status, headers: { ...cors, 'content-type': 'application/json' }, body: JSON.stringify(j) }); if (u.pathname !== '/account/delete') return reply(404, { error: 'not found' }); const body = req.postData() || ''; let ev = null; try { ev = JSON.parse(Buffer.from((req.headers().authorization || '').replace(/^Nostr /, ''), 'base64').toString()); } catch {} const tag = (k) => ev?.tags.find((t) => t[0] === k)?.[1]; const signed = !!ev && ev.kind === 27235 && verifyEvent(ev) && tag('method') === 'POST' && new URL(tag('u')).pathname === '/account/delete' && tag('payload') === createHash('sha256').update(body).digest('hex'); const j = JSON.parse(body || '{}'); calls.push({ check: !!j.check, signed, pubkey: ev?.pubkey }); const blocked = owed.pendingSat + owed.withdrawSat > 0; if (j.check) return reply(200, { names: ['andy@coinos.io'], ...owed, paying: 0, blocked }); if (blocked) return reply(409, { error: 'funds', ...owed, paying: 0 }); return reply(200, { ok: true, names: ['andy@coinos.io'] }); }); const click = (sel, t) => page.evaluate((s, x) => { const e = [...document.querySelectorAll(s)].find((n) => n.textContent.trim().toLowerCase().includes(x.toLowerCase())); if (e) { e.click(); return true; } return false; }, sel, t); const until = async (fn, ms = 15000) => { const end = Date.now() + ms; for (;;) { const v = await fn(); if (v) return v; if (Date.now() > end) return null; await sleep(250); } }; const text = () => page.evaluate(() => document.body.innerText); const goBtn = () => page.evaluate(() => { const b = [...document.querySelectorAll('button.btn-danger')].find((x) => x.textContent.trim() === 'Delete account' && x.closest('.card')?.querySelector('input[type=checkbox]')); return b ? { disabled: b.disabled } : null; }); const openCard = async () => { await page.evaluate(() => document.querySelector('[data-key="delete-account"] button')?.click()); await until(async () => (await text()).includes('andy@coinos.io is released')); }; try { await page.goto('http://localhost:5271/', { waitUntil: 'domcontentloaded' }); await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('button', 'Create a new wallet'); await sleep(300); await click('button', 'Have an existing seed'); await sleep(300); await page.waitForSelector('textarea'); await page.type('textarea', generateMnemonic(wordlist)); await click('button', 'Open wallet'); await until(async () => /receive/i.test(await text())); await page.evaluate(() => [...document.querySelectorAll('button')].find((e) => e.getAttribute('aria-label') === 'Settings')?.click()); await until(async () => (await text()).includes('Advanced')); await page.evaluate(() => [...document.querySelectorAll('.settings-tile')].find((e) => [...e.querySelectorAll('span')].some((x) => x.textContent.trim() === 'Wallet'))?.click()); await sleep(500); if (process.env.DEBUG) console.log((await text()).slice(0, 600)); check('Settings → Wallet has a Delete account card', !!(await until(() => page.evaluate(() => !!document.querySelector('[data-key="delete-account"]'))))); console.log('\n[sats still owed]'); owed = { pendingSat: 21, withdrawSat: 500 }; await openCard(); let tx = await text(); check('says the name is released, records deleted, and what stays', /is released/.test(tx) && /Our servers delete/.test(tx) && /Not deleted: your coins/.test(tx)); check('names the sats still owed and won\'t go on', /521 sats are still on their way/.test(tx) && (await goBtn())?.disabled === true, tx.match(/⚠[^\n]*/)?.[0]?.slice(0, 80)); check('the question was signed by the wallet\'s key', calls.length === 1 && calls[0].check && calls[0].signed); await click('button', 'Back'); await sleep(300); check('Back returns to the settings page, account intact', !!(await page.evaluate(() => !!document.querySelector('[data-key="delete-account"]'))) && calls.every((c) => c.check)); console.log('\n[nothing in the way]'); owed = { pendingSat: 0, withdrawSat: 0 }; await openCard(); check('the button waits for the box to be ticked', (await goBtn())?.disabled === true); await page.evaluate(() => document.querySelector('.card input[type=checkbox]')?.click()); await sleep(300); check('ticked: the button is live', (await goBtn())?.disabled === false); const before = calls.length; await page.evaluate(() => [...document.querySelectorAll('button.btn-danger')].find((x) => x.textContent.trim() === 'Delete account' && !x.disabled)?.click()); const gone = await until(async () => /create a new wallet/i.test(await text()), 20000); if (process.env.DEBUG) console.log((await text()).slice(0, 900)); check('the registrar was told to delete, signed', calls.slice(before).some((c) => !c.check && c.signed), JSON.stringify(calls.slice(before).map((c) => ({ check: c.check, signed: c.signed })))); check('the wallet is gone from the device: back at the front door', !!gone); const left = await page.evaluate(() => ({ session: sessionStorage.getItem('btc-wallet-accounts'), vault: localStorage.getItem('btc-wallet-vault') })); check('no account left in the session', !left.session || !JSON.parse(left.session).accounts?.length, String(left.session).slice(0, 60)); await page.reload({ waitUntil: 'domcontentloaded' }); check('and a reload doesn\'t bring it back', !!(await until(async () => /create a new wallet/i.test(await text()), 10000))); } catch (e) { console.log(' error:', e.message); ok = false; } finally { await browser.close(); server.stop(true); } console.log(ok ? '\nPASS' : '\nFAIL'); process.exit(ok ? 0 : 1);