diff options
| author | Adam Soltys <[email protected]> | 2026-06-24 14:41:36 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-06-24 14:41:36 -0700 |
| commit | c81616ed0024c0cec693b552de5c5b6d7056eff3 (patch) | |
| tree | a26b493f6590f3be33569c62f4f2769c60c32662 /src/wallet.js | |
| parent | 3463af1d81c2712d657bbf30ecba98ffe5873729 (diff) | |
| download | coinosv3-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.js | 62 |
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); |
