aboutsummaryrefslogtreecommitdiffstats
path: root/src/features/sp-wallet.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/sp-wallet.js')
-rw-r--r--src/features/sp-wallet.js467
1 files changed, 467 insertions, 0 deletions
diff --git a/src/features/sp-wallet.js b/src/features/sp-wallet.js
new file mode 100644
index 0000000..c2d1cdb
--- /dev/null
+++ b/src/features/sp-wallet.js
@@ -0,0 +1,467 @@
+// Silent payments (BIP-352) wallet engine — key derivation, indexer scanning
+// (worker-accelerated with an inline fallback), the live indexer WebSocket,
+// SP outputs as spendable coins, sp1 send preparation, and per-output-key
+// signing. Installed onto the core wallet by the sp feature, wired through the
+// wallet's generic seams so a build without sp ships none of this.
+
+import * as btc from '@scure/btc-signer';
+import { hex } from '@scure/base';
+import { schnorr } from '@noble/curves/secp256k1';
+import { HDKey } from '@scure/bip32';
+import { mnemonicToSeedSync } from '@scure/bip39';
+import {
+ isSilentPaymentAddress, decodeSilentPaymentAddress, silentPaymentScripts,
+ silentPaymentPlaceholder, deriveSilentPaymentKeys, encodeSilentPaymentAddress,
+ silentPaymentScan, silentPaymentOutputPrivKey, silentPaymentCandidate, bloomHas,
+} from '../silentpay.js';
+import { spIndexerUrl } from '../api.js';
+import { utxoId, absWsUrl, NETS } from '../wallet.js';
+
+const SP_DUST_LIMIT = 0;
+
+export function installSpWallet(wallet) {
+ if (wallet.scanSilentPayments) return; // already installed
+ wallet.spUtxos = [];
+ wallet.lastSpScan = 0; // highest block height scanned for silent payments
+ wallet.spScanning = false;
+
+ Object.assign(wallet, {
+ // 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,
+ }));
+ },
+
+ // 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;
+ }
+ },
+
+ // 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]));
+ });
+ },
+ });
+
+ // -- seam registrations -----------------------------------------------
+ wallet.registerBalanceExtra(() => wallet.spBalance());
+ wallet.registerHistoryProvider(() => wallet.spTxRows());
+ wallet.registerScanHook(() => wallet.scanSilentPayments({ silent: true }).catch(() => {}));
+ wallet.registerRealtimeHook({
+ start: () => wallet._spConnect(), // live push of silent-payment receipts
+ stop: () => wallet._spDisconnect(),
+ // WS is primary; the heartbeat poll is the fallback when it isn't live.
+ poll: () => { if (!wallet.spLive) wallet.scanSilentPayments({ silent: true }).catch(() => {}); },
+ });
+ wallet.registerLoadHook(({ spFresh }) => {
+ // A freshly-generated wallet can't have received silent payments before it
+ // existed, so its SP watermark starts at the current tip (no history scan).
+ wallet._spFresh = !!spFresh;
+ wallet.spUtxos = [];
+ wallet.lastSpScan = 0;
+ });
+ wallet.registerCacheExtension({
+ save: () => ({ spUtxos: wallet.spUtxos, lastSpScan: wallet.lastSpScan }),
+ load: (d) => { wallet.spUtxos = d.spUtxos || []; wallet.lastSpScan = d.lastSpScan || 0; },
+ });
+ // 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).
+ wallet.registerInputProvider(({ coinIds }) => {
+ const confirmedSp = wallet.spUtxos.filter((u) => u.confirmed);
+ const pool = coinIds ? confirmedSp.filter((u) => coinIds.includes(utxoId(u))) : confirmedSp;
+ return pool.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 inputs are signed raw below, via tapKeySig).
+ tapInternalKey: hex.decode(u.xonly),
+ }));
+ });
+ // sp1… recipients: the real taproot output commits to the final input set,
+ // so buildTx selects against a placeholder and swaps the derived script in.
+ wallet.registerSendPreparer(() => {
+ const spOuts = []; // { placeholder, scan, spend, address }
+ return {
+ match: (addr) => isSilentPaymentAddress(addr),
+ prepare(addr) {
+ if (wallet.watchOnly) throw new Error('Silent payments require a wallet with keys.');
+ const { scan, spend } = decodeSilentPaymentAddress(addr);
+ const placeholder = silentPaymentPlaceholder(spOuts.length);
+ spOuts.push({ placeholder, scan, spend, address: addr });
+ return placeholder;
+ },
+ apply(tx) { if (spOuts.length) wallet._applySilentPayments(tx, spOuts); },
+ tag(summary) { wallet._tagSilentOutputs(summary, spOuts); },
+ };
+ });
+ // Received SP outputs sign with d = spend_priv + t_k (raw output key —
+ // no BIP-341 tweak, so btc-signer's auto key-path signing doesn't apply).
+ wallet.registerInputSigner((tx, i, id, { prevScripts, amounts }) => {
+ const u = wallet.spUtxos.find((x) => utxoId(x) === id);
+ if (!u) return false;
+ const d = silentPaymentOutputPrivKey(wallet.silentPaymentKeys().spendPriv, hex.decode(u.tweak));
+ const hash = tx.preimageWitnessV1(i, prevScripts, btc.SigHash.DEFAULT, amounts);
+ tx.updateInput(i, { tapKeySig: schnorr.sign(hash, d) }, true);
+ return true;
+ });
+}