aboutsummaryrefslogtreecommitdiffstats
path: root/src/wallet.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-06-24 14:41:36 -0700
committerAdam Soltys <[email protected]>2026-06-24 14:41:36 -0700
commitc81616ed0024c0cec693b552de5c5b6d7056eff3 (patch)
treea26b493f6590f3be33569c62f4f2769c60c32662 /src/wallet.js
parent3463af1d81c2712d657bbf30ecba98ffe5873729 (diff)
downloadcoinosv3-c81616ed0024c0cec693b552de5c5b6d7056eff3.tar.gz
coinosv3-c81616ed0024c0cec693b552de5c5b6d7056eff3.zip
Add account switching and watch-only wallets
Accounts: a switcher (header chip → Accounts screen) lets you keep several wallets loaded and flip between them without re-importing. Per the chosen model, full (seed) accounts live only in sessionStorage — ephemeral, no seed on disk; watch-only accounts (no seed) are persisted in localStorage and reload across restarts. enterWallet is refactored around an accounts list (add/switch/remove/lock); Add wallet reaches the unlock screen with a back button. Watch-only: load any wallet from its account xpub/zpub (parseAccountKey normalizes zpub→xpub; the existing public-derivation path yields the same addresses). Signing is blocked, the Send tab and seed/sync/offline cards are hidden, and a watch-only note is shown. Settings gains a "Public key" card (export this wallet's zpub) so it can be watched read-only elsewhere. Password-encrypted persistence for full accounts is the planned next step. New strings translated across all 19 locales. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Diffstat (limited to 'src/wallet.js')
-rw-r--r--src/wallet.js62
1 files changed, 58 insertions, 4 deletions
diff --git a/src/wallet.js b/src/wallet.js
index 39848e8..cacd313 100644
--- a/src/wallet.js
+++ b/src/wallet.js
@@ -11,7 +11,7 @@
import { HDKey } from '@scure/bip32';
import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from '@scure/bip39';
import { wordlist } from '@scure/bip39/wordlists/english';
-import { hex, base64urlnopad } from '@scure/base';
+import { hex, base64urlnopad, base58check } from '@scure/base';
import { sha256 } from '@noble/hashes/sha256';
import * as btc from '@scure/btc-signer';
import { p2wpkh } from '@scure/btc-signer/payment';
@@ -110,15 +110,18 @@ export class Wallet {
}
// --- setup --------------------------------------------------------------
- load({ mnemonic, passphrase = '', netName = 'mainnet', offline = false }) {
+ load({ mnemonic = '', passphrase = '', xpub = '', netName = 'mainnet', offline = false }) {
this.stopRealtime();
- this.mnemonic = mnemonic.trim().replace(/\s+/g, ' ');
+ this.mnemonic = (mnemonic || '').trim().replace(/\s+/g, ' ');
this.passphrase = passphrase;
+ this.xpub = xpub || '';
+ this.watchOnly = !!this.xpub && !this.mnemonic; // no seed = view/receive only
this.netName = netName;
this.offline = offline;
this.api = new Api(netName);
this.api.offline = offline;
this._account = null;
+ this._accountKey = null;
this._addrCache.clear();
this._reserved = null; // gift coins set aside from spending (lazy-loaded)
this._reclaimed = null; // gift coins freed for spending but link still live
@@ -169,6 +172,15 @@ export class Wallet {
// --- derivation ---------------------------------------------------------
account() {
+ // Watch-only wallets are loaded directly from the account public key.
+ if (this.watchOnly) {
+ if (!this._account || this._accountKey !== this.xpub) {
+ this._account = HDKey.fromExtendedKey(this.xpub);
+ this._accountKey = this.xpub;
+ this._addrCache.clear();
+ }
+ return this._account;
+ }
const key = `${this.netName}|${this.mnemonic}|${this.passphrase}`;
if (this._account && this._accountKey === key) return this._account;
const seed = mnemonicToSeedSync(this.mnemonic, this.passphrase);
@@ -180,6 +192,11 @@ export class Wallet {
return this._account;
}
+ // The account-level extended public key (xpub / zpub) for export → watch-only.
+ accountXpub() {
+ return this.account().publicExtendedKey;
+ }
+
node(chain, index) {
// Relative derivation from the account node (chain/index are non-hardened).
return this.account().deriveChild(chain).deriveChild(index);
@@ -633,6 +650,7 @@ export class Wallet {
// Sign every input of an already-built Transaction with the matching HD key.
sign(tx) {
+ if (this.watchOnly) throw new Error('Watch-only wallet — no keys to sign with.');
const byOutpoint = new Map();
for (const u of this.utxos) byOutpoint.set(utxoId(u), u);
for (let i = 0; i < tx.inputsLength; i++) {
@@ -1113,7 +1131,8 @@ export class Wallet {
// then shows the last-known balance/history instantly while a fresh scan
// runs in the background.
_cacheKey() {
- const bytes = new TextEncoder().encode(`${this.mnemonic}\n${this.passphrase}`);
+ const id = this.watchOnly ? this.xpub : `${this.mnemonic}\n${this.passphrase}`;
+ const bytes = new TextEncoder().encode(id);
return 'btc-wallet-cache:' + hex.encode(sha256(bytes)).slice(0, 32);
}
@@ -1291,6 +1310,41 @@ export function utxoId(u) {
return `${u.txid}:${u.vout}`;
}
+// Watch-only: accept a native-segwit account xpub or zpub and normalize it to
+// xpub version bytes (the key material is identical; only the prefix differs),
+// so HDKey can load it. Throws on anything else (private keys, wrong type).
+const _b58c = base58check(sha256);
+const _XPUB_VER = Uint8Array.from([0x04, 0x88, 0xb2, 0x1e]);
+const _ZPUB_VER = Uint8Array.from([0x04, 0xb2, 0x47, 0x46]);
+export function parseAccountKey(s) {
+ let data;
+ try {
+ data = _b58c.decode((s || '').trim());
+ } catch {
+ throw new Error('Not a valid extended public key.');
+ }
+ if (data.length !== 78) throw new Error('Not a valid extended public key.');
+ const ver = hex.encode(data.slice(0, 4));
+ if (ver !== hex.encode(_XPUB_VER) && ver !== hex.encode(_ZPUB_VER))
+ throw new Error('Use an account xpub or zpub (native segwit).');
+ const out = new Uint8Array(data);
+ out.set(_XPUB_VER, 0);
+ const xpub = _b58c.encode(out);
+ try {
+ HDKey.fromExtendedKey(xpub);
+ } catch {
+ throw new Error('Not a valid extended public key.');
+ }
+ return xpub;
+}
+
+// Convert a standard account xpub to a BIP84 zpub for export/interop.
+export function xpubToZpub(xpub) {
+ const data = new Uint8Array(_b58c.decode(xpub));
+ data.set(_ZPUB_VER, 0);
+ return _b58c.encode(data);
+}
+
// Gift-link claiming (sender side is Wallet.createGift). A gift code is a
// base64url PSBT: one SIGHASH_SINGLE-signed input + the sender's change output.
// previewGift reports the room available to the claimer (before their fee);