diff options
| -rw-r--r-- | src/app.js | 74 | ||||
| -rw-r--r-- | src/features/ark.js | 2 | ||||
| -rw-r--r-- | src/features/messages.js | 11 | ||||
| -rw-r--r-- | src/features/names.js | 22 | ||||
| -rw-r--r-- | src/i18n.js | 14 | ||||
| -rw-r--r-- | src/locales/it.json | 14 | ||||
| -rw-r--r-- | tools/account-delete-test.js | 115 |
7 files changed, 251 insertions, 1 deletions
@@ -3209,7 +3209,9 @@ function settingsTab() { 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), historyExportCard()] : []); + case 'wallet': + if (ui.delAcct && a) return deleteAccountView(a); + return page(a ? [recoveryCard(a), walletNameCard(a), pubkeyCard(a), historyExportCard(), deleteAccountCard(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 @@ -3777,6 +3779,76 @@ function openAccountSettings(id) { // Per-wallet settings cards, shared by the per-account screen (Accounts → ⚙) // and the main Settings tab (which inlines them for the active wallet). +// Delete account: the server forgets this wallet (its name is freed, its +// records go) and the wallet leaves this device. "someone's asking for a way +// to delete their v3 account. we allowed that in v2" (Adam, 2026-11-10). The +// coins are the user's own and no server can delete them: they stay +// spendable from the recovery phrase, which is why the page says how much is +// still here before anything happens. +// The saved copy on this device has to go too, and the vault can only be +// rewritten with its password in hand: a blank one is tried here, a real one +// is known once the app has been unlocked with it this session. +function vaultWritable() { + if (vaultPassword != null || !hasVault()) return true; + try { const list = decryptVault(loadVaultBlob(), ''); vaultPassword = ''; mergeVaultList(list); return true; } catch { return false; } +} +function deleteAccountCard(a) { + if (a.type === 'watch' || wallet.watchOnly || !wallet.nostrSign) return null; + return h('div', { class: 'card col', 'data-key': 'delete-account' }, + h('h3', {}, t('delAcctTitle')), + h('p', { class: 'small muted', style: 'margin:0' }, t('delAcctBlurb')), + h('button', { class: 'btn-danger btn-block', onClick: () => { + if (!vaultWritable()) { toast(t('delAcctUnlockFirst')); return; } + const d = ui.delAcct = { accId: a.id, info: null, sure: false, busy: false, error: '' }; + render(); + Promise.resolve(featureHook('accountDelete', true)).then((info) => { if (ui.delAcct === d) { d.info = info || {}; render(); } }) + .catch((e) => { if (ui.delAcct === d) { d.info = {}; d.error = e.message; render(); } }); + } }, t('delAcctTitle'))); +} +function deleteAccountView(a) { + const d = ui.delAcct; + const heldSat = (wallet.spendable || 0) + (featureHook('arkHeldSat') || 0); + const info = d.info; + const names = (info && info.names) || []; + const owedSat = info ? (info.pendingSat || 0) + (info.withdrawSat || 0) : 0; + const blocked = !!(info && (info.blocked || owedSat > 0 || info.paying)); + const back = () => { ui.delAcct = null; render(); }; + const go = async () => { + if (d.busy || !d.sure || blocked) return; + d.busy = true; d.error = ''; render(); + try { + const r = await featureHook('accountDelete', false); + if (r && r.blocked) { d.info = { ...d.info, ...r, blocked: true }; d.sure = false; return; } + ui.delAcct = null; ui.settingsPage = null; + wipeAccountCache(a); + // out of the saved vault first, or the next unlock brings it back + if (a.persisted && vaultWritable()) { a.persisted = false; writeVault(); } + removeAccount(a.id); + toast(t('delAcctDone')); + } catch (e) { d.error = e.message; } + finally { d.busy = false; render(); } + }; + return h('div', { class: 'col', style: 'gap:16px' }, + h('div', { class: 'card col', style: 'gap:10px' }, + h('h3', {}, t('delAcctTitle')), + !info ? h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('delAcctChecking'))) : null, + h('ul', { class: 'small', style: 'margin:0;padding-left:18px;display:flex;flex-direction:column;gap:6px' }, + names.length ? h('li', {}, t('delAcctName', { name: names.join(', ') })) : null, + h('li', {}, t('delAcctRecords')), + h('li', {}, t('delAcctDevice', { name: seedName(a) })), + h('li', { class: 'muted' }, t('delAcctStays'))), + blocked ? h('div', { class: 'warn-box' }, t('delAcctOwed', { sat: fmtAmount(owedSat) })) : null, + heldSat > 0 ? h('div', { class: 'warn-box' }, t('delAcctFunds', { sat: fmtAmount(heldSat) })) : null, + d.error ? h('div', { class: 'warn-box' }, d.error) : null, + h('label', { class: 'row gap6 small', style: 'align-items:flex-start;cursor:pointer' }, + h('input', { type: 'checkbox', checked: d.sure, disabled: blocked || d.busy, style: 'margin-top:2px', onChange: (e) => { d.sure = e.target.checked; render(); } }), + h('span', {}, t(heldSat > 0 ? 'delAcctSureFunds' : 'delAcctSure'))), + h('div', { class: 'row gap6' }, + h('button', { class: 'btn-ghost grow', disabled: d.busy, onClick: back }, t('back')), + h('button', { class: 'btn-danger grow', disabled: !info || !d.sure || blocked || d.busy, onClick: go }, + d.busy ? h('span', { class: 'spinner sm' }) : t('delAcctGo'))))); +} + function recoveryCard(a) { const isWatch = a.type === 'watch' || (!a.mnemonic && !a.xprv); const shown = ui.revealShown; diff --git a/src/features/ark.js b/src/features/ark.js index 075cf4b..1b8c591 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -4149,6 +4149,8 @@ export function arkFeature(ctx) { return mgr.lnOutcome(paymentHash, timeoutMs); }, arkSpendableSat() { const b = arkBalance(); return b ? b.spendableSat : 0; }, + // everything Spending holds, settled or not + arkHeldSat() { const b = arkBalance(); return b ? b.spendableSat + b.pendingSat + b.boardingSat : 0; }, arkReady() { return arkAvailable(); }, // A Lightning invoice for another feature to watch (point of sale): // the action id is the handle, the poller tells it when it settles. diff --git a/src/features/messages.js b/src/features/messages.js index f72d71d..005b20d 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -13205,6 +13205,8 @@ export function messagesFeature(ctx) { if (opts.fieldWhen && opts.fieldWhen[k] && !opts.fieldWhen[k](base)) continue; merged[k] = v; } + // fields to take out altogether (an address that no longer exists) + for (const k of opts.remove || []) if (!opts.fieldWhen || !opts.fieldWhen[k] || opts.fieldWhen[k](base)) delete merged[k]; if (merged.name && !(fill.has('name') && taken('name'))) merged.display_name = merged.name; if (JSON.stringify(merged) === JSON.stringify(base)) return true; // nothing to say const partial = { kind: 0, content: JSON.stringify(merged), tags: [], created_at: kind0Stamp(id.pubkey) }; @@ -19089,6 +19091,15 @@ export function messagesFeature(ctx) { // lud16 and nip05 — but only where they pointed at the released address // (or were empty). Deliberately different values are not ours to touch. addressRenamed(oldAddr, newAddr) { return repointAddress(oldAddr, newAddr).catch(() => {}); }, + // the address was given up with nothing in its place (account deleted): + // a profile still naming it would send zaps to nobody + addressGone(oldAddr) { + const is = (k) => (base) => base[k] === oldAddr; + return publishProfileFields({}, { + onlyWhen: (base) => is('lud16')(base) || is('nip05')(base), + remove: ['lud16', 'nip05'], fieldWhen: { lud16: is('lud16'), nip05: is('nip05') }, + }).catch(() => {}); + }, // older copies of our kind 0 on history-keeping relays, and filling the // open editor's empty fields from one (tests) profileHistory() { return findProfileHistory(); }, diff --git a/src/features/names.js b/src/features/names.js index e07e025..3f23a0c 100644 --- a/src/features/names.js +++ b/src/features/names.js @@ -132,6 +132,27 @@ export function namesFeature(ctx) { return j; } + // Delete the account at the registrar: every name this wallet's key owns + // or manages is freed and what the server filed under them goes with it + // (coinosv3-server names/forget.js). `check` only asks what stands in the + // way: sats still on their way here, or held in a withdraw link. + async function accountDelete(check) { + const url = `${REGISTRAR}/account/delete`; + const body = JSON.stringify(check ? { check: true } : {}); + const auth = await withTimeout(nip98Header(walletSigner(), url, 'POST', body), 12000, 'signing'); + const r = await withTimeout(fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', authorization: auth }, body }), 30000, 'registrar'); + const j = await r.json().catch(() => ({})); + if (j.error === 'funds') return { blocked: true, ...j }; + if (!r.ok || j.error) throw new Error(j.error || `registrar refused (${r.status})`); + if (!check) { + // a profile still pointing at a freed address would pay nobody (or its next owner) + for (const key of j.names || []) + await Promise.race([Promise.resolve(hook('addressGone', key)).catch(() => {}), new Promise((res) => setTimeout(res, 8000))]); + wallet.saveFeatureState('names', {}); + } + return j; + } + async function release() { const st = load(); if (!st.name) return; @@ -1056,6 +1077,7 @@ export function namesFeature(ctx) { return null; }, settingsCards() { return namesCards(); }, + accountDelete(check) { return wallet.watchOnly || !wallet.nostrSign ? null : accountDelete(check); }, posSettingsCards() { return posSettingsCardsForName(); }, // the Ark server changed: the name's ark= instruction must follow namesRefresh() { refresh().catch(() => {}); return true; }, diff --git a/src/i18n.js b/src/i18n.js index 9c7827d..8f54f1a 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -68,6 +68,20 @@ const STR = { logoutAll: 'Log out of all accounts', clearAllWarn: '⚠ This removes every account from this device. Any account whose recovery phrase or key you have not backed up will be lost for good.', removeWalletTitle: 'Remove account', + delAcctTitle: 'Delete account', + delAcctBlurb: 'Give up your Coinos name, have our servers forget this account, and remove it from this device.', + delAcctChecking: 'Checking your account…', + delAcctName: '{name} is released. Anyone can claim it afterwards, and payments sent to it will no longer reach you.', + delAcctRecords: 'Our servers delete what they hold for this account: its payment address, notification registrations, scheduled posts, hats, point-of-sale and receipt records.', + delAcctDevice: '“{name}” is removed from this device.', + delAcctStays: 'Not deleted: your coins, which only your recovery phrase controls, and what you have published to nostr relays (profile, posts, messages).', + delAcctOwed: '⚠ {sat} sats are still on their way to you or held in your withdraw links. Wait for payments to arrive, take back your withdraw links (Send → Withdraw link), then try again.', + delAcctFunds: '⚠ This account still holds {sat} sats. Deleting does not move them. Send them somewhere first, or make sure you have the recovery phrase: without it they are lost for good.', + delAcctSure: 'I understand this cannot be undone.', + delAcctSureFunds: 'I have moved my funds or saved my recovery phrase, and I understand this cannot be undone.', + delAcctGo: 'Delete account', + delAcctDone: 'Account deleted', + delAcctUnlockFirst: 'Lock Coinos and unlock it with your password first, then try again', removeWalletWarn: '⚠ Remove “{name}” from this device? It will be gone from this browser. Make sure you have its recovery phrase. Other accounts aren’t affected.', rename: 'Rename', remove: 'Remove', diff --git a/src/locales/it.json b/src/locales/it.json index 112296f..1844905 100644 --- a/src/locales/it.json +++ b/src/locales/it.json @@ -28,6 +28,20 @@ "logoutOneBlurbSeed": "Esce solo questo account. Gli altri restano connessi. Verrà rimosso da questo dispositivo, quindi assicurati di avere la sua frase di recupero.", "logoutAll": "Esci da tutti gli account", "clearAllWarn": "⚠ Questo rimuove tutti gli account da questo dispositivo. Ogni account di cui non hai salvato la frase di recupero o la chiave andrà perso per sempre.", + "delAcctTitle": "Elimina account", + "delAcctBlurb": "Rinuncia al tuo nome Coinos, fai dimenticare questo account ai nostri server e rimuovilo da questo dispositivo.", + "delAcctChecking": "Controllo del tuo account…", + "delAcctName": "{name} viene rilasciato. Chiunque potrà richiederlo in seguito e i pagamenti inviati a quel nome non ti arriveranno più.", + "delAcctRecords": "I nostri server eliminano ciò che conservano per questo account: indirizzo di pagamento, registrazioni delle notifiche, post programmati, cappelli, dati del punto vendita e delle ricevute.", + "delAcctDevice": "“{name}” viene rimosso da questo dispositivo.", + "delAcctStays": "Non eliminati: le tue monete, controllate solo dalla tua frase di recupero, e ciò che hai pubblicato sui relay nostr (profilo, post, messaggi).", + "delAcctOwed": "⚠ {sat} sat sono ancora in arrivo o trattenuti nei tuoi link di prelievo. Attendi i pagamenti, riprendi i link di prelievo (Invia → Link di prelievo) e riprova.", + "delAcctFunds": "⚠ Questo account contiene ancora {sat} sat. L'eliminazione non li sposta. Inviali altrove prima, oppure assicurati di avere la frase di recupero: senza di essa sono persi per sempre.", + "delAcctSure": "Ho capito che non si può annullare.", + "delAcctSureFunds": "Ho spostato i miei fondi o salvato la frase di recupero, e ho capito che non si può annullare.", + "delAcctGo": "Elimina account", + "delAcctDone": "Account eliminato", + "delAcctUnlockFirst": "Blocca Coinos e sbloccalo con la tua password, poi riprova", "removeWalletTitle": "Rimuovi account", "removeWalletWarn": "⚠ Rimuovere “{name}” da questo dispositivo? Sparirà da questo browser. Assicurati di avere la sua frase di recupero. Gli altri account non vengono toccati.", "rename": "Rinomina", 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); |
