aboutsummaryrefslogtreecommitdiffstats
path: root/src/wallet.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-07-04 12:35:15 -0700
committerAdam Soltys <[email protected]>2026-07-04 12:35:15 -0700
commitcc2c05d2a1b7a9601eb3cb0df998483099e590cc (patch)
tree53f388790f8cca263c15d6d870a36ea283541995 /src/wallet.js
parentb7c5abd93868b97c25f52610510544764a3e44eb (diff)
downloadcoinosv3-cc2c05d2a1b7a9601eb3cb0df998483099e590cc.tar.gz
coinosv3-cc2c05d2a1b7a9601eb3cb0df998483099e590cc.zip
Features phase 2b: silent payments + nostr sync leave the core wallet
wallet.js drops from 2,374 to ~1,970 lines, holding only BIP84 on-chain logic plus generic feature seams. New seams added this pass: registerBalanceExtra extra confirmed/pending buckets registerInputProvider extra spendable inputs for buildTx registerSendPreparer destinations needing post-selection derivation registerInputSigner non-BIP84 input signing registerHistoryProvider / registerScanHook / registerRealtimeHook / registerLoadHook / registerCacheExtension / registerCacheSavedHook src/features/sp-wallet.js: the whole BIP-352 engine — key derivation, worker-accelerated indexer scanning, the live indexer WebSocket, SP outputs as spendable inputs, sp1 send preparation, and per-output-key signing — installs via those seams (installSpWallet). src/features/sync.js: nostr identity + encrypted cross-device state sync plus its Settings card, installed via load/cache-saved/realtime hooks. profileChip/nostrProfile move here-adjacent (into gifts, the only other nostr consumer), so core app.js no longer imports nostr at all. buildTx/sign are now feature-agnostic (input providers + preparers + signers); _recomputeBalance* use the balance-extra registry, so a core build with no SP no longer calls a missing spBalance() (that regression dropped the minimal build into offline mode — caught by the smoke test). New coverage: tools/sp-test.js drives two browser "devices" through an SP send, indexer discovery, and an SP-input spend on the ark regtest chain (needs tools/sp-indexer.js pointed at bitcoind:18543). Minimal smoke now also asserts no SP/nostr code ships. Minimal profile: 314KB, down from 412KB (full: 580KB). All suites green: minimal smoke, sp-test, ark UI, gift lifecycle. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01QFAcD4V284DvYZZHP7Rxxy
Diffstat (limited to 'src/wallet.js')
-rw-r--r--src/wallet.js569
1 files changed, 86 insertions, 483 deletions
diff --git a/src/wallet.js b/src/wallet.js
index 403bcd8..647db99 100644
--- a/src/wallet.js
+++ b/src/wallet.js
@@ -21,17 +21,15 @@ import * as btc from '@scure/btc-signer';
import { p2wpkh } from '@scure/btc-signer/payment';
import { concatBytes } from '@scure/btc-signer/utils.js';
-import { Api, pool, getBackend, explorerWeb, electrumCandidates, spIndexerUrl } from './api.js';
+import { Api, pool, getBackend, explorerWeb, electrumCandidates } from './api.js';
import { ElectrumApi } from './electrum.js';
-import { NostrSync, getSyncConfig, npubOf } from './nostr.js';
-import { isSilentPaymentAddress, decodeSilentPaymentAddress, silentPaymentScripts, silentPaymentPlaceholder, deriveSilentPaymentKeys, encodeSilentPaymentAddress, silentPaymentScan, silentPaymentOutputPrivKey, silentPaymentCandidate, bloomHas } from './silentpay.js';
import { schnorr } from '@noble/curves/secp256k1';
// Resolve a same-origin '/path' WebSocket URL to an absolute ws(s):// URL against
// the page. The regtest backends are proxied through the dev server as relative
// paths (e.g. '/electrum') so the app works from a phone over the LAN/Tailscale;
// absolute URLs (real deployments, tests) pass through untouched.
-function absWsUrl(u) {
+export function absWsUrl(u) {
if (u && u[0] === '/' && typeof location !== 'undefined') {
return (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + u;
}
@@ -53,9 +51,8 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Silent-payment catch-up dust limit (sats): the indexer drops tweaks for txs
// whose taproot outputs are all below this, cutting the candidate math on a big
// scan. 0 = receive everything (default, so no small payment is ever missed).
-const SP_DUST_LIMIT = 0;
-const NETS = {
+export const NETS = {
mainnet: { net: btc.NETWORK, coin: 0 },
testnet: { net: btc.TEST_NETWORK, coin: 1 },
// mutinynet is a signet — same address format / version bytes / coin type as
@@ -108,6 +105,19 @@ export class Wallet {
// Feature-registered providers of coin ids to exclude from spending and
// the spendable balance (e.g. coins reserved behind unclaimed gift links).
this._coinLocks = [];
+ // Further optional-feature registries (see the seams section below): extra
+ // balance buckets, extra spendable inputs, send-address preparers, input
+ // signers, history rows, scan/realtime/load hooks, cache extensions.
+ this._balanceExtras = [];
+ this._inputProviders = [];
+ this._sendPreparers = [];
+ this._inputSigners = [];
+ this._historyProviders = [];
+ this._scanHooks = [];
+ this._realtimeHooks = [];
+ this._loadHooks = [];
+ this._cacheExtensions = [];
+ this._cacheSavedHooks = [];
this.api = new Api('mainnet');
this.offline = false;
@@ -122,13 +132,6 @@ export class Wallet {
this.feeRates = null;
this.confirmed = 0;
this.pending = 0;
- // Silent-payment (BIP-352) receiving: outputs we found by scanning the
- // indexer. Kept in their own bucket (one-time taproot keys, not BIP84
- // addresses); { txid, vout, value, confirmed, tweak, xonly, address }.
- this.spUtxos = [];
- this.lastSpScan = 0; // highest block height scanned for silent payments
- this.spScanning = false;
-
this.scanning = false;
this.loaded = false; // true once a scan/snapshot has populated balances once
this.historyLoading = false; // history is still being fetched in the background
@@ -154,10 +157,7 @@ export class Wallet {
this._lastMsg = 0; // time of last WS message (incl. pong)
this._wakeHooked = false;
- // Encrypted cross-device state sync over Nostr.
- this.nostr = new NostrSync();
this._savedAt = 0;
- this._nostrPubTimer = null;
this._subs = new Set();
}
@@ -176,7 +176,7 @@ export class Wallet {
this.stopRealtime();
// A freshly-generated wallet can't have received silent payments before it
// existed, so we start its SP watermark at the current tip (no history scan).
- this._spFresh = spFresh;
+
this.mnemonic = (mnemonic || '').trim().replace(/\s+/g, ' ');
this.passphrase = passphrase;
this.xpub = xpub || '';
@@ -193,8 +193,7 @@ export class Wallet {
this._reclaimed = null; // gift coins freed for spending but link still live
this._savedAt = 0;
try {
- if (this.mnemonic) this.nostr.load(this.mnemonic, this.passphrase);
- else this.nostr.unload();
+ for (const fn of this._loadHooks) { try { fn({ spFresh, netName, offline }); } catch {} }
} catch {}
this.reset();
}
@@ -207,8 +206,6 @@ export class Wallet {
this.txs = [];
this.confirmed = 0;
this.pending = 0;
- this.spUtxos = [];
- this.lastSpScan = 0;
this.loaded = false;
this.nextReceiveIndex = 0;
this.nextChangeIndex = 0;
@@ -286,166 +283,26 @@ export class Wallet {
return this.account().publicExtendedKey;
}
- // BIP-352 scan/spend keys, derived from the master seed (m/352' paths) — so
- // they need the mnemonic (or a depth-0 master xprv); watch-only and account-
- // level imported keys can't do silent payments. Cached per account.
- silentPaymentKeys() {
- if (this.watchOnly) return null;
- const coin = NETS[this.netName].coin;
- const ck = `sp|${this.netName}|${this.mnemonic}|${this.passphrase}|${this.xprv}`;
- if (this._spKeys && this._spKeysKey === ck) return this._spKeys;
- let master = null;
- if (this.mnemonic) master = HDKey.fromMasterSeed(mnemonicToSeedSync(this.mnemonic, this.passphrase));
- else if (this.xprv) { const n = HDKey.fromExtendedKey(this.xprv); if (n.depth === 0) master = n; }
- this._spKeys = master ? deriveSilentPaymentKeys(master, coin) : null;
- this._spKeysKey = ck;
- this._spScanCache = new Map(); // per-tweak scan results are keyed to these keys
- this._spCandCache = new Map();
- return this._spKeys;
- }
- // Scan one tweak's outputs for our payments, memoized by tweak — the EC
- // point-multiplication is the cost, and a tweak's outcome never changes, so
- // repeated mempool pushes (which re-send the same tweaks) stay cheap.
- _spScan(tweakHex, outputs) {
- const keys = this.silentPaymentKeys(); // resets the caches if keys changed
- if (!keys) return [];
- let m = this._spScanCache.get(tweakHex);
- if (m) return m;
- if (this._spScanCache.size > 100000) this._spScanCache.clear(); // bound memory over long sessions
- m = silentPaymentScan({ scanPriv: keys.scanPriv, spendPub: keys.spendPub, tweak: hex.decode(tweakHex), outputs });
- this._spScanCache.set(tweakHex, m);
- return m;
- }
- // Our candidate key for a tweak (for the Bloom pre-filter), memoized likewise.
- _spCandidate(tweakHex) {
- const keys = this.silentPaymentKeys();
- if (!keys) return null;
- let c = this._spCandCache.get(tweakHex);
- if (c) return c;
- if (this._spCandCache.size > 100000) this._spCandCache.clear(); // bound memory over long sessions
- c = silentPaymentCandidate({ scanPriv: keys.scanPriv, spendPub: keys.spendPub, tweak: hex.decode(tweakHex) });
- this._spCandCache.set(tweakHex, c);
- return c;
- }
- // Spawn (once) the scanning worker and keep its keys current. Returns null if
- // workers are unavailable or the embedded bundle is missing — callers then fall
- // back to scanning on the main thread (still cached + yielding).
- _ensureSpWorker() {
- if (this._spWorkerFailed || typeof Worker === 'undefined') return null;
- const keys = this.silentPaymentKeys();
- if (!keys) return null;
- if (!this._spWorker) {
- try {
- const b64 = globalThis.__SP_WORKER__;
- if (!b64) { this._spWorkerFailed = true; return null; }
- const bytes = Uint8Array.from(atob(b64), (ch) => ch.charCodeAt(0));
- const w = new Worker(URL.createObjectURL(new Blob([bytes], { type: 'text/javascript' })));
- this._spPending = new Map();
- this._spReqId = 0;
- w.onmessage = (e) => { const p = this._spPending.get(e.data.id); if (p) { this._spPending.delete(e.data.id); e.data.error ? p.rej(new Error(e.data.error)) : p.res(e.data); } };
- w.onerror = () => { this._spWorkerFailed = true; try { w.terminate(); } catch {} this._spWorker = null; for (const p of this._spPending.values()) p.rej(new Error('sp worker error')); this._spPending.clear(); };
- this._spWorker = w;
- this._spWorkerKeysSig = null;
- } catch { this._spWorkerFailed = true; return null; }
- }
- if (this._spWorkerKeysSig !== this._spKeysKey) {
- this._spWorkerKeysSig = this._spKeysKey;
- this._spWorker.postMessage({ id: ++this._spReqId, op: 'keys', scanPriv: keys.scanPriv, spendPub: keys.spendPub });
- }
- return this._spWorker;
- }
- _spCall(op, data) {
- return new Promise((res, rej) => {
- const id = ++this._spReqId;
- this._spPending.set(id, { res, rej });
- this._spWorker.postMessage({ id, op, ...data });
- setTimeout(() => { if (this._spPending.has(id)) { this._spPending.delete(id); rej(new Error('sp worker timeout')); } }, 30000);
- });
- }
- // Block heights whose Bloom filter might hold one of our outputs (worker if
- // available, else a cached + yielding main-thread loop).
- async _spHitBlocks(blocks) {
- if (!blocks.length) return new Set();
- const w = this._ensureSpWorker();
- if (w) { try { return new Set((await this._spCall('candidates', { blocks })).hits || []); } catch {} }
- const hits = new Set();
- let i = 0;
- for (const b of blocks) {
- if (++i % 8 === 0) await new Promise((r) => setTimeout(r, 0));
- for (const tw of b.tweaks) { if (bloomHas(b.filter, this._spCandidate(tw))) { hits.add(b.height); break; } }
- }
- return hits;
- }
- // Full-scan items → our resolved utxos {txid,vout,value,xonly,tweak} (worker if
- // available, else a cached + yielding main-thread loop).
- async _spScanItems(items) {
- if (!items.length) return [];
- const w = this._ensureSpWorker();
- if (w) { try { return (await this._spCall('scan', { items })).found || []; } catch {} }
- const found = [];
- let i = 0;
- for (const it of items) {
- if (++i % 50 === 0) await new Promise((r) => setTimeout(r, 0));
- const matches = this._spScan(it.tweak, it.outputs.map((o) => o.xonly));
- for (const m of matches) { const o = it.outputs.find((x) => x.xonly === m.output); if (o) found.push({ txid: it.txid, vout: o.vout, value: o.value, xonly: m.output, tweak: m.tweak }); }
- }
- return found;
- }
- // Our silent-payment address (sp1…/tsp1…), or null if this wallet can't do SP.
- silentPaymentAddress() {
- const k = this.silentPaymentKeys();
- return k ? encodeSilentPaymentAddress(k.scanPub, k.spendPub, { testnet: this.netName !== 'mainnet' }) : null;
- }
// True if addr is a valid on-chain address for this wallet's network.
isOnchainAddress(addr) {
try { btc.Address(this.netCfg.net).decode((addr || '').trim()); return true; } catch { return false; }
}
- // Is SP receiving available here? (we can derive keys AND an indexer is set)
- silentPaymentsAvailable() {
- return !!(this.silentPaymentKeys() && spIndexerUrl(this.netName));
- }
- // Total received via silent payments: { confirmed, pending, count } over the
- // unspent SP outputs we've found.
- spBalance() {
- let confirmed = 0, pending = 0;
- for (const u of this.spUtxos) { if (u.confirmed) confirmed += u.value; else pending += u.value; }
- return { confirmed, pending, count: this.spUtxos.length };
- }
- // Received silent payments as history rows (one per tx; multiple outputs to us
- // sum into one received entry) so they appear in history like normal payments.
- spTxRows() {
- const byTx = new Map();
- for (const u of this.spUtxos) {
- const e = byTx.get(u.txid) || { txid: u.txid, net: 0, confirmed: true, firstSeen: u.firstSeen || Date.now() };
- e.net += u.value;
- if (!u.confirmed) e.confirmed = false;
- e.firstSeen = Math.min(e.firstSeen, u.firstSeen || e.firstSeen);
- byTx.set(u.txid, e);
- }
- return [...byTx.values()].map((e) => ({
- txid: e.txid, net: e.net, fee: 0, vsize: 0, sp: true,
- confirmed: e.confirmed,
- blockTime: e.confirmed ? Math.floor(e.firstSeen / 1000) : 0,
- blockHeight: 0,
- firstSeen: e.firstSeen,
- }));
- }
// History for display: BIP84 txs + silent-payment receipts, newest first. (SP
// receipts pay one-time taproot keys, never our BIP84 addresses, so no overlap.)
get history() {
- const rows = [...this.txs, ...this.spTxRows()];
+ const rows = [...this.txs, ...this._historyProviders.flatMap((fn) => fn())];
rows.sort((a, b) => {
if (a.confirmed !== b.confirmed) return a.confirmed ? 1 : -1; // pending first
if (a.confirmed) return (b.blockTime || 0) - (a.blockTime || 0);
@@ -454,185 +311,12 @@ export class Wallet {
return rows;
}
- // The taproot address for an x-only output key (for backend UTXO lookups).
- _spAddress(xonly) {
- return btc.Address(this.netCfg.net).encode(btc.OutScript.decode(btc.OutScript.encode({ type: 'tr', pubkey: hex.decode(xonly) })));
- }
- // Scan the configured SP indexer from the last-scanned height to the tip, find
- // outputs paying our scan/spend keys, and verify each is unspent via the chain
- // backend (the indexer only does discovery). Updates this.spUtxos.
- async scanSilentPayments({ rescan = false, silent = false } = {}) {
- const keys = this.silentPaymentKeys();
- const indexer = spIndexerUrl(this.netName);
- if (!keys || !indexer || this.offline) return { unavailable: true };
- if (this._spScanBusy) return { busy: true };
- // Fresh wallet, never scanned: set the watermark to the current tip instead of
- // scanning all history (nothing predates the wallet). New blocks + mempool
- // arrive via the live push from here on.
- if (this._spFresh && !this.lastSpScan && !rescan) {
- this._spFresh = false;
- try { this.lastSpScan = (await (await fetch(`${indexer}/height`)).json()).height || 0; this.saveCache(); } catch {}
- return { fresh: true };
- }
- this._spScanBusy = true;
- // Only a manual scan shows the visible "Scanning…" state; auto-scans are
- // invisible (setting spScanning for them left the card stuck on "Scanning…").
- if (!silent) { this.spScanning = true; this.emit(); }
- try {
- if (rescan) { this.spUtxos = []; this.lastSpScan = 0; }
- const from = (this.lastSpScan || 0) + 1;
- // Catch-up: per block we get tweaks + a Bloom filter (no outputs). Derive a
- // candidate key per tweak, test the filter, and only fetch the full block on
- // a hit — so most blocks cost just the candidate math, no download/scan.
- const res = await (await fetch(`${indexer}/scan/${from}?dustLimit=${SP_DUST_LIMIT}`)).json();
- const tip = res.tip || 0;
- const have = new Set(this.spUtxos.map((u) => `${u.txid}:${u.vout}`));
- const found = [];
- const hits = await this._spHitBlocks(res.blocks || []);
- for (const block of res.blocks || []) {
- if (!hits.has(block.height)) continue;
- const blk = await (await fetch(`${indexer}/block/${block.height}`)).json();
- for (const f of await this._spScanItems(blk.items || [])) {
- const id = `${f.txid}:${f.vout}`;
- if (have.has(id)) continue;
- have.add(id);
- found.push(f);
- }
- }
- if (tip > this.lastSpScan) this.lastSpScan = tip;
- // Verify newly-found outputs are unspent + get confirmation status.
- for (const f of found) {
- const address = this._spAddress(f.xonly);
- try {
- const us = await this.api.addressUtxos(address);
- const u = us.find((x) => x.txid === f.txid && x.vout === f.vout);
- if (u) this.spUtxos.push({ ...f, address, confirmed: !!(u.status && u.status.confirmed), firstSeen: Date.now() });
- } catch {}
- }
- await this._refreshSpUtxos();
- this._recomputeBalanceFromChains(); // fold found SP value into the displayed balance
- this.saveCache();
- return { found: found.length, scanned: tip };
- } finally {
- this._spScanBusy = false;
- this.spScanning = false;
- this.emit(); // always refresh (balance + clears any "Scanning…")
- }
- }
- // Re-check tracked SP outputs against the backend: drop spent ones, refresh
- // confirmation status.
- async _refreshSpUtxos() {
- const next = [];
- for (const u of this.spUtxos) {
- try {
- const us = await this.api.addressUtxos(u.address);
- const live = us.find((x) => x.txid === u.txid && x.vout === u.vout);
- if (live) next.push({ ...u, confirmed: !!(live.status && live.status.confirmed) });
- } catch { next.push(u); } // backend hiccup: keep as-is
- }
- this.spUtxos = next;
- }
- // Live silent-payment push: subscribe to the indexer's WebSocket so each new
- // block's SP items are scanned on arrival instead of polling. Catches up via a
- // /scan on connect (covers anything missed while disconnected) and reconnects.
- _spConnect() {
- if (this.offline) return;
- const base = spIndexerUrl(this.netName);
- if (!base || !this.silentPaymentKeys()) return;
- const url = base.replace(/^http/, 'ws') + '/ws';
- if (this._spWs && this._spWsUrl === url) return;
- this._spDisconnect();
- this._spWsUrl = url;
- let ws;
- try { ws = new WebSocket(absWsUrl(url)); } catch { return; }
- this._spWs = ws;
- ws.onopen = () => {
- this.spLive = true;
- this.scanSilentPayments({ silent: true }).catch(() => {}); // confirmed catch-up
- // Pending (mempool) catch-up.
- fetch(`${base}/mempool`).then((r) => r.json()).then((d) => this._scanSpMempool(d.items || [])).catch(() => {});
- };
- ws.onmessage = (ev) => {
- let msg;
- try { msg = JSON.parse(typeof ev.data === 'string' ? ev.data : new TextDecoder().decode(ev.data)); } catch { return; }
- if (!msg || !Array.isArray(msg.items)) return;
- if (msg.type === 'block') this._scanSpItems(msg.items, msg.height).catch(() => {});
- else if (msg.type === 'mempool') this._scanSpMempool(msg.items).catch(() => {});
- };
- ws.onerror = () => { try { ws.close(); } catch {} };
- ws.onclose = () => {
- if (this._spWs !== ws) return;
- this.spLive = false; this._spWs = null;
- if (!this.offline) this._spReconnect = setTimeout(() => this._spConnect(), 3000);
- };
- }
- _spDisconnect() {
- clearTimeout(this._spReconnect);
- this.spLive = false;
- const ws = this._spWs;
- this._spWs = null; this._spWsUrl = null;
- if (ws) { try { ws.onclose = null; ws.close(); } catch {} }
- }
- // Scan a confirmed block's pushed items and upsert ours into spUtxos as
- // confirmed (a previously-pending mempool entry is promoted to confirmed).
- async _scanSpItems(items, height) {
- const keys = this.silentPaymentKeys();
- if (!keys) return;
- let changed = false;
- for (const f of await this._spScanItems(items)) {
- const existing = this.spUtxos.find((u) => u.txid === f.txid && u.vout === f.vout);
- if (existing) { if (!existing.confirmed) { existing.confirmed = true; changed = true; } }
- else { this.spUtxos.push({ txid: f.txid, vout: f.vout, value: f.value, xonly: f.xonly, tweak: f.tweak, address: this._spAddress(f.xonly), confirmed: true, firstSeen: Date.now() }); changed = true; }
- }
- if (height && height > this.lastSpScan) this.lastSpScan = height;
- if (changed) this._recomputeBalanceFromChains();
- this.saveCache();
- if (changed) this.emit();
- }
- // Scan the current mempool's pushed items and rebuild our pending (unconfirmed)
- // SP set — so payments show the moment they're broadcast. Confirmed entries are
- // kept; the unconfirmed set is replaced, which also drops vanished/RBF'd ones.
- async _scanSpMempool(items) {
- // Coalesce: pushes arrive faster than a cold (mainnet) scan completes, so
- // keep only the latest set and never run two scans at once.
- this._spMempoolLatest = items;
- if (this._spMempoolBusy) return;
- this._spMempoolBusy = true;
- try {
- if (!this.silentPaymentKeys()) return;
- while (this._spMempoolLatest) {
- const cur = this._spMempoolLatest;
- this._spMempoolLatest = null;
- const priorSeen = new Map(this.spUtxos.map((u) => [`${u.txid}:${u.vout}`, u.firstSeen]));
- const confirmedIds = new Set(this.spUtxos.filter((u) => u.confirmed).map((u) => `${u.txid}:${u.vout}`));
- const pending = [];
- for (const f of await this._spScanItems(cur)) {
- const id = `${f.txid}:${f.vout}`;
- if (confirmedIds.has(id)) continue;
- pending.push({ txid: f.txid, vout: f.vout, value: f.value, xonly: f.xonly, tweak: f.tweak, address: this._spAddress(f.xonly), confirmed: false, firstSeen: priorSeen.get(id) || Date.now() });
- }
- // Re-read confirmed (a block push may have landed during a yield) and drop
- // any pending that just confirmed, so an outpoint never appears twice.
- const confNow = this.spUtxos.filter((u) => u.confirmed);
- const confNowIds = new Set(confNow.map((u) => `${u.txid}:${u.vout}`));
- const oldPendingIds = new Set(this.spUtxos.filter((u) => !u.confirmed).map((u) => `${u.txid}:${u.vout}`));
- const freshPending = pending.filter((p) => !confNowIds.has(`${p.txid}:${p.vout}`));
- // Only emit when our pending set actually changed — the mempool churns
- // constantly, and a no-op emit would re-render and steal input focus.
- const changed = freshPending.length !== oldPendingIds.size || freshPending.some((p) => !oldPendingIds.has(`${p.txid}:${p.vout}`));
- this.spUtxos = [...confNow, ...freshPending];
- if (changed) { this._recomputeBalanceFromChains(); this.saveCache(); this.emit(); }
- }
- } finally {
- this._spMempoolBusy = false;
- }
- }
node(chain, index) {
// Relative derivation from the account node (chain/index are non-hardened).
@@ -995,7 +679,7 @@ export class Wallet {
}
this.loaded = true;
this.saveCache();
- this.scanSilentPayments({ silent: true }).catch(() => {}); // fold in any silent-payment receipts
+ for (const fn of this._scanHooks) { try { fn({ silent: true }); } catch {} }
} finally {
this._refreshing = false;
this.historyLoading = false; // clear even if an earlier step threw
@@ -1147,14 +831,21 @@ export class Wallet {
if (res.has(utxoId(u))) return s;
if (u.confirmed || u.chain === 1) return s + u.value;
return s;
- }, 0) + this.spBalance().confirmed; // confirmed silent-payment receipts are spendable
+ }, 0) + this._balanceExtras.reduce((n, fn) => n + (fn().confirmed || 0), 0);
}
// Unconfirmed incoming receives (not our change, not gift-locked) — shown as a
// separate pending line, kept out of the spendable headline. Includes pending
// (mempool) silent-payment receipts.
get pendingIncoming() {
const res = this.lockedCoinIds();
- return this.utxos.reduce((s, u) => s + ((!u.confirmed && u.chain === 0 && !res.has(utxoId(u))) ? u.value : 0), 0) + this.spBalance().pending;
+ return this.utxos.reduce((s, u) => s + ((!u.confirmed && u.chain === 0 && !res.has(utxoId(u))) ? u.value : 0), 0)
+ + this._balanceExtras.reduce((n, fn) => n + (fn().pending || 0), 0);
+ }
+
+ _balanceExtra() {
+ let confirmed = 0, pending = 0;
+ for (const fn of this._balanceExtras) { const b = fn(); confirmed += b.confirmed || 0; pending += b.pending || 0; }
+ return { confirmed, pending };
}
_recomputeBalanceFromChains() {
@@ -1164,15 +855,15 @@ export class Wallet {
c += a.confirmed || 0;
p += a.pending || 0;
}
- const sp = this.spBalance();
- this.confirmed = c + sp.confirmed;
- this.pending = p + sp.pending;
+ const ex = this._balanceExtra();
+ this.confirmed = c + ex.confirmed;
+ this.pending = p + ex.pending;
}
_recomputeBalanceFromUtxos() {
- const sp = this.spBalance();
- this.confirmed = this.utxos.reduce((s, u) => s + (u.confirmed ? u.value : 0), 0) + sp.confirmed;
- this.pending = this.utxos.reduce((s, u) => s + (u.confirmed ? 0 : u.value), 0) + sp.pending;
+ const ex = this._balanceExtra();
+ this.confirmed = this.utxos.reduce((s, u) => s + (u.confirmed ? u.value : 0), 0) + ex.confirmed;
+ this.pending = this.utxos.reduce((s, u) => s + (u.confirmed ? 0 : u.value), 0) + ex.pending;
}
freshReceive() {
@@ -1190,16 +881,10 @@ export class Wallet {
const pool_ = coinIds
? this.utxos.filter((u) => coinIds.includes(utxoId(u)))
: (() => { const locked = this.lockedCoinIds(); return this.utxos.filter((u) => !locked.has(utxoId(u))); })(); // skip locked coins (gift reservations, ...)
- // Received silent-payment outputs are spendable too — one-time taproot keys,
- // signed by sign() with the per-output key. Excluded when paying an sp1…
- // recipient (funding an SP send from a taproot input needs BIP-352 even-Y
- // key handling we don't do yet), so those sends use BIP84 coins.
- // Confirmed SP coins are spendable like any other coin — including to fund a
- // silent payment. Unconfirmed ones are excluded (the sender's tx could be
- // replaced, invalidating our spend).
- const confirmedSp = this.spUtxos.filter((u) => u.confirmed);
- const spPool = coinIds ? confirmedSp.filter((u) => coinIds.includes(utxoId(u))) : confirmedSp.slice();
- if (!pool_.length && !spPool.length) throw new Error('No spendable coins selected.');
+ // Feature coins (e.g. received silent-payment outputs) join the pool as
+ // ready-made inputs; their signing is handled by registered input signers.
+ const extraInputs = this._inputProviders.flatMap((fn) => fn({ coinIds }));
+ if (!pool_.length && !extraInputs.length) throw new Error('No spendable coins selected.');
const inputs = [
...pool_.map((u) => {
@@ -1213,27 +898,17 @@ export class Wallet {
witnessUtxo: { script: pay.script, amount: BigInt(u.value) },
};
}),
- ...spPool.map((u) => ({
- txid: u.txid,
- index: u.vout,
- sequence: 0xfffffffd,
- witnessUtxo: { script: btc.OutScript.encode({ type: 'tr', pubkey: hex.decode(u.xonly) }), amount: BigInt(u.value) },
- // Present so the fee estimator sizes a key-path (64-byte) witness; the
- // value is unused (SP outputs are signed raw in sign(), via tapKeySig).
- tapInternalKey: hex.decode(u.xonly),
- })),
+ ...extraInputs,
];
const changeAddress = this.freshChange().address;
const feePerByte = BigInt(Math.max(1, Math.round(feeRate)));
- // Silent payment (sp1…) recipients: the real taproot output can only be
- // derived once coin selection fixes the input set (it commits to every
- // input), so we select against a same-size placeholder taproot output and
- // swap in the derived script afterward.
- const spOuts = []; // { placeholder, scan, spend }
- if (recipients.some((r) => isSilentPaymentAddress(r.address)) && this.watchOnly)
- throw new Error('Silent payments require a wallet with keys.');
+ // Send-address preparers (e.g. silent payments): destinations whose real
+ // output can only be derived once coin selection fixes the input set select
+ // against a placeholder and swap the derived script in afterward.
+ const prepSessions = this._sendPreparers.map((fn) => fn(this));
+ const sessionFor = (addr) => prepSessions.find((s) => s.match(addr));
if (sendMax) {
if (recipients.length !== 1)
@@ -1241,12 +916,8 @@ export class Wallet {
// Drain everything: use the recipient as the "change" address with no
// fixed outputs, so the estimator sends (total − exact fee) to them.
let dest = recipients[0].address;
- if (isSilentPaymentAddress(dest)) {
- const { scan, spend } = decodeSilentPaymentAddress(dest);
- const placeholder = silentPaymentPlaceholder(0);
- spOuts.push({ placeholder, scan, spend, address: dest });
- dest = btc.Address(this.netCfg.net).encode(btc.OutScript.decode(placeholder));
- }
+ const ds = sessionFor(dest);
+ if (ds) dest = btc.Address(this.netCfg.net).encode(btc.OutScript.decode(ds.prepare(dest)));
const sel = btc.selectUTXO(inputs, [], 'all', {
changeAddress: dest,
feePerByte,
@@ -1256,19 +927,15 @@ export class Wallet {
disableScriptCheck: true,
});
if (!sel || !sel.tx) throw new Error('Fee exceeds balance.');
- if (spOuts.length) this._applySilentPayments(sel.tx, spOuts);
+ for (const s of prepSessions) s.apply(sel.tx);
const sweepSummary = summarize(sel, this.netCfg.net);
- this._tagSilentOutputs(sweepSummary, spOuts);
+ for (const s of prepSessions) s.tag(sweepSummary);
return sweepSummary;
}
const outputs = recipients.map((r) => {
- if (isSilentPaymentAddress(r.address)) {
- const { scan, spend } = decodeSilentPaymentAddress(r.address);
- const placeholder = silentPaymentPlaceholder(spOuts.length);
- spOuts.push({ placeholder, scan, spend, address: r.address });
- return { script: placeholder, amount: BigInt(r.amount) };
- }
+ const s = sessionFor(r.address);
+ if (s) return { script: s.prepare(r.address), amount: BigInt(r.amount) };
return { address: r.address, amount: BigInt(r.amount) };
});
const strategy = coinIds ? 'all' : 'default';
@@ -1285,53 +952,13 @@ export class Wallet {
});
if (!sel || !sel.tx)
throw new Error('Insufficient funds for amount + fee.');
- if (spOuts.length) this._applySilentPayments(sel.tx, spOuts);
+ for (const s of prepSessions) s.apply(sel.tx);
const summary = summarize(sel, this.netCfg.net);
- this._tagSilentOutputs(summary, spOuts);
+ for (const s of prepSessions) s.tag(summary);
return summary;
}
- // Mark summary outputs that fund a silent payment with the sp1… address the
- // user actually typed (the on-chain output is a derived one-time taproot addr).
- _tagSilentOutputs(summary, spOuts) {
- for (const s of spOuts) {
- if (!s.derivedAddress) continue;
- const o = summary.outputs.find((o) => o.address === s.derivedAddress && !o.silent);
- if (o) o.silent = s.address;
- }
- }
- // Replace each placeholder silent-payment output in a freshly-selected (still
- // unsigned) tx with its real one-time taproot script, derived from the tx's
- // actual inputs per BIP-352.
- _applySilentPayments(tx, spOuts) {
- const byOutpoint = new Map();
- for (const u of this.utxos) byOutpoint.set(utxoId(u), { u, sp: false });
- for (const u of this.spUtxos) byOutpoint.set(utxoId(u), { u, sp: true });
- const spKeys = this.silentPaymentKeys();
- const inputs = [];
- for (let i = 0; i < tx.inputsLength; i++) {
- const inp = tx.getInput(i);
- const e = byOutpoint.get(`${hex.encode(inp.txid)}:${inp.index}`);
- if (!e) throw new Error('Missing key for a silent payment input.');
- // Our own SP coins can fund a silent payment too — they're taproot inputs
- // whose key is d = spend_priv + t_k (committed to with even Y by the script).
- if (e.sp) {
- if (!spKeys) throw new Error('Missing keys for a silent payment input.');
- inputs.push({ txid: e.u.txid, vout: e.u.vout, priv: silentPaymentOutputPrivKey(spKeys.spendPriv, hex.decode(e.u.tweak)), taproot: true });
- } else {
- inputs.push({ txid: e.u.txid, vout: e.u.vout, priv: this.node(e.u.chain, e.u.index).privateKey });
- }
- }
- const scripts = silentPaymentScripts(inputs, spOuts.map((s) => ({ scan: s.scan, spend: s.spend })));
- spOuts.forEach((s, k) => {
- const ph = hex.encode(s.placeholder);
- const idx = tx.outputs.findIndex((o) => hex.encode(o.script) === ph);
- if (idx < 0) throw new Error('Silent payment output not found after selection.');
- tx.updateOutput(idx, { script: scripts[k], amount: tx.outputs[idx].amount }, true);
- s.derivedAddress = btc.Address(this.netCfg.net).encode(btc.OutScript.decode(scripts[k]));
- });
- }
// True if a built tx spends any of our still-unconfirmed coins — meaning it
// can't confirm until that parent (ancestor) transaction confirms first.
@@ -1353,9 +980,7 @@ export class Wallet {
if (this.watchOnly) throw new Error('Watch-only wallet — no keys to sign with.');
const bip84 = new Map();
for (const u of this.utxos) bip84.set(utxoId(u), u);
- const sp = new Map();
- for (const u of this.spUtxos) sp.set(utxoId(u), u);
- // Prevout scripts + amounts for any taproot (SP) sighash.
+ // Prevout scripts + amounts, for any signer that needs a taproot sighash.
const prevScripts = [];
const amounts = [];
for (let i = 0; i < tx.inputsLength; i++) {
@@ -1363,18 +988,15 @@ export class Wallet {
prevScripts.push(w.script);
amounts.push(w.amount);
}
- const spKeys = sp.size ? this.silentPaymentKeys() : null;
+ const signCtx = { prevScripts, amounts };
for (let i = 0; i < tx.inputsLength; i++) {
const inp = tx.getInput(i);
const id = `${hex.encode(inp.txid)}:${inp.index}`;
if (bip84.has(id)) {
const u = bip84.get(id);
tx.signIdx(this.node(u.chain, u.index).privateKey, i);
- } else if (sp.has(id)) {
- const u = sp.get(id);
- const d = silentPaymentOutputPrivKey(spKeys.spendPriv, hex.decode(u.tweak));
- const hash = tx.preimageWitnessV1(i, prevScripts, btc.SigHash.DEFAULT, amounts);
- tx.updateInput(i, { tapKeySig: schnorr.sign(hash, d) }, true);
+ } else if (this._inputSigners.some((fn) => fn(tx, i, id, signCtx))) {
+ // signed by a feature (e.g. a received silent-payment output)
} else {
throw new Error(`Cannot find key for input ${id}`);
}
@@ -1508,6 +1130,29 @@ export class Wallet {
// Coin locks: features contribute sets of coin ids that spending and the
// balance treat as unavailable.
registerCoinLock(fn) { this._coinLocks.push(fn); }
+ // Extra balance buckets: fn() -> { confirmed, pending } folded into the
+ // spendable / pending headline figures.
+ registerBalanceExtra(fn) { this._balanceExtras.push(fn); }
+ // Extra spendable inputs for buildTx: fn({ coinIds }) -> scure input objects.
+ registerInputProvider(fn) { this._inputProviders.push(fn); }
+ // Send-address preparers: fn(wallet) -> per-build session with
+ // match(addr) / prepare(addr) -> placeholderScript / apply(tx) / tag(summary),
+ // for destinations whose real output can only be derived after coin selection.
+ registerSendPreparer(fn) { this._sendPreparers.push(fn); }
+ // Input signers: fn(tx, i, id, { prevScripts, amounts }) -> true if signed.
+ registerInputSigner(fn) { this._inputSigners.push(fn); }
+ // Extra history rows (merged + sorted with on-chain txs).
+ registerHistoryProvider(fn) { this._historyProviders.push(fn); }
+ // Called at the end of every scan/refresh pass.
+ registerScanHook(fn) { this._scanHooks.push(fn); }
+ // start()/stop() with the realtime backend; poll() on its heartbeat.
+ registerRealtimeHook(h) { this._realtimeHooks.push(h); }
+ // Called from load() with its options (wallet switch / (re)open).
+ registerLoadHook(fn) { this._loadHooks.push(fn); }
+ // save() -> object merged into the cache snapshot; load(d) restores.
+ registerCacheExtension(e) { this._cacheExtensions.push(e); }
+ // Called with every saved state snapshot (e.g. to push it to sync relays).
+ registerCacheSavedHook(fn) { this._cacheSavedHooks.push(fn); }
lockedCoinIds() {
const all = new Set();
for (const fn of this._coinLocks) { try { for (const id of fn()) all.add(id); } catch {} }
@@ -1549,13 +1194,6 @@ export class Wallet {
- // Our nostr identity (used to DM a locked gift's claim code to the recipient).
- nostrPubkey() { return (this.nostr && this.nostr.pk) || null; }
- nostrNpub() { const pk = this.nostrPubkey(); return pk ? npubOf(pk) : null; }
- // Send a nostr DM (e.g. a locked gift's claim code) to a recipient pubkey.
- async sendNostrDM(recipientPkHex, text) {
- return this.nostr && this.nostr.sk ? this.nostr.sendDM(recipientPkHex, text) : false;
- }
// --- realtime (mempool.space WebSocket) ---------------------------------
@@ -1798,11 +1436,9 @@ export class Wallet {
if (Date.now() - (this._lastPoll || 0) < due) return;
this._lastPoll = Date.now();
this.refreshLive();
- // SP receipts arrive via the indexer WebSocket (_spConnect); only poll for
- // them as a fallback when that socket isn't live.
- if (!this.spLive) this.scanSilentPayments({ silent: true }).catch(() => {});
+ for (const h of this._realtimeHooks) { try { h.poll && h.poll(); } catch {} }
}, 1000);
- this._spConnect(); // live push of silent-payment receipts
+ for (const h of this._realtimeHooks) { try { h.start && h.start(); } catch {} }
// No periodic full re-scan: the WS (and the frontier poll above) keep the
// balance current, and refreshLive's _reconcileHeld catches spends of held
// coins. The only thing neither covers is a deposit to a reused old address,
@@ -2065,7 +1701,7 @@ export class Wallet {
stopRealtime() {
this._wsWant = false;
this.live = false;
- this._spDisconnect();
+ for (const h of this._realtimeHooks) { try { h.stop && h.stop(); } catch {} }
this._rejectPending('realtime stopped'); // fail any in-flight Electrum data calls
this._callQueue = [];
clearTimeout(this._wsRetry);
@@ -2073,7 +1709,6 @@ export class Wallet {
clearInterval(this._pollTimer);
clearInterval(this._deepTimer);
clearInterval(this._hbTimer);
- clearTimeout(this._nostrPubTimer);
if (this._ws) {
try {
this._ws.onclose = null;
@@ -2125,8 +1760,7 @@ export class Wallet {
nextReceiveIndex: this.nextReceiveIndex,
nextChangeIndex: this.nextChangeIndex,
feeRates: this.feeRates,
- spUtxos: this.spUtxos,
- lastSpScan: this.lastSpScan,
+ ...Object.assign({}, ...this._cacheExtensions.map((e) => { try { return e.save(); } catch { return {}; } })),
};
}
@@ -2139,8 +1773,7 @@ export class Wallet {
this.nextReceiveIndex = d.nextReceiveIndex || 0;
this.nextChangeIndex = d.nextChangeIndex || 0;
this.feeRates = d.feeRates || this.feeRates;
- this.spUtxos = d.spUtxos || [];
- this.lastSpScan = d.lastSpScan || 0;
+ for (const e of this._cacheExtensions) { try { e.load(d); } catch {} }
this.addrMap = new Map();
for (const a of [...this.receive, ...this.change]) {
this.addrMap.set(a.address, { chain: a.chain, index: a.index });
@@ -2167,14 +1800,7 @@ export class Wallet {
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.
- const sync = getSyncConfig();
- if (!this.offline && sync.enabled) {
- this.nostr.setRelays(sync.relays);
- clearTimeout(this._nostrPubTimer);
- this._nostrPubTimer = setTimeout(() => this.nostr.publish(snap), 2500);
- }
+ for (const fn of this._cacheSavedHooks) { try { fn(snap); } catch {} }
}
restoreCache() {
@@ -2192,29 +1818,6 @@ export class Wallet {
}
}
- // Pull the latest state from Nostr; apply it if it's newer than what we have.
- // Returns true if state was applied (so the caller can skip a full scan).
- async syncFromNostr() {
- const sync = getSyncConfig();
- if (this.offline || !sync.enabled) return false;
- this.nostr.setRelays(sync.relays);
- let remote;
- try {
- remote = await this.nostr.fetch();
- } catch {
- return false;
- }
- if (!remote) return false;
- if ((remote.savedAt || 0) > (this._savedAt || 0)) {
- this._applySnapshot(remote);
- this.saveCache(); // mirror into localStorage
- this.emit();
- return true;
- }
- // Our local copy is newer (or equal) — push it up so the relay catches up.
- if ((this._savedAt || 0) > (remote.savedAt || 0)) this.saveCache();
- return true; // remote existed, so no full scan needed
- }
// --- offline snapshot ---------------------------------------------------
// Exported on the ONLINE device. Contains no secrets — just what an offline