aboutsummaryrefslogtreecommitdiffstats
path: root/src/wallet.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-06-28 18:34:29 -0700
committerAdam Soltys <[email protected]>2026-06-28 18:34:29 -0700
commit17f203ba3603c3f1ba9c8d14c53d9f10cccf3c81 (patch)
treefd4e7ba72fccc4c8440a58c2f715e311a09231dc /src/wallet.js
parentfea5010f83c013bf25fb50649366b5c91f3c0b3a (diff)
parent374f08e28a1bb42fb8911d34126043b69f286410 (diff)
downloadcoinosv3-17f203ba3603c3f1ba9c8d14c53d9f10cccf3c81.tar.gz
coinosv3-17f203ba3603c3f1ba9c8d14c53d9f10cccf3c81.zip
Merge branch 'master' into boltz-swaps
# Conflicts: # src/api.js # src/app.js
Diffstat (limited to 'src/wallet.js')
-rw-r--r--src/wallet.js570
1 files changed, 537 insertions, 33 deletions
diff --git a/src/wallet.js b/src/wallet.js
index a92b0ce..bc70561 100644
--- a/src/wallet.js
+++ b/src/wallet.js
@@ -21,10 +21,11 @@ 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 } from './api.js';
+import { Api, pool, getBackend, explorerWeb, electrumCandidates, spIndexerUrl } from './api.js';
import { ElectrumApi } from './electrum.js';
-import { NostrSync, getSyncConfig } from './nostr.js';
-import { isSilentPaymentAddress, decodeSilentPaymentAddress, silentPaymentScripts, silentPaymentPlaceholder } from './silentpay.js';
+import { NostrSync, getSyncConfig, encryptGiftPayload, npubOf } from './nostr.js';
+import { isSilentPaymentAddress, decodeSilentPaymentAddress, silentPaymentScripts, silentPaymentPlaceholder, deriveSilentPaymentKeys, encodeSilentPaymentAddress, silentPaymentScan, silentPaymentOutputPrivKey, silentPaymentCandidate, bloomHas } from './silentpay.js';
+import { schnorr } from '@noble/curves/secp256k1';
// No look-ahead: stop scanning a chain at the first unused address. This wallet
// only ever exposes ONE unused address at a time (freshReceive = first unused;
@@ -38,6 +39,11 @@ const USED_HIT_DELAY_MS = 500;
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 = {
mainnet: { net: btc.NETWORK, coin: 0 },
testnet: { net: btc.TEST_NETWORK, coin: 1 },
@@ -102,6 +108,12 @@ 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
@@ -146,8 +158,11 @@ export class Wallet {
}
// --- setup --------------------------------------------------------------
- load({ mnemonic = '', passphrase = '', xpub = '', xprv = '', netName = 'mainnet', offline = false }) {
+ load({ mnemonic = '', passphrase = '', xpub = '', xprv = '', netName = 'mainnet', offline = false, spFresh = false }) {
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 || '';
@@ -178,6 +193,8 @@ 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;
@@ -255,6 +272,349 @@ 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;
+ }
+
+ // 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()];
+ 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);
+ return (b.firstSeen || 0) - (a.firstSeen || 0);
+ });
+ 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(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).
return this.account().deriveChild(chain).deriveChild(index);
@@ -616,6 +976,7 @@ export class Wallet {
}
this.loaded = true;
this.saveCache();
+ this.scanSilentPayments({ silent: true }).catch(() => {}); // fold in any silent-payment receipts
} finally {
this._refreshing = false;
this.historyLoading = false; // clear even if an earlier step threw
@@ -769,13 +1130,14 @@ export class Wallet {
if (res.has(utxoId(u))) return s;
if (u.confirmed || u.chain === 1) return s + u.value;
return s;
- }, 0);
+ }, 0) + this.spBalance().confirmed; // confirmed silent-payment receipts are spendable
}
// Unconfirmed incoming receives (not our change, not gift-locked) — shown as a
- // separate pending line, kept out of the spendable headline.
+ // separate pending line, kept out of the spendable headline. Includes pending
+ // (mempool) silent-payment receipts.
get pendingIncoming() {
const res = this.reservedSet();
- return this.utxos.reduce((s, u) => s + ((!u.confirmed && u.chain === 0 && !res.has(utxoId(u))) ? u.value : 0), 0);
+ return this.utxos.reduce((s, u) => s + ((!u.confirmed && u.chain === 0 && !res.has(utxoId(u))) ? u.value : 0), 0) + this.spBalance().pending;
}
_recomputeBalanceFromChains() {
@@ -785,13 +1147,15 @@ export class Wallet {
c += a.confirmed || 0;
p += a.pending || 0;
}
- this.confirmed = c;
- this.pending = p;
+ const sp = this.spBalance();
+ this.confirmed = c + sp.confirmed;
+ this.pending = p + sp.pending;
}
_recomputeBalanceFromUtxos() {
- this.confirmed = this.utxos.reduce((s, u) => s + (u.confirmed ? u.value : 0), 0);
- this.pending = this.utxos.reduce((s, u) => s + (u.confirmed ? 0 : u.value), 0);
+ 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;
}
freshReceive() {
@@ -809,19 +1173,39 @@ export class Wallet {
const pool_ = coinIds
? this.utxos.filter((u) => coinIds.includes(utxoId(u)))
: this.utxos.filter((u) => !this.isReserved(utxoId(u))); // skip coins set aside for gifts
- if (!pool_.length) throw new Error('No spendable coins selected.');
+ // 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.');
- const inputs = pool_.map((u) => {
- const { script, pubkey } = this.derive(u.chain, u.index);
- const pay = p2wpkh(pubkey, this.netCfg.net);
- return {
- ...pay,
+ const inputs = [
+ ...pool_.map((u) => {
+ const { pubkey } = this.derive(u.chain, u.index);
+ const pay = p2wpkh(pubkey, this.netCfg.net);
+ return {
+ ...pay,
+ txid: u.txid,
+ index: u.vout,
+ sequence: 0xfffffffd, // signal opt-in RBF (BIP125) so sends are bumpable
+ witnessUtxo: { script: pay.script, amount: BigInt(u.value) },
+ };
+ }),
+ ...spPool.map((u) => ({
txid: u.txid,
index: u.vout,
- sequence: 0xfffffffd, // signal opt-in RBF (BIP125) so sends are bumpable
- witnessUtxo: { script: pay.script, amount: BigInt(u.value) },
- };
- });
+ 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),
+ })),
+ ];
const changeAddress = this.freshChange().address;
const feePerByte = BigInt(Math.max(1, Math.round(feeRate)));
@@ -852,6 +1236,7 @@ export class Wallet {
network: this.netCfg.net,
createTx: true,
bip69: true,
+ disableScriptCheck: true,
});
if (!sel || !sel.tx) throw new Error('Fee exceeds balance.');
if (spOuts.length) this._applySilentPayments(sel.tx, spOuts);
@@ -875,7 +1260,8 @@ export class Wallet {
feePerByte,
network: this.netCfg.net,
createTx: true,
- bip69: true,
+ bip69: true,
+ disableScriptCheck: true,
});
if (!sel || !sel.tx)
throw new Error('Insufficient funds for amount + fee.');
@@ -900,13 +1286,22 @@ export class Wallet {
// actual inputs per BIP-352.
_applySilentPayments(tx, spOuts) {
const byOutpoint = new Map();
- for (const u of this.utxos) byOutpoint.set(utxoId(u), u);
+ 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 u = byOutpoint.get(`${hex.encode(inp.txid)}:${inp.index}`);
- if (!u) throw new Error('Missing key for a silent payment input.');
- inputs.push({ txid: u.txid, vout: u.vout, priv: this.node(u.chain, u.index).privateKey });
+ 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) => {
@@ -930,18 +1325,39 @@ export class Wallet {
return false;
}
- // Sign every input of an already-built Transaction with the matching HD key.
+ // Sign every input of an already-built Transaction. BIP84 inputs sign with the
+ // matching HD key; received silent-payment outputs are taproot key-path spends
+ // signed manually with d = spend_priv + t_k (the output key is used raw, with
+ // no BIP-341 tweak, so btc-signer's auto key-path signing doesn't apply).
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);
+ 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.
+ const prevScripts = [];
+ const amounts = [];
+ for (let i = 0; i < tx.inputsLength; i++) {
+ const w = tx.getInput(i).witnessUtxo;
+ prevScripts.push(w.script);
+ amounts.push(w.amount);
+ }
+ const spKeys = sp.size ? this.silentPaymentKeys() : null;
for (let i = 0; i < tx.inputsLength; i++) {
const inp = tx.getInput(i);
const id = `${hex.encode(inp.txid)}:${inp.index}`;
- const u = byOutpoint.get(id);
- if (!u) throw new Error(`Cannot find key for input ${id}`);
- const node = this.node(u.chain, u.index);
- tx.signIdx(node.privateKey, i);
+ 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 {
+ throw new Error(`Cannot find key for input ${id}`);
+ }
}
tx.finalize();
return tx.hex;
@@ -1153,6 +1569,32 @@ export class Wallet {
}));
}
+ // Persistent gift records (per account): the shareable link/code + amount, so a
+ // gift's link/QR can be re-viewed later and a claimed one identified in history.
+ _giftRecordsKey() { return this._cacheKey() + ':giftrec'; }
+ giftRecords() {
+ if (!this._giftRecords) { try { this._giftRecords = JSON.parse(localStorage.getItem(this._giftRecordsKey()) || '{}'); } catch { this._giftRecords = {}; } }
+ return this._giftRecords;
+ }
+ _saveGiftRecords() { try { localStorage.setItem(this._giftRecordsKey(), JSON.stringify(this.giftRecords())); } catch {} }
+ recordGift({ code, locked, amount, claimCode, outpoints }) {
+ if (!outpoints || !outpoints.length) return;
+ this.giftRecords()[outpoints[0]] = { id: outpoints[0], code, locked: !!locked, amount, claimCode: claimCode || null, outpoints, created: Date.now(), revoked: false };
+ this._saveGiftRecords();
+ }
+ giftLink(id) { return this.giftRecords()[id] || null; }
+ markGiftRevoked(id) { const r = this.giftRecords()[id]; if (r) { r.revoked = true; this._saveGiftRecords(); } }
+ // Gifts whose coin has been spent and weren't revoked by us → claimed. (Only
+ // once scanned, so an un-synced wallet doesn't mislabel everything as claimed.)
+ claimedGifts() {
+ if (!this.loaded) return [];
+ const live = new Set(this.utxos.map((u) => utxoId(u)));
+ return Object.values(this.giftRecords())
+ .filter((r) => !r.revoked && r.outpoints && !r.outpoints.some((o) => live.has(o)))
+ .map((r) => ({ id: r.id, amount: r.amount, locked: r.locked, created: r.created }))
+ .sort((a, b) => (b.created || 0) - (a.created || 0));
+ }
+
// Best-fit coin selection for a gift of `gift` sats (needs gift + a dust
// change): the smallest single confirmed coin that covers it — one input and
// the least value locked until claim — else the fewest large coins. Returns
@@ -1181,6 +1623,29 @@ export class Wallet {
return sel ? sel.reduce((s, u) => s + u.value, 0) : null;
}
+ // The coins a gift of this amount would lock: total value + how many inputs.
+ // A high input count means a costly-to-claim gift (each input adds to the
+ // claimer's fee) — the UI uses it to recommend splitting/consolidating first.
+ giftCoinSummary(amountSats) {
+ const sel = this._selectGiftCoins(BigInt(Math.round(amountSats)));
+ return sel ? { lock: sel.reduce((s, u) => s + u.value, 0), count: sel.length } : null;
+ }
+
+ // Number of unreserved spendable coins (for the consolidate prompt/action).
+ spendableCoinCount() {
+ return this.utxos.filter((u) => !this.isReserved(utxoId(u)) && (u.confirmed || u.chain === 1)).length;
+ }
+
+ // Merge all unreserved spendable coins into one, via a max self-send. Leaves
+ // the wallet with a single coin so future sends/gifts use one input.
+ async consolidate(feeRate) {
+ const rate = Math.max(1, Math.round(feeRate));
+ const draft = this.buildTx({ recipients: [{ address: this.freshReceive().address }], feeRate: rate, sendMax: true });
+ const hex = this.sign(draft.tx);
+ await this.broadcast(hex);
+ return { txid: draft.tx.id, inputs: draft.inputsCount };
+ }
+
// Build a signed gift from already-chosen coins. If the gift leaves a viable
// (>= dust) change, output0 is that change back to us, cryptographically fixed
// by signing input0 SIGHASH_SINGLE (the rest SIGHASH_NONE) — so our change is
@@ -1232,6 +1697,14 @@ export class Wallet {
return this._buildGiftPsbt(sel, gift);
}
+ // 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;
+ }
+
// Split + gift in one shot, with no confirmation wait: broadcast a self-send
// that carves out a gift+dust coin, then build the gift spending that
// still-unconfirmed carve-out. The funding and the claim form a chain that
@@ -1494,7 +1967,11 @@ 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(() => {});
}, 1000);
+ this._spConnect(); // live push of silent-payment receipts
// 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,
@@ -1757,6 +2234,7 @@ export class Wallet {
stopRealtime() {
this._wsWant = false;
this.live = false;
+ this._spDisconnect();
this._rejectPending('realtime stopped'); // fail any in-flight Electrum data calls
this._callQueue = [];
clearTimeout(this._wsRetry);
@@ -1816,6 +2294,8 @@ export class Wallet {
nextReceiveIndex: this.nextReceiveIndex,
nextChangeIndex: this.nextChangeIndex,
feeRates: this.feeRates,
+ spUtxos: this.spUtxos,
+ lastSpScan: this.lastSpScan,
};
}
@@ -1828,6 +2308,8 @@ 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;
this.addrMap = new Map();
for (const a of [...this.receive, ...this.change]) {
this.addrMap.set(a.address, { chain: a.chain, index: a.index });
@@ -2157,6 +2639,28 @@ export function buildClaimTx(code, toAddress, feeRate, net) {
return { hex: tx.hex, txid: tx.id, amount: Number(out), fee: Number(fee) };
}
+// A gift locked to a recipient's nostr key. The amount + recipient pubkey are
+// public (anyone can see what it is and who it's for), but the claim payload (the
+// gift code) is NIP-44-encrypted to the recipient via an ephemeral key — so the
+// link can be shared openly yet only the recipient's nostr key can claim it.
+const LOCKED_GIFT_VERSION = 1;
+// Encrypt the gift's claim payload under a one-time code. The code is delivered
+// to the recipient via a nostr DM; the public link carries only the ciphertext.
+// Returns { blob (for the /lg/ link), claimCode (to DM) }.
+export function lockGift(code, amount, recipientPkHex) {
+ const { code: claimCode, ctCode, eph, ctKey } = encryptGiftPayload(code, recipientPkHex);
+ const blob = base64urlnopad.encode(new TextEncoder().encode(JSON.stringify({ v: LOCKED_GIFT_VERSION, amount, to: recipientPkHex, ct: ctCode, eph, ctKey })));
+ return { blob, claimCode };
+}
+// The public fields of a locked-gift blob (or null). Decryption needs the code.
+export function previewLockedGift(blob) {
+ try {
+ const o = JSON.parse(new TextDecoder().decode(base64urlnopad.decode(blob)));
+ if (o.v === LOCKED_GIFT_VERSION && typeof o.amount === 'number' && o.to && o.ct) return o;
+ } catch {}
+ return null;
+}
+
function firstUnused(chain) {
const u = chain.find((a) => !a.used);
return u ? u.index : chain.length;