aboutsummaryrefslogtreecommitdiffstats
path: root/src/wallet.js
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 /src/wallet.js
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]>
Diffstat (limited to 'src/wallet.js')
-rw-r--r--src/wallet.js31
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();