aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-06-26 11:29:24 -0700
committerAdam Soltys <[email protected]>2026-06-26 11:29:24 -0700
commit92626d18830c34d223b2174e0b81ced6a24ec91f (patch)
tree6dd81297525f22422c7a247dac3b957317717d89
parentf090a29e9a208e67716cd61e43443ec7e095002f (diff)
downloadcoinosv3-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]>
-rw-r--r--src/app.js159
-rw-r--r--src/i18n.js13
-rw-r--r--src/wallet.js31
3 files changed, 178 insertions, 25 deletions
diff --git a/src/app.js b/src/app.js
index 8524528..5b1ef5a 100644
--- a/src/app.js
+++ b/src/app.js
@@ -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();
diff --git a/src/i18n.js b/src/i18n.js
index e3341a7..d36f301 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -47,6 +47,18 @@ const STR = {
watchOnlyAdd: 'Add watch-only',
watchOnlyDesc: 'Watch any wallet from its account public key. You can receive and view balance and history, but not send.',
watchOnlyNote: 'This is a watch-only wallet. You can receive and view, but you cannot send.',
+ loadSeedTitle: 'Load recovery phrase',
+ loadSeedDesc: "Enter this wallet's recovery phrase (or private key) to spend from it — it must match this wallet.",
+ loadSeedBtn: 'Load recovery phrase',
+ seedPlaceholder: 'Recovery phrase or xprv',
+ bip39PassphraseOpt: 'BIP39 passphrase (optional)',
+ alsoSaveDevice: 'Also save to this device',
+ enterSeedToSpend: 'Enter your recovery phrase.',
+ seedMismatch: "That recovery phrase doesn't match this wallet.",
+ seedNeedsPrivate: "That's a public key — enter the recovery phrase or private key to spend.",
+ seedInvalid: 'Not a valid recovery phrase or key.',
+ watchOnlySendTitle: 'Load your seed to send',
+ watchOnlySendDesc: "You're viewing this wallet without its seed, so you can't spend yet. Load your recovery phrase to send.",
watchXpubLabel: 'Account xpub or zpub',
labelOptional: 'Label (optional)',
walletLabelN: 'Wallet {n}',
@@ -85,6 +97,7 @@ const STR = {
wordN: 'Word #{n}',
reenterPassphrase: 'Re-enter passphrase',
back: '← Back',
+ cancel: 'Cancel',
openWallet: 'Open wallet',
wordsMismatch: 'Those words don’t match your phrase. Check your backup.',
passphraseMismatch: 'That passphrase doesn’t match the one you entered.',
diff --git a/src/wallet.js b/src/wallet.js
index e146b34..44694bc 100644
--- a/src/wallet.js
+++ b/src/wallet.js
@@ -47,6 +47,21 @@ export function newMnemonic(strengthBits = 128) {
return generateMnemonic(wordlist, strengthBits);
}
+// Account-level xpub for a seed/key without constructing a Wallet — used to
+// verify an entered recovery phrase matches a watch-only account before
+// upgrading it to spendable.
+export function accountXpubFor({ mnemonic, passphrase = '', xprv } = {}, netName = 'mainnet') {
+ const { coin } = NETS[netName];
+ let acct;
+ if (xprv) {
+ const node = HDKey.fromExtendedKey(xprv);
+ acct = node.depth === 0 ? node.derive(`m/84'/${coin}'/0'`) : node;
+ } else {
+ acct = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic, passphrase)).derive(`m/84'/${coin}'/0'`);
+ }
+ return acct.publicExtendedKey;
+}
+
export function isValidMnemonic(m) {
try {
return validateMnemonic(m.trim().replace(/\s+/g, ' '), wordlist);
@@ -1684,11 +1699,22 @@ export class Wallet {
this.loaded = true;
}
+ // A cache key derived from the account xpub (not the seed) — the same key a
+ // watch-only load of this wallet would use. A full wallet also writes its cache
+ // here so that, if the session is wiped and the wallet reopens as watch-only,
+ // its balance/history shows instantly instead of needing a rescan.
+ _xpubCacheKey() {
+ const bytes = new TextEncoder().encode(this.accountXpub());
+ return 'btc-wallet-cache:' + hex.encode(sha256(bytes)).slice(0, 32);
+ }
+
saveCache() {
const snap = this._snapshot();
this._savedAt = snap.savedAt;
try {
localStorage.setItem(this._cacheKey(), JSON.stringify(snap));
+ const xk = this._xpubCacheKey();
+ if (xk !== this._cacheKey()) localStorage.setItem(xk, JSON.stringify(snap)); // watch-only mirror
} catch {}
// Push to the configured relays too (debounced), so other devices get the
// update — unless cross-device sync is turned off.
@@ -1702,7 +1728,10 @@ export class Wallet {
restoreCache() {
try {
- const raw = localStorage.getItem(this._cacheKey());
+ let raw = localStorage.getItem(this._cacheKey());
+ // A wallet just upgraded from watch-only (seed re-entered) has no seed-keyed
+ // cache yet — fall back to the xpub mirror so it shows instantly.
+ if (!raw && !this.watchOnly) raw = localStorage.getItem(this._xpubCacheKey());
if (!raw) return false;
this._applySnapshot(JSON.parse(raw));
this.emit();