aboutsummaryrefslogtreecommitdiffstats
path: root/src
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 /src
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 'src')
-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
6 files changed, 136 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",