aboutsummaryrefslogtreecommitdiffstats
path: root/tools
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-11-10 15:10:18 -0700
committerAdam Soltys <[email protected]>2026-11-10 15:10:18 -0700
commit0f2407e0fcf0c8267262275367f5bd01edd94532 (patch)
tree2759e935e0cc84ead20a82d058e6fb3b82a4a946 /tools
parent83abfea172d09d6e730859f3028f3cb6870136c5 (diff)
downloadcoinosv3-0f2407e0fcf0c8267262275367f5bd01edd94532.tar.gz
coinosv3-0f2407e0fcf0c8267262275367f5bd01edd94532.zip
Settings → Wallet → Delete account: the name is given up, the servers forget the account, and it leaves this device. "someone's asking for a way to delete their v3 account. we allowed that in v2 … we can expose a button for it in settings somewhere" (Adam, 2026-11-10). The page first asks the registrar what stands in the way (coinosv3-server f8c9efa, POST /account/delete with check): sats still on their way here or held in a withdraw link block it, and say so. It lists what goes (the name, free for anyone after; the server's records: payment address, notification registrations, scheduled posts, hats, point-of-sale and receipt records; the wallet on this device) and what does not: the coins, which only the recovery phrase controls, and what was published to nostr relays. Coins still here are named in sats with a warning, and the box to tick then says "I have moved my funds or saved my recovery phrase". On the tap the registrar deletes (signed by the wallet's key, which also answers for a login identity's name it manages), the profile drops a lud16/nip05 that named the freed address (addressGone; publishProfileFields learns `remove`), and the wallet is wiped from the session, the saved vault and its caches. A vault behind a real password not entered this session asks for a lock and unlock first. tools/account-delete-test.js (a stand-in registrar checks the NIP-98 signature)
Co-Authored-By: Claude Fable 5.2 <[email protected]> Claude-Session: https://claude.ai/code/session_01VMan6V74NSFWZtNtgCfokN
Diffstat (limited to 'tools')
-rw-r--r--tools/account-delete-test.js115
1 files changed, 115 insertions, 0 deletions
diff --git a/tools/account-delete-test.js b/tools/account-delete-test.js
new file mode 100644
index 0000000..d06bcb8
--- /dev/null
+++ b/tools/account-delete-test.js
@@ -0,0 +1,115 @@
+// 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: ['[email protected]'], ...owed, paying: 0, blocked });
+ if (blocked) return reply(409, { error: 'funds', ...owed, paying: 0 });
+ return reply(200, { ok: true, names: ['[email protected]'] });
+});
+
+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('[email protected] 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);