aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/app.js74
-rw-r--r--src/features/ark.js2
-rw-r--r--src/features/messages.js11
-rw-r--r--src/features/names.js22
-rw-r--r--src/i18n.js14
-rw-r--r--src/locales/it.json14
-rw-r--r--tools/account-delete-test.js115
7 files changed, 251 insertions, 1 deletions
diff --git a/src/app.js b/src/app.js
index 23a1d42..95909d0 100644
--- a/src/app.js
+++ b/src/app.js
@@ -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);