diff options
| author | Adam Soltys <[email protected]> | 2026-06-26 11:29:24 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-06-26 11:29:24 -0700 |
| commit | 92626d18830c34d223b2174e0b81ced6a24ec91f (patch) | |
| tree | 6dd81297525f22422c7a247dac3b957317717d89 /src/app.js | |
| parent | f090a29e9a208e67716cd61e43443ec7e095002f (diff) | |
| download | coinosv3-92626d18830c34d223b2174e0b81ced6a24ec91f.tar.gz coinosv3-92626d18830c34d223b2174e0b81ced6a24ec91f.zip | |
Persist wallets as watch-only across sessions; re-enter seed to spend
Closing the browser without 'Save to device' used to wipe the wallet entirely.
Now every wallet's account xpub is mirrored to a durable localStorage directory
(never the seed), so it survives a session wipe as a watch-only entry — you keep
seeing balance and history, and re-enter the seed to spend again.
- Durable account directory: saveDirectory() mirrors {id,label,xpub} for every
account; restore falls back to it as watch-only when there's no session/vault.
- Instant view: a full wallet also caches under its xpub key, so the downgraded
watch-only shows balance/history immediately (no rescan). Verified round-trip.
- Re-enter seed to spend: the Send tab (now shown for watch-only) and Settings
prompt for the recovery phrase / xprv; it must derive to this wallet's xpub
(verified via accountXpubFor — wrong seed/passphrase rejected), with an option
to Save to device at the same time. Upgrade is in place (id/label/cache kept).
- Vault merge now dedupes by xpub so saved + non-saved wallets coexist without
duplicates.
Privacy note: the xpub now persists by default (someone with device access can
see balance/history without the seed); 'Remove' clears it.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Diffstat (limited to 'src/app.js')
| -rw-r--r-- | src/app.js | 159 |
1 files changed, 135 insertions, 24 deletions
@@ -4,7 +4,7 @@ // which rebuilds the active screen. Text inputs write back into `ui` on `input` // (without re-rendering) so their values survive structural re-renders. -import { Wallet, newMnemonic, isValidMnemonic, utxoId, previewGift, giftOutpoints, buildClaimTx, giftMinimum, parseExtendedKey, xpubToZpub, encryptVault, decryptVault } from './wallet.js'; +import { Wallet, newMnemonic, isValidMnemonic, accountXpubFor, utxoId, previewGift, giftOutpoints, buildClaimTx, giftMinimum, parseExtendedKey, xpubToZpub, encryptVault, decryptVault } from './wallet.js'; import { qrSvg } from './qr.js'; import { scanQr } from './scan.js'; import { getSyncConfig, setSyncConfig } from './nostr.js'; @@ -616,6 +616,10 @@ async function activateAccount(acc, opts = {}) { if (acc.type === 'watch') wallet.load({ xpub: acc.xpub, netName: 'mainnet', offline: false }); else if (acc.xprv) wallet.load({ xprv: acc.xprv, netName: 'mainnet', offline: false }); else wallet.load({ mnemonic: acc.mnemonic, passphrase: acc.passphrase || '', netName: 'mainnet', offline: false }); + // Record the account-level xpub so this wallet survives a session wipe as a + // watch-only entry (see the durable account directory) — you keep seeing your + // balance/history and re-enter the seed to spend again. + if (acc.type !== 'watch' && !acc.provisional) acc.xpub = wallet.accountXpub(); persistAccounts(); const hadCache = wallet.restoreCache(); // show last-known balance/history instantly // An opened gift link starts on a claim/back-up screen instead of the wallet. @@ -713,17 +717,25 @@ function defaultLabel(type) { return t(type === 'watch' ? 'watchLabelN' : 'walletLabelN', { n }); } +// The durable account directory (localStorage): a view-only mirror of every +// account — id, label, and account xpub, never a seed. So a wallet survives a +// session wipe (browser closed without "Save to device") as a watch-only entry: +// you keep seeing balance/history and re-enter the seed to spend again. function loadWatchAccounts() { - try { return JSON.parse(localStorage.getItem(WATCH_KEY) || '[]'); } catch { return []; } + try { + const dir = JSON.parse(localStorage.getItem(WATCH_KEY) || '[]'); + return dir.filter((d) => d.xpub).map((d) => ({ id: d.id, label: d.label, type: 'watch', xpub: d.xpub })); + } catch { return []; } } -function saveWatchAccounts() { +function saveDirectory() { try { - const watch = accounts.filter((a) => a.type === 'watch').map((a) => ({ id: a.id, label: a.label, type: 'watch', xpub: a.xpub })); - localStorage.setItem(WATCH_KEY, JSON.stringify(watch)); + const dir = accounts.filter((a) => a.xpub && !a.provisional).map((a) => ({ id: a.id, label: a.label, xpub: a.xpub })); + localStorage.setItem(WATCH_KEY, JSON.stringify(dir)); } catch {} } function persistAccounts() { try { sessionStorage.setItem(ACCOUNTS_KEY, JSON.stringify({ accounts, activeId })); } catch {} + saveDirectory(); } function clearAccounts() { accounts = []; @@ -738,7 +750,6 @@ function addOrGetAccount(partial) { if (!acc) { acc = { id: genId(), ...partial }; accounts.push(acc); - if (acc.type === 'watch') saveWatchAccounts(); persistAccounts(); } return acc; @@ -748,14 +759,17 @@ function addOrGetAccount(partial) { // longer discarded on bail. Called once the user claims, keeps, or enters it. function commitAccount() { const a = accounts.find((x) => x.id === activeId); - if (a && a.provisional) { delete a.provisional; persistAccounts(); } + if (a && a.provisional) { + delete a.provisional; + if (a.type !== 'watch') a.xpub = wallet.accountXpub(); // now eligible for the durable directory + persistAccounts(); + } } function removeAccount(id) { const acc = accounts.find((a) => a.id === id); if (!acc) return; accounts = accounts.filter((a) => a.id !== id); - if (acc.type === 'watch') saveWatchAccounts(); persistAccounts(); if (activeId === id) { if (accounts.length) activateAccount(accounts[0], { fresh: true }); @@ -813,14 +827,27 @@ function writeVault() { } catch {} } +// Bring vault-saved seeds into the session. Each upgrades the matching watch-only +// directory entry (by xpub) to a spendable full account so wallets aren't +// duplicated; one with no directory entry is added fresh. function mergeVaultList(list) { for (const v of list) { - const acc = addOrGetAccount( - v.xprv - ? { type: 'full', label: v.label || defaultLabel('full'), xprv: v.xprv } - : { type: 'full', label: v.label || defaultLabel('full'), mnemonic: v.mnemonic, passphrase: v.passphrase || '' } - ); - acc.persisted = true; + const xpub = v.xprv ? accountXpubFor({ xprv: v.xprv }) : accountXpubFor({ mnemonic: v.mnemonic, passphrase: v.passphrase || '' }); + const existing = accounts.find((a) => a.xpub === xpub); + if (existing) { + existing.type = 'full'; + if (v.xprv) { existing.xprv = v.xprv; delete existing.mnemonic; delete existing.passphrase; } + else { existing.mnemonic = v.mnemonic; existing.passphrase = v.passphrase || ''; delete existing.xprv; } + if (v.label) existing.label = v.label; + existing.persisted = true; + } else { + const acc = addOrGetAccount( + v.xprv + ? { type: 'full', label: v.label || defaultLabel('full'), xprv: v.xprv, xpub } + : { type: 'full', label: v.label || defaultLabel('full'), mnemonic: v.mnemonic, passphrase: v.passphrase || '', xpub } + ); + acc.persisted = true; + } } } @@ -833,6 +860,42 @@ function startSave(id) { ui.pw = { purpose: 'save', accId: id, mode: hasVault() ? 'enter' : 'set', v1: '', v2: '', error: '' }; render(); } +// --- load a seed into a watch-only account (make it spendable) -------------- +function startLoadSeed(opts = {}) { + ui.loadSeed = { value: '', passphrase: '', save: !!opts.save, error: '' }; + render(); +} +function cancelLoadSeed() { ui.loadSeed = null; render(); } +async function doLoadSeed() { + const ls = ui.loadSeed; + const acc = activeAccount(); + if (!ls || !acc || !acc.xpub) return; + const raw = (ls.value || '').trim().replace(/\s+/g, ' '); + if (!raw) { ls.error = t('enterSeedToSpend'); render(); return; } + let next = null; + try { + if (isValidMnemonic(raw)) { + if (accountXpubFor({ mnemonic: raw, passphrase: ls.passphrase || '' }) !== acc.xpub) { ls.error = t('seedMismatch'); render(); return; } + next = { mnemonic: raw, passphrase: ls.passphrase || '' }; + } else { + const pk = parseExtendedKey(raw); // throws if not an extended key + if (pk.kind !== 'xprv') { ls.error = t('seedNeedsPrivate'); render(); return; } + if (accountXpubFor({ xprv: pk.key }) !== acc.xpub) { ls.error = t('seedMismatch'); render(); return; } + next = { xprv: pk.key }; + } + } catch { ls.error = t('seedInvalid'); render(); return; } + // Upgrade in place — keep id/label/xpub so the cache, directory entry and + // history all carry over; the account is now spendable. + acc.type = 'full'; + if (next.mnemonic) { acc.mnemonic = next.mnemonic; acc.passphrase = next.passphrase; delete acc.xprv; } + else { acc.xprv = next.xprv; delete acc.mnemonic; delete acc.passphrase; } + const save = ls.save; + ui.loadSeed = null; + await activateAccount(acc, { fresh: false }); + if (save) startSave(acc.id); // opens the Save-to-device (vault) flow + else render(); +} + function startForget(id) { if (vaultPassword != null) { const acc = accounts.find((a) => a.id === id); @@ -869,9 +932,11 @@ function unlockVault() { let list; try { list = decryptVault(loadVaultBlob(), ui.vaultPw); } catch { ui.vaultError = t('pwWrong'); render(); return; } vaultPassword = ui.vaultPw; - accounts = list - .map((v) => ({ id: genId(), type: 'full', label: v.label || defaultLabel('full'), mnemonic: v.mnemonic || '', passphrase: v.passphrase || '', xprv: v.xprv || '', persisted: true })) - .concat(loadWatchAccounts()); + // Start from the durable directory (watch-only views of every known wallet), + // then upgrade the vault-saved ones to spendable — so non-saved wallets still + // appear (watch-only) instead of vanishing. + accounts = loadWatchAccounts(); + mergeVaultList(list); ui.vaultPw = ''; ui.vaultError = ''; activateAccount(accounts[0], { fresh: true }); @@ -1036,10 +1101,15 @@ function settingsTab() { 'div', { class: 'col', style: 'gap:16px' }, wallet.watchOnly - ? h('div', { class: 'card col' }, - h('h3', {}, t('watchOnly')), - h('p', { class: 'small muted', style: 'margin:0' }, t('watchOnlyNote')) - ) + ? (ui.loadSeed + ? loadSeedCard() + : h('div', { class: 'card col' }, + h('h3', {}, t('watchOnly')), + h('p', { class: 'small muted', style: 'margin:0' }, t('watchOnlyNote')), + activeAccount() && activeAccount().xpub + ? h('button', { class: 'btn-primary btn-block', style: 'margin-top:4px', onClick: () => startLoadSeed() }, t('loadSeedBtn')) + : null + )) : !wallet.mnemonic ? h('div', { class: 'card col' }, h('h3', {}, t('importedKey')), @@ -1726,7 +1796,6 @@ function renameAccount(id) { const v = (ui.editLabel || '').trim(); if (v) acc.label = v; persistAccounts(); - if (acc.type === 'watch') saveWatchAccounts(); if (acc.persisted) writeVault(); } ui.editId = null; @@ -1826,8 +1895,8 @@ function balanceCard() { function tabsBar() { const tabs = [ ['receive', t('tabReceive')], - // Watch-only wallets can't sign, so no Send tab. - ...(wallet.watchOnly ? [] : [['send', t('tabSend')]]), + // Watch-only wallets show Send too — it prompts to load the seed to spend. + ['send', t('tabSend')], ['history', t('tabHistory')], ['settings', t('tabSettings')], ]; @@ -1908,7 +1977,49 @@ function receiveTab() { } // ---------------------------------------------------------------- Send +// Form to enter a recovery phrase / xprv and make a watch-only wallet spendable. +// Shared by the Send tab and Settings. The entered seed must derive to this +// account's xpub — you can't load the wrong wallet's seed. +function loadSeedCard() { + const ls = ui.loadSeed; + return h( + 'div', + { class: 'card col' }, + h('h3', {}, t('loadSeedTitle')), + h('p', { class: 'small muted', style: 'margin:0' }, t('loadSeedDesc')), + h('textarea', { + class: 'mono-input', rows: '3', placeholder: t('seedPlaceholder'), + autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: ls.value, + onInput: (e) => { ls.value = e.target.value; }, + }), + h('input', { + type: 'password', class: 'mono-input', placeholder: t('bip39PassphraseOpt'), + autocapitalize: 'none', autocomplete: 'off', value: ls.passphrase, + onInput: (e) => { ls.passphrase = e.target.value; }, + }), + h('label', { class: 'row gap6', style: 'align-items:center;cursor:pointer' }, + h('input', { type: 'checkbox', checked: ls.save, onChange: (e) => { ls.save = e.target.checked; } }), + h('span', { class: 'small' }, t('alsoSaveDevice')) + ), + ls.error ? h('div', { class: 'notice err' }, ls.error) : null, + h('div', { class: 'row gap6' }, + h('button', { class: 'btn-ghost', onClick: cancelLoadSeed }, t('cancel')), + h('button', { class: 'btn-primary grow', onClick: doLoadSeed }, t('loadSeedBtn')) + ) + ); +} + function sendTab() { + // Watch-only wallet (e.g. restored after a session wipe without "Save to + // device"): prompt to re-enter the seed before spending. + if (wallet.watchOnly) { + if (ui.loadSeed) return loadSeedCard(); + return h('div', { class: 'card col', style: 'gap:12px' }, + h('h3', {}, t('watchOnlySendTitle')), + h('p', { class: 'small muted', style: 'margin:0' }, t('watchOnlySendDesc')), + h('button', { class: 'btn-primary btn-block', onClick: () => startLoadSeed() }, t('loadSeedBtn')) + ); + } if (ui.sendResult) return sendResultView(); if (ui.broadcastTx) return broadcastConfirmView(); if (ui.draft) return reviewView(); |
