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/wallet.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/wallet.js')
| -rw-r--r-- | src/wallet.js | 31 |
1 files changed, 30 insertions, 1 deletions
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(); |
